$result ) { if ( is_wp_error( $result ) ) { $errors[] = $name . ': ' . $result->get_error_message(); continue; } $counts[ $name ] = count( $result ); foreach ( $result as $id => $a ) { // A CVE known to be exploited (CISA) is not replaced by its NVD entry; it only gets the score. if ( 'nvd' === $a['source'] && isset( $new[ $id ] ) && 'cisa' === $new[ $id ]['source'] ) { $new[ $id ]['cvss'] = $a['cvss']; continue; } if ( 'cisa' === $a['source'] && isset( $new[ $id ]['cvss'] ) ) { $a['cvss'] = $new[ $id ]['cvss']; } $new[ $id ] = $a; } } foreach ( $new as $id => $a ) { if ( empty( $s[ $a['source'] ] ) || $a['date'] < $limit ) { unset( $new[ $id ] ); continue; } $new[ $id ]['seen'] = $old[ $id ]['seen'] ?? time(); if ( 'nvd' === $a['source'] ) { // Re-check stored NVD entries against the current rules. $product = empty( $a['cna'] ) ? '' : cpradar_nvd_product( $a['cna'], (string) $a['text'] ); if ( '' === $product || (float) $a['cvss'] < (float) $s['cvss'] ) { unset( $new[ $id ] ); continue; } $new[ $id ]['product'] = $product; /* translators: 1: product name, 2: CVSS score */ $new[ $id ]['title'] = sprintf( __( '%1$s: critical vulnerability (CVSS %2$s)', 'syncnet-patch-radar' ), $product, cpradar_score( $a['cvss'] ) ); } elseif ( ! cpradar_matches( $a['title'] . ' ' . ( $a['product'] ?? '' ) ) ) { unset( $new[ $id ] ); } } uasort( $new, fn( $a, $b ) => $b['date'] <=> $a['date'] ); $new = array_slice( $new, 0, 600, true ); update_option( 'cpradar_alerts', $new, false ); update_option( 'cpradar_status', array( 'time' => time(), 'count' => count( $new ), 'errors' => $errors, 'sources' => $counts, ), false ); delete_transient( 'cpradar_running' ); return $new; } /** * Stored alerts, newest first. * * @return array */ function cpradar_alerts() { static $alerts = null; if ( null === $alerts ) { $alerts = (array) get_option( 'cpradar_alerts', array() ); // NVD titles are built from data, so they follow the current site language. foreach ( $alerts as $id => $a ) { if ( 'nvd' === ( $a['source'] ?? '' ) && ! empty( $a['product'] ) ) { /* translators: 1: product name, 2: CVSS score */ $alerts[ $id ]['title'] = sprintf( __( '%1$s: critical vulnerability (CVSS %2$s)', 'syncnet-patch-radar' ), $a['product'], cpradar_score( $a['cvss'] ) ); } } } return $alerts; } /** * Format a CVSS score for display (9,1 or 9.1 depending on locale). * * @param float $score Score. * @return string */ function cpradar_score( $score ) { return number_format_i18n( (float) $score, 1 ); } /** * NVD API key from the settings, or an empty string once the NVD has rejected it during this run. * * @param bool $reject Mark the key as rejected for the rest of this run. * @return string */ function cpradar_nvd_key( $reject = false ) { static $rejected = false; if ( $reject ) { $rejected = true; } return $rejected ? '' : trim( (string) cpradar_settings()['nvd_key'] ); } /** * HTTP GET returning the body or a WP_Error. * * If the NVD rejects the API key (invalid or not yet confirmed), the request is repeated * without the key and the key is no longer used during this run. * * @param string $url URL. * @param array $headers Extra headers. * @return string|WP_Error */ function cpradar_get( $url, $headers = array() ) { $response = wp_remote_get( $url, array( 'timeout' => 25, 'user-agent' => 'SyncNet-Patch-Radar/' . CPRADAR_VERSION . ' (WordPress plugin)', 'headers' => $headers, ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( isset( $headers['apiKey'] ) ) { if ( in_array( $code, array( 401, 403, 404 ), true ) ) { cpradar_nvd_key( true ); update_option( 'cpradar_nvd_key_error', array( time(), $code ), false ); unset( $headers['apiKey'] ); sleep( 6 ); return cpradar_get( $url, $headers ); } if ( 200 === $code ) { delete_option( 'cpradar_nvd_key_error' ); } } if ( 200 !== $code ) { /* translators: %d: HTTP status code */ return new WP_Error( 'cpradar_http', sprintf( __( 'HTTP status %d', 'syncnet-patch-radar' ), $code ) ); } return wp_remote_retrieve_body( $response ); } // BSI / CERT-Bund (German): only severity "kritisch". /** * Fetch BSI advisories. * * @return array|WP_Error */ function cpradar_fetch_bsi() { $body = cpradar_get( CPRADAR_BSI_URL ); return is_wp_error( $body ) ? $body : cpradar_parse_bsi( $body ); } /** * Parse the BSI RSS feed. * * @param string $body RSS XML. * @return array|WP_Error */ function cpradar_parse_bsi( $body ) { $prev = libxml_use_internal_errors( true ); $xml = simplexml_load_string( $body, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOCDATA ); libxml_use_internal_errors( $prev ); if ( ! $xml || ! isset( $xml->channel->item ) ) { return new WP_Error( 'cpradar_xml', __( 'Feed could not be read', 'syncnet-patch-radar' ) ); } $out = array(); foreach ( $xml->channel->item as $item ) { $title = trim( (string) $item->title ); $severity = strtolower( trim( (string) $item->category ) ); $kind = ''; if ( preg_match( '/^\s*\[(NEU|UPDATE)\]\s*\[([^\]]+)\]\s*(.+)$/iu', $title, $t ) ) { $kind = strtoupper( $t[1] ); $severity = $severity ? $severity : strtolower( $t[2] ); $title = trim( $t[3] ); } if ( 'kritisch' !== $severity ) { continue; } $link = (string) $item->link; if ( ! preg_match( '/name=(WID-SEC-\d{4}-\d+)/i', $link, $n ) ) { continue; } $id = strtoupper( $n[1] ); $out[ $id ] = array( 'id' => $id, 'source' => 'bsi', 'kind' => $kind, 'title' => $title, 'text' => wp_strip_all_tags( html_entity_decode( (string) $item->description, ENT_QUOTES, 'UTF-8' ) ), 'date' => strtotime( (string) $item->pubDate ) ? strtotime( (string) $item->pubDate ) : time(), // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- RSS element name. 'link' => esc_url_raw( $link ), ); } return $out; } // CISA Known Exploited Vulnerabilities. /** * Fetch the CISA KEV catalog. * * @param int $limit Oldest timestamp to keep. * @return array|WP_Error */ function cpradar_fetch_cisa( $limit ) { $body = cpradar_get( CPRADAR_CISA_URL ); return is_wp_error( $body ) ? $body : cpradar_parse_cisa( $body, $limit ); } /** * Parse the CISA KEV JSON. * * @param string $body JSON. * @param int $limit Oldest timestamp to keep. * @return array|WP_Error */ function cpradar_parse_cisa( $body, $limit ) { $data = json_decode( $body, true ); if ( empty( $data['vulnerabilities'] ) || ! is_array( $data['vulnerabilities'] ) ) { return new WP_Error( 'cpradar_json', __( 'List could not be read', 'syncnet-patch-radar' ) ); } $out = array(); foreach ( $data['vulnerabilities'] as $v ) { $date = strtotime( ( $v['dateAdded'] ?? '' ) . ' 12:00:00 UTC' ); if ( ! $date || $date < $limit || empty( $v['cveID'] ) ) { continue; } $id = strtoupper( $v['cveID'] ); $vendor = (string) ( $v['vendorProject'] ?? '' ); $product = trim( $vendor . ' ' . ( $v['product'] ?? '' ) ); $name = trim( (string) ( $v['vulnerabilityName'] ?? '' ) ); $title = ( '' !== $name && '' !== $vendor && 0 === stripos( $name, $vendor ) ) ? $name : $product . ': ' . ( $name ? $name : $id ); $out[ $id ] = array( 'id' => $id, 'source' => 'cisa', 'kind' => '', 'title' => $title, 'product' => $product, 'text' => trim( (string) ( $v['shortDescription'] ?? '' ) ), 'date' => $date, 'link' => 'https://nvd.nist.gov/vuln/detail/' . rawurlencode( $id ), 'ransom' => ( 'Known' === ( $v['knownRansomwareCampaignUse'] ?? '' ) ), ); } return $out; } // NVD: CVSS >= threshold, network, no privileges, reported by the vendor itself. /** * Vendors whose own security teams (CNAs) report vulnerabilities – domain of the NVD "sourceIdentifier". * * @return array domain => vendor name */ function cpradar_nvd_vendors() { return apply_filters( 'cpradar_nvd_vendors', array( 'microsoft.com' => 'Microsoft', 'oracle.com' => 'Oracle', 'fortinet.com' => 'Fortinet', 'cisco.com' => 'Cisco', 'paloaltonetworks.com' => 'Palo Alto', 'ivanti.com' => 'Ivanti', 'citrix.com' => 'Citrix', 'cloud.com' => 'Citrix', 'netscaler.com' => 'Citrix', 'vmware.com' => 'VMware', 'broadcom.com' => 'VMware', 'apple.com' => 'Apple', 'google.com' => 'Google', 'chromium.org' => 'Google', 'mozilla.org' => 'Mozilla', 'adobe.com' => 'Adobe', 'sap.com' => 'SAP', 'atlassian.com' => 'Atlassian', 'f5.com' => 'F5', 'sonicwall.com' => 'SonicWall', 'watchguard.com' => 'WatchGuard', 'sophos.com' => 'Sophos', 'juniper.net' => 'Juniper', 'checkpoint.com' => 'Check Point', 'veeam.com' => 'Veeam', 'synology.com' => 'Synology', 'qnap.com' => 'QNAP', 'zoom.us' => 'Zoom', 'teamviewer.com' => 'TeamViewer', 'apache.org' => 'Apache', 'openssl.org' => 'OpenSSL', 'php.net' => 'PHP', 'bitdefender.com' => 'Bitdefender', ) ); } /** * For vendors with a huge portfolio only their major products count. Empty = all products. * * @return array vendor => product keywords */ function cpradar_nvd_product_scope() { return apply_filters( 'cpradar_nvd_product_scope', array( 'Oracle' => array( 'Java', 'WebLogic', 'MySQL', 'VirtualBox' ), 'Google' => array( 'Chrome' ), 'Mozilla' => array( 'Firefox', 'Thunderbird' ), 'Apache' => array( 'Tomcat', 'Struts', 'HTTP Server', 'Log4j', 'ActiveMQ' ), 'Adobe' => array( 'Acrobat', 'Reader' ), 'Atlassian' => array( 'Confluence', 'Jira', 'Bitbucket' ), ) ); } /** * Vendor for an NVD source identifier ("secalert_us@oracle.com" → "Oracle"). * * @param string $source Source identifier. * @return string Vendor or ''. */ function cpradar_nvd_cna( $source ) { $domain = strtolower( (string) substr( (string) strrchr( (string) $source, '@' ), 1 ) ); foreach ( cpradar_nvd_vendors() as $d => $name ) { if ( $domain === $d || str_ends_with( $domain, '.' . $d ) ) { return $name; } } return ''; } /** * Product label for an NVD entry of vendor $cna, or '' if it does not match scope and product list. * * @param string $cna Vendor. * @param string $text English description. * @return string */ function cpradar_nvd_product( $cna, $text ) { $scope = cpradar_nvd_product_scope()[ $cna ] ?? array(); $list = array_map( 'mb_strtolower', cpradar_products() ); if ( ! $scope ) { $hits = cpradar_matches( $text, true ); if ( ! in_array( mb_strtolower( $cna ), $list, true ) && ! $hits ) { return ''; } $more = array_values( array_diff( $hits, array( $cna ) ) ); return trim( $cna . ' ' . ( $more[0] ?? '' ) ); } foreach ( $scope as $product ) { if ( preg_match( '/(? 'CRITICAL' ), '', $start, $budget + 7, $limit, $cvss ); $out = array_merge( $out, $part2['data'] ); if ( $part2['done'] ) { update_option( 'cpradar_nvd_mod_done', $now, false ); } } return $out; } /** * Wait between NVD requests (rate limit: without key a few requests per 30 seconds). */ function cpradar_nvd_pause() { sleep( '' !== cpradar_nvd_key() ? 1 : 6 ); } /** * Fetch page by page until done or the time budget is used up (continues on the next run). * * @param string $field "pub" or "lastMod". * @param int $from Start timestamp. * @param int $to End timestamp. * @param array $extra Extra query args. * @param string $index_option Option storing the resume index ('' = none). * @param float $start Start time (microtime). * @param int $budget Seconds. * @param int $limit Oldest timestamp to keep. * @param float $cvss Minimum score. * @return array { data, error, done } */ function cpradar_nvd_pages( $field, $from, $to, $extra, $index_option, $start, $budget, $limit, $cvss ) { $index = $index_option ? (int) get_option( $index_option, 0 ) : 0; $data = array(); $error = null; $done = false; while ( true ) { $key = cpradar_nvd_key(); $headers = '' !== $key ? array( 'apiKey' => $key ) : array(); $url = add_query_arg( array_merge( array( $field . 'StartDate' => gmdate( 'Y-m-d\TH:i:s.000', $from ), $field . 'EndDate' => gmdate( 'Y-m-d\TH:i:s.000', $to ), 'resultsPerPage' => 500, 'startIndex' => $index, ), $extra ), CPRADAR_NVD_URL ); $body = cpradar_get( $url, $headers ); if ( is_wp_error( $body ) ) { $error = $body; break; } $json = json_decode( $body, true ); unset( $body ); if ( ! is_array( $json ) || ! isset( $json['vulnerabilities'] ) ) { $error = new WP_Error( 'cpradar_json', __( 'Response could not be read', 'syncnet-patch-radar' ) ); break; } $data = array_merge( $data, cpradar_parse_nvd( $json, $limit, $cvss ) ); $count = (int) ( $json['resultsPerPage'] ?? 0 ); $total = (int) ( $json['totalResults'] ?? 0 ); unset( $json ); $index += $count; if ( $index_option ) { update_option( 'cpradar_nvd_progress', array( $index, $total ), false ); } if ( ! $count || $index >= $total ) { $done = true; if ( $index_option ) { delete_option( $index_option ); } break; } if ( microtime( true ) - $start > $budget - 7 ) { if ( $index_option ) { update_option( $index_option, $index, false ); } break; } cpradar_nvd_pause(); } return array( 'data' => $data, 'error' => $error, 'done' => $done, ); } /** * Evaluate one NVD response page. * * @param array $data Decoded JSON. * @param int $limit Oldest timestamp to keep. * @param float $cvss Minimum score. * @return array */ function cpradar_parse_nvd( $data, $limit, $cvss ) { $out = array(); foreach ( (array) $data['vulnerabilities'] as $entry ) { $c = $entry['cve'] ?? array(); $id = strtoupper( (string) ( $c['id'] ?? '' ) ); $date = strtotime( (string) ( $c['published'] ?? '' ) . 'Z' ); if ( ! $id || ! $date || $date < $limit || 'Rejected' === ( $c['vulnStatus'] ?? '' ) ) { continue; } $cna = cpradar_nvd_cna( $c['sourceIdentifier'] ?? '' ); if ( '' === $cna ) { continue; } $best = cpradar_nvd_best_metric( $c ); if ( ! $best || (float) $best['baseScore'] < $cvss || 'NETWORK' !== ( $best['attackVector'] ?? '' ) || 'NONE' !== ( $best['privilegesRequired'] ?? '' ) ) { continue; } $text = cpradar_nvd_description( $c ); $product = cpradar_nvd_product( $cna, $text ); if ( '' === $product ) { continue; } $out[ $id ] = array( 'id' => $id, 'source' => 'nvd', 'kind' => '', /* translators: 1: product name, 2: CVSS score */ 'title' => sprintf( __( '%1$s: critical vulnerability (CVSS %2$s)', 'syncnet-patch-radar' ), $product, cpradar_score( $best['baseScore'] ) ), 'product' => $product, 'text' => $text, 'date' => $date, 'link' => 'https://nvd.nist.gov/vuln/detail/' . rawurlencode( $id ), 'cvss' => (float) $best['baseScore'], 'cna' => $cna, ); } return $out; } /** * Highest CVSS v3 metric (NVD or vendor). * * @param array $c CVE record. * @return array|null cvssData */ function cpradar_nvd_best_metric( $c ) { $best = null; foreach ( array( 'cvssMetricV31', 'cvssMetricV30' ) as $type ) { foreach ( (array) ( $c['metrics'][ $type ] ?? array() ) as $metric ) { $d = $metric['cvssData'] ?? array(); if ( ! $best || (float) ( $d['baseScore'] ?? 0 ) > (float) $best['baseScore'] ) { $best = $d; } } } return $best; } /** * English description of a CVE record. * * @param array $c CVE record. * @return string */ function cpradar_nvd_description( $c ) { foreach ( (array) ( $c['descriptions'] ?? array() ) as $d ) { if ( 'en' === ( $d['lang'] ?? '' ) ) { return trim( (string) $d['value'] ); } } return ''; } // Grouping and vendor names. /** * Get one alert or product group by ID. * * @param string $id ID. * @return array|null */ function cpradar_alert( $id ) { $id = strtoupper( (string) $id ); if ( 0 === strpos( $id, 'PRODUCT-' ) ) { return cpradar_groups()[ $id ] ?? null; } return cpradar_alerts()[ $id ] ?? null; } /** * NVD entries grouped per product (BSI and CISA stay single; a product with one entry stays single). * * @return array */ function cpradar_groups() { static $groups = null; if ( null !== $groups ) { return $groups; } $collect = array(); foreach ( cpradar_alerts() as $a ) { if ( 'nvd' === $a['source'] && ! empty( $a['product'] ) ) { $collect[ 'PRODUCT-' . strtoupper( sanitize_title( $a['product'] ) ) ][] = $a; } } $groups = array(); foreach ( $collect as $id => $list ) { if ( count( $list ) < 2 ) { continue; } usort( $list, fn( $a, $b ) => $b['date'] <=> $a['date'] ); $max = max( array_map( fn( $x ) => (float) $x['cvss'], $list ) ); $count = count( $list ); $name = $list[0]['product']; $groups[ $id ] = array( 'id' => $id, 'source' => 'group', 'kind' => '', 'product' => $name, /* translators: 1: product name, 2: number of vulnerabilities */ 'title' => sprintf( _n( '%1$s: %2$d critical vulnerability', '%1$s: %2$d critical vulnerabilities', $count, 'syncnet-patch-radar' ), $name, $count ), 'text' => sprintf( /* translators: 1: date, 2: CVSS score */ __( 'Last reported on %1$s, highest score CVSS %2$s. All of them can be exploited over the network without authentication. Fix: install the current version or all available updates.', 'syncnet-patch-radar' ), wp_date( get_option( 'date_format' ), $list[0]['date'] ), cpradar_score( $max ) ), 'date' => $list[0]['date'], 'cvss' => $max, 'count' => $count, 'items' => $list, 'link' => '', 'cna' => $list[0]['cna'] ?? '', 'type' => 'nvd', ); } // Actively exploited vulnerabilities (CISA) in the same product, at most 7 days apart, are grouped as well. $cisa = array(); foreach ( cpradar_alerts() as $a ) { if ( 'cisa' === $a['source'] && ! empty( $a['product'] ) ) { $cisa[ sanitize_title( $a['product'] ) ][] = $a; } } foreach ( $cisa as $slug => $list ) { usort( $list, fn( $a, $b ) => $b['date'] <=> $a['date'] ); $clusters = array(); foreach ( $list as $a ) { $last = count( $clusters ) - 1; if ( $last >= 0 && $clusters[ $last ][0]['date'] - $a['date'] <= 7 * DAY_IN_SECONDS ) { $clusters[ $last ][] = $a; } else { $clusters[] = array( $a ); } } foreach ( $clusters as $part ) { if ( count( $part ) < 2 ) { continue; } $id = 'PRODUCT-' . strtoupper( $slug ) . '-A' . gmdate( 'Ymd', $part[0]['date'] ); $count = count( $part ); $scores = array_filter( array_map( fn( $x ) => (float) ( $x['cvss'] ?? 0 ), $part ) ); $groups[ $id ] = array( 'id' => $id, 'source' => 'group', 'type' => 'cisa', 'kind' => '', 'product' => $part[0]['product'], /* translators: 1: product name, 2: number of vulnerabilities */ 'title' => sprintf( _n( '%1$s: %2$d actively exploited vulnerability', '%1$s: %2$d actively exploited vulnerabilities', $count, 'syncnet-patch-radar' ), $part[0]['product'], $count ), 'text' => sprintf( /* translators: %s: date */ __( 'Last reported on %s. According to the US agency CISA all of these vulnerabilities are already being exploited in attacks. Update affected systems immediately.', 'syncnet-patch-radar' ), wp_date( get_option( 'date_format' ), $part[0]['date'] ) ), 'date' => $part[0]['date'], 'cvss' => $scores ? max( $scores ) : 0, 'count' => $count, 'items' => $part, 'link' => 'https://www.cisa.gov/known-exploited-vulnerabilities-catalog', 'ransom' => (bool) array_filter( array_column( $part, 'ransom' ) ), 'cna' => '', ); } } return $groups; } /** * Short name of a vulnerability inside an exploited-group card ("Server-Side Request Forgery Vulnerability"). * * @param array $a Alert. * @param array $g Group. * @return string */ function cpradar_short_name( $a, $g ) { $name = trim( (string) preg_replace( '/^' . preg_quote( $g['product'], '/' ) . '[\s:–-]*/iu', '', $a['title'] ) ); return '' !== $name ? $name : $a['title']; } /** * Whether an alert or group is about actively exploited vulnerabilities. * * @param array $a Alert or group. * @return bool */ function cpradar_is_exploited( $a ) { return 'cisa' === $a['source'] || 'cisa' === ( $a['type'] ?? '' ); } /** * Cards for the list: exploited (last 14 days) first, then by date. * * @return array */ function cpradar_entries() { $groups = cpradar_groups(); $grouped = array(); foreach ( $groups as $g ) { foreach ( $g['items'] as $a ) { $grouped[ $a['id'] ] = true; } } $list = array_values( $groups ); foreach ( cpradar_alerts() as $id => $a ) { if ( empty( $grouped[ $id ] ) ) { $list[] = $a; } } $recent = time() - 14 * DAY_IN_SECONDS; usort( $list, function ( $a, $b ) use ( $recent ) { $pa = ( cpradar_is_exploited( $a ) && $a['date'] >= $recent ) ? 1 : 0; $pb = ( cpradar_is_exploited( $b ) && $b['date'] >= $recent ) ? 1 : 0; return $pb <=> $pa ? $pb <=> $pa : $b['date'] <=> $a['date']; } ); return $list; } /** * Card ID an alert appears under (product group for grouped NVD entries). * * @param array $a Alert. * @return string */ function cpradar_card_id( $a ) { foreach ( cpradar_groups() as $gid => $g ) { if ( in_array( $a['id'], array_column( $g['items'], 'id' ), true ) ) { return $gid; } } return $a['id']; } /** * Vendor name for filters and vendor feeds (NetScaler → Citrix …). * * @param array $a Alert or group. * @return string */ function cpradar_vendor( $a ) { $groups = apply_filters( 'cpradar_vendor_groups', array( 'Microsoft' => array( 'Microsoft', 'Windows', 'Exchange', 'SharePoint' ), 'Citrix' => array( 'Citrix', 'NetScaler' ), 'Fortinet' => array( 'Fortinet', 'FortiGate', 'FortiOS', 'FortiWeb', 'FortiClient' ), 'Palo Alto' => array( 'Palo Alto', 'PAN-OS' ), 'VMware' => array( 'VMware', 'ESXi', 'vCenter' ), 'Oracle' => array( 'Oracle', 'Java', 'WebLogic' ), 'Apple' => array( 'Apple', 'iOS', 'macOS', 'Safari' ), 'Google' => array( 'Google', 'Chrome', 'Android' ), 'Mozilla' => array( 'Mozilla', 'Firefox', 'Thunderbird' ), 'F5' => array( 'F5', 'BIG-IP' ), 'Apache' => array( 'Apache', 'Tomcat' ), 'Adobe' => array( 'Adobe', 'Acrobat' ), 'Atlassian' => array( 'Atlassian', 'Confluence', 'Jira' ), ) ); if ( ! empty( $a['cna'] ) ) { return $a['cna']; } $hits = cpradar_matches( $a['title'] . ' ' . ( $a['product'] ?? '' ) ); if ( ! $hits ) { return ''; } foreach ( $groups as $name => $names ) { if ( in_array( $hits[0], $names, true ) ) { return $name; } } return $hits[0]; }