'libcurl', 'imagemagick' => 'imagemagick', 'openssl' => 'libssl3', 'sqlite' => 'sqlite-libs', ); /** Display names for the two products the CVE feed does not cover. */ const EXTRA_NAMES = array( 'openssl' => 'OpenSSL', 'alpine' => 'Alpine Linux', 'debian' => 'Debian', 'ubuntu' => 'Ubuntu', ); // ── Reading the host ─────────────────────────────────────────────── /** * Everything that can be measured about the host from inside PHP. * * @return array{components:array>,unverifiable:array,os:array} */ public static function inventory(): array { $server = class_exists( 'CSDT_Site_Audit' ) ? CSDT_Site_Audit::server_software_components() : array(); $out = array( 'components' => (array) ( $server['components'] ?? array() ), 'unverifiable' => (array) ( $server['unverifiable'] ?? array() ), 'os' => self::parse_os_release( self::read_os_release() ), ); $runtime = ''; if ( function_exists( 'curl_version' ) ) { $cv = curl_version(); $runtime = is_array( $cv ) ? (string) ( $cv['ssl_version'] ?? '' ) : ''; } $ssl = self::pick_openssl( $runtime, defined( 'OPENSSL_VERSION_TEXT' ) ? (string) OPENSSL_VERSION_TEXT : '' ); $sslv = self::parse_openssl( $ssl ); if ( '' !== $sslv ) { $out['components'][] = array( 'type' => 'openssl', 'slug' => 'openssl', 'name' => 'OpenSSL', 'version' => $sslv, ); } elseif ( '' !== trim( $ssl ) ) { $out['unverifiable'][] = 'TLS library "' . trim( $ssl ) . '" (not OpenSSL, no release data for it)'; } $os = $out['os']; if ( '' !== $os['id'] && '' !== $os['version'] ) { $out['components'][] = array( 'type' => $os['id'], 'slug' => $os['id'], 'name' => $os['name'], 'version' => $os['version'], ); } return $out; } /** * /etc/os-release, or '' when it is not there or open_basedir fences it off. * * @return string */ private static function read_os_release(): string { $path = '/etc/os-release'; $base = (string) ini_get( 'open_basedir' ); if ( '' !== $base ) { $allowed = false; foreach ( explode( PATH_SEPARATOR, $base ) as $dir ) { $dir = rtrim( $dir, '/' ); if ( '' !== $dir && 0 === strpos( $path, $dir . '/' ) ) { $allowed = true; break; } } if ( ! $allowed ) { return ''; } } if ( ! file_exists( $path ) || ! is_readable( $path ) ) { return ''; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local operating-system file, not a remote URL or an upload; WP_Filesystem has no read of /etc $text = file_get_contents( $path ); return is_string( $text ) ? $text : ''; } /** * The ID and version of an os-release file. * * Alpine writes VERSION_ID=3.24.1, Debian VERSION_ID="13", Ubuntu VERSION_ID="24.04". * * @param string $text File contents. * @return array{id:string,name:string,version:string,pretty:string} */ public static function parse_os_release( string $text ): array { $vals = array(); foreach ( preg_split( '/\R/', $text ) as $line ) { if ( preg_match( '/^([A-Z_]+)=(.*)$/', trim( (string) $line ), $m ) ) { $vals[ $m[1] ] = trim( $m[2], " \t\"'" ); } } $id = strtolower( (string) ( $vals['ID'] ?? '' ) ); if ( ! preg_match( '/^[a-z0-9._-]{1,32}$/', $id ) ) { $id = ''; } $version = (string) ( $vals['VERSION_ID'] ?? '' ); if ( ! preg_match( '/^\d+(?:\.\d+){0,3}$/', $version ) ) { $version = ''; } return array( 'id' => $id, 'name' => self::EXTRA_NAMES[ $id ] ?? (string) ( $vals['NAME'] ?? $id ), 'version' => $version, 'pretty' => (string) ( $vals['PRETTY_NAME'] ?? '' ), ); } /** * Which OpenSSL version text to report: the library that is loaded, not the one PHP was * compiled against. * * OPENSSL_VERSION_TEXT is a compile-time constant. On 2026-10-16 production said 3.5.7 * through it while libssl3 3.5.9 was installed and in use, because the PHP binary in the * base image was built before Alpine shipped 3.5.9, and the panel advised an upgrade that * had already happened. curl's ssl_version asks the loaded library, so it wins whenever it * names OpenSSL. The header is only a fallback, for a PHP without curl or a curl on * another TLS library. * * @param string $runtime curl_version()['ssl_version'], e.g. "OpenSSL/3.5.9". * @param string $header OPENSSL_VERSION_TEXT. * @return string */ public static function pick_openssl( string $runtime, string $header ): string { if ( '' !== self::parse_openssl( $runtime ) ) { return $runtime; } return '' !== trim( $header ) ? $header : $runtime; } /** * OpenSSL's version from OPENSSL_VERSION_TEXT ("OpenSSL 3.5.4 30 Sep 2025") or curl's * ssl_version ("OpenSSL/3.5.4"). LibreSSL, BoringSSL and anything else give ''. * * @param string $text Version text. * @return string */ public static function parse_openssl( string $text ): string { return preg_match( '/^OpenSSL[\s\/]+(\d+\.\d+\.\d+[a-z]?)/i', trim( $text ), $m ) ? $m[1] : ''; } // ── Release data ─────────────────────────────────────────────────── /** * The release snapshot, from cache when fresh, else from the proxy. * * A failed fetch keeps the old snapshot and says so, rather than reporting every row as * "no release data": a day-old latest version is still right far more often than not. * * @param bool $may_fetch Whether a request is allowed. * @return array{data:?array,at:int,fresh:bool,error:string} */ public static function releases( bool $may_fetch ): array { $cached = get_option( self::OPTION_RELEASES, array() ); $cached = is_array( $cached ) ? $cached : array(); $data = is_array( $cached['data'] ?? null ) ? $cached['data'] : null; $at = (int) ( $cached['at'] ?? 0 ); if ( null !== $data && ( time() - $at ) < self::RELEASES_TTL ) { return array( 'data' => $data, 'at' => $at, 'fresh' => true, 'error' => '', ); } if ( ! $may_fetch ) { return array( 'data' => $data, 'at' => $at, 'fresh' => false, 'error' => null === $data ? 'not fetched yet' : '', ); } $resp = wp_remote_get( self::RELEASES_URL, array( 'timeout' => 8, 'sslverify' => true, 'user-agent' => self::USER_AGENT, ) ); $r = CSDT_Http::json( $resp, self::MAX_BYTES ); $error = ''; if ( 200 !== $r['code'] ) { $error = 0 === $r['code'] ? $r['error'] : 'HTTP ' . $r['code']; } elseif ( ! self::valid_snapshot( $r['data'] ) ) { $error = 'the answer was not a release snapshot'; } if ( '' !== $error ) { return array( 'data' => $data, 'at' => $at, 'fresh' => false, 'error' => $error, ); } $at = time(); update_option( self::OPTION_RELEASES, array( 'at' => $at, 'data' => $r['data'], ), false ); return array( 'data' => $r['data'], 'at' => $at, 'fresh' => true, 'error' => '', ); } /** * Whether a decoded answer has the snapshot's shape. * * @param mixed $data Decoded JSON. * @return bool */ public static function valid_snapshot( $data ): bool { return is_array( $data ) && 1 === (int) ( $data['schema'] ?? 0 ) && is_array( $data['products'] ?? null ) && ! empty( $data['products'] ); } // ── The check ────────────────────────────────────────────────────── /** * Runs the check the button asks for: CVE lookups, release data, rows. Stores and returns it. * * @return array */ public static function check(): array { $inv = self::inventory(); $feed = array(); $cve = array( 'ok' => false, 'reason' => '', ); $ask = array(); foreach ( $inv['components'] as $c ) { if ( CSDT_Vuln_Feed::is_server_type( (string) $c['type'] ) ) { $ask[] = $c; } } if ( ! empty( $ask ) ) { $res = CSDT_Vuln_Feed::lookup( $ask, self::CVE_BUDGET ); if ( ! empty( $res['available'] ) ) { $cve['ok'] = true; foreach ( (array) ( $res['components'] ?? array() ) as $c ) { if ( is_array( $c ) ) { $feed[ (string) ( $c['type'] ?? '' ) ] = $c; } } } else { $cve['reason'] = (string) ( $res['reason'] ?? 'the CVE feed did not answer' ); } } return self::store( $inv, $feed, $cve, self::releases( true ) ); } /** * The daily re-check's half: reuses the CVE results it already has, then alerts once on * each component whose fix has newly reached the package manager. * * @param array> $server Feed results for server components. * @return array What was stored. */ public static function record_from_recheck( array $server ): array { $last = self::record_from_feed( $server ); self::alert_new_package_fixes( (array) $last['rows'], (string) ( $last['os']['pretty'] ?? '' ) ); return $last; } /** * Builds and stores the table from CVE results someone else already fetched: the daily * re-check, or the deep dive, which shows the table in its report. * * @param array> $server Feed results; non-server types are ignored. * @return array What was stored. */ public static function record_from_feed( array $server ): array { $feed = array(); foreach ( $server as $c ) { if ( is_array( $c ) && CSDT_Vuln_Feed::is_server_type( (string) ( $c['type'] ?? '' ) ) ) { $feed[ (string) $c['type'] ] = $c; } } return self::store( self::inventory(), $feed, array( 'ok' => ! empty( $feed ), 'reason' => empty( $feed ) ? 'the CVE feed did not run on this scan' : '', ), self::releases( true ) ); } /** * Builds the rows and stores the result. * * @param array $inv inventory(). * @param array $feed CVE results keyed by type. * @param array $cve {ok, reason}. * @param array $rel releases(). * @return array */ private static function store( array $inv, array $feed, array $cve, array $rel ): array { $rows = self::build_rows( $inv, $rel['data'], $feed, time() ); $last = array( 'ts' => time(), 'rows' => $rows, 'os' => $inv['os'], 'unverifiable' => array_values( array_map( 'strval', (array) $inv['unverifiable'] ) ), 'releases_at' => (int) $rel['at'], 'releases_ok' => null !== $rel['data'], 'releases_err' => (string) $rel['error'], 'cve_ok' => ! empty( $cve['ok'] ), 'cve_reason' => (string) ( $cve['reason'] ?? '' ), 'rag' => self::worst( $rows, ! empty( $inv['unverifiable'] ) ), ); update_option( self::OPTION_LAST, $last, false ); return $last; } /** * The stored result, or null before the first check. * * @return array|null */ public static function last(): ?array { $last = get_option( self::OPTION_LAST, null ); return is_array( $last ) && isset( $last['rows'] ) ? $last : null; } // ── Building rows (pure) ─────────────────────────────────────────── /** * One row per component. * * Pure: no WordPress, no clock but $now, so a test can drive it with fixture snapshots. * * @param array $inv inventory() shape. * @param array|null $releases The snapshot, or null when there is none. * @param array $feed CVE feed results keyed by component type. * @param int $now Unix time. * @return array> */ public static function build_rows( array $inv, ?array $releases, array $feed, int $now ): array { $products = is_array( $releases['products'] ?? null ) ? $releases['products'] : array(); $os = (array) ( $inv['os'] ?? array() ); $distro = self::distro_packages( $releases, $os ); $rows = array(); foreach ( (array) ( $inv['components'] ?? array() ) as $c ) { $key = (string) ( $c['type'] ?? '' ); $version = (string) ( $c['version'] ?? '' ); $product = is_array( $products[ $key ] ?? null ) ? $products[ $key ] : null; $cycle = null === $product ? null : self::match_cycle( (array) ( $product['cycles'] ?? array() ), $version ); $row = array( 'key' => $key, 'name' => (string) ( $c['name'] ?? $key ), 'installed' => $version, 'cycle' => null === $cycle ? '' : (string) ( $cycle['cycle'] ?? '' ), 'cycle_latest' => null === $cycle ? '' : (string) ( $cycle['latest'] ?? '' ), 'cycle_date' => null === $cycle ? '' : (string) ( $cycle['latest_date'] ?? '' ), 'newest' => (string) ( $product['latest']['version'] ?? '' ), 'newest_date' => (string) ( $product['latest']['date'] ?? '' ), 'eol' => null === $cycle ? '' : (string) ( $cycle['eol'] ?? '' ), 'eol_state' => 'unknown', 'behind' => 'unknown', 'fix_age_days' => null, 'cves' => 0, 'cve_worst' => '', 'cve_ids' => array(), 'cve_fixed_in' => '', 'cve_checked' => CSDT_Vuln_Feed::is_server_type( $key ) ? isset( $feed[ $key ] ) && empty( $feed[ $key ]['skipped'] ) : null, 'pkg_name' => '', 'pkg_version' => '', 'pkg_fix' => null, 'rag' => 'amber', 'advice' => '', ); if ( null !== $cycle ) { $row['eol_state'] = self::eol_state( $row['eol'], ! empty( $cycle['maintained'] ), $now ); $row['behind'] = self::behind( $version, $row['cycle_latest'], $row['newest'] ); if ( 'patch' === $row['behind'] && '' !== $row['cycle_date'] ) { $ts = strtotime( $row['cycle_date'] . ' 00:00:00 UTC' ); $row['fix_age_days'] = false === $ts ? null : max( 0, (int) floor( ( $now - $ts ) / DAY_IN_SECONDS ) ); } } // The feed's own support date wins when endoflife.date has no cycle for it. $support = (array) ( $feed[ $key ]['support'] ?? array() ); if ( '' === $row['eol'] && preg_match( '/^\d{4}-\d{2}-\d{2}$/', (string) ( $support['date_end'] ?? '' ) ) ) { $row['eol'] = (string) $support['date_end']; $row['eol_state'] = self::eol_state( $row['eol'], true, $now ); } $worst_rank = 0; $rank = array( 'low' => 1, 'unknown' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4, ); foreach ( (array) ( $feed[ $key ]['matched_cves'] ?? array() ) as $cve ) { if ( ! is_array( $cve ) ) { continue; } ++$row['cves']; $sev = ! empty( $cve['kev'] ) ? 'critical' : (string) ( $cve['severity'] ?? 'unknown' ); if ( ( $rank[ $sev ] ?? 0 ) > $worst_rank ) { $worst_rank = $rank[ $sev ] ?? 0; $row['cve_worst'] = $sev; } if ( count( $row['cve_ids'] ) < 3 && '' !== (string) ( $cve['cve_id'] ?? '' ) ) { $row['cve_ids'][] = (string) $cve['cve_id']; } $fx = (string) ( $cve['fixed_in'] ?? '' ); if ( '' !== $fx && ( '' === $row['cve_fixed_in'] || self::vcmp( $fx, $row['cve_fixed_in'] ) > 0 ) ) { $row['cve_fixed_in'] = $fx; } } $total = (int) ( $feed[ $key ]['total_matched'] ?? 0 ); if ( $total > $row['cves'] ) { $row['cves'] = $total; } if ( isset( self::DISTRO_PACKAGES[ $key ] ) && null !== $distro ) { $pkg = self::DISTRO_PACKAGES[ $key ]; $row['pkg_name'] = $pkg; $row['pkg_version'] = (string) ( $distro['packages'][ $pkg ] ?? '' ); if ( '' !== $row['pkg_version'] ) { $row['pkg_fix'] = self::vcmp( self::pkg_upstream( $row['pkg_version'] ), $version ) > 0; } } $row['rag'] = self::rag( $row ); $row['advice'] = self::advice( $row, (string) ( $distro['label'] ?? '' ) ); $rows[] = $row; } usort( $rows, static function ( array $a, array $b ): int { $order = array( 'red' => 0, 'amber' => 1, 'green' => 2, ); return ( $order[ $a['rag'] ] ?? 1 ) <=> ( $order[ $b['rag'] ] ?? 1 ) ?: strcmp( $a['name'], $b['name'] ); } ); return $rows; } /** * This host's Alpine branch in the snapshot, or null when the host is not Alpine or the * branch is not listed (an end-of-life branch has no package data). * * @param array|null $releases Snapshot. * @param array $os parse_os_release(). * @return array{label:string,packages:array}|null */ private static function distro_packages( ?array $releases, array $os ): ?array { if ( 'alpine' !== (string) ( $os['id'] ?? '' ) ) { return null; } $branch = implode( '.', array_slice( explode( '.', (string) ( $os['version'] ?? '' ) ), 0, 2 ) ); $data = $releases['distros']['alpine'][ $branch ] ?? null; if ( ! is_array( $data ) || ! is_array( $data['packages'] ?? null ) ) { return null; } return array( 'label' => 'Alpine ' . $branch, 'packages' => $data['packages'], ); } /** * The release cycle a version belongs to: the longest cycle name the version starts with. * * "8.4.13" is in "8.4", "7.1.2-32" in "7.1", "3.24.1" in "3.24", SQLite "3.50.4" in "3". * * @param array $cycles Snapshot cycles. * @param string $version Installed version. * @return array|null */ public static function match_cycle( array $cycles, string $version ): ?array { $best = null; $len = -1; foreach ( $cycles as $cy ) { $name = is_array( $cy ) ? (string) ( $cy['cycle'] ?? '' ) : ''; if ( '' === $name ) { continue; } if ( ( $version === $name || 0 === strpos( $version, $name . '.' ) || 0 === strpos( $version, $name . '-' ) ) && strlen( $name ) > $len ) { $best = $cy; $len = strlen( $name ); } } return $best; } /** * How far behind: current, patch (newer release in the same cycle), minor or major * (on the newest of its cycle, but a newer cycle exists). * * @param string $installed Installed version. * @param string $cycle_latest Newest release of the installed cycle. * @param string $newest Newest release overall. * @return string */ public static function behind( string $installed, string $cycle_latest, string $newest ): string { if ( '' === $cycle_latest ) { return 'unknown'; } if ( self::vcmp( $cycle_latest, $installed ) > 0 ) { return 'patch'; } if ( '' === $newest || self::vcmp( $newest, $installed ) <= 0 ) { return 'current'; } $a = self::parts( $installed ); $b = self::parts( $newest ); return ( $a[0] ?? 0 ) !== ( $b[0] ?? 0 ) ? 'major' : 'minor'; } /** * Past, soon (within EOL_SOON_DAYS), supported, or unknown. * * @param string $eol YYYY-MM-DD or ''. * @param bool $maintained The snapshot's maintained flag. * @param int $now Unix time. * @return string */ public static function eol_state( string $eol, bool $maintained, int $now ): string { if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $eol ) ) { return $maintained ? 'supported' : 'unknown'; } $ts = strtotime( $eol . ' 23:59:59 UTC' ); if ( false === $ts ) { return 'unknown'; } if ( $ts < $now ) { return 'past'; } return ( $ts - $now ) <= self::EOL_SOON_DAYS * DAY_IN_SECONDS ? 'soon' : 'supported'; } /** * Red: a high or critical CVE, or past end of support. Amber: a medium CVE, support ending * within EOL_SOON_DAYS, or nothing to measure against. Green otherwise, including a * release behind with no known flaw and low-only CVEs. * * @param array $row Row. * @return string */ private static function rag( array $row ): string { if ( in_array( $row['cve_worst'], array( 'high', 'critical' ), true ) || 'past' === $row['eol_state'] ) { return 'red'; } if ( 'medium' === $row['cve_worst'] || 'soon' === $row['eol_state'] ) { return 'amber'; } if ( '' === $row['cycle'] && '' === $row['eol'] ) { return 'amber'; } if ( false === $row['cve_checked'] ) { return 'amber'; } return 'green'; } /** * One sentence on what to do. * * @param array $row Row. * @param string $distro "Alpine 3.24" or ''. * @return string */ private static function advice( array $row, string $distro ): string { $parts = array(); if ( 'past' === $row['eol_state'] ) { $parts[] = sprintf( 'Branch %s stopped receiving security fixes on %s. Move to a supported release (newest: %s).', $row['cycle'] ?: $row['installed'], $row['eol'], $row['newest'] ?: 'unknown' ); } elseif ( 'soon' === $row['eol_state'] ) { $parts[] = sprintf( 'Support for %s ends on %s. Plan the move to a newer release.', $row['cycle'] ?: $row['installed'], $row['eol'] ); } if ( $row['cves'] > 0 ) { $parts[] = sprintf( '%d known CVE%s, worst %s%s.%s', $row['cves'], 1 === $row['cves'] ? '' : 's', $row['cve_worst'] ?: 'unrated', empty( $row['cve_ids'] ) ? '' : ' (' . implode( ', ', $row['cve_ids'] ) . ')', '' !== $row['cve_fixed_in'] ? ' Fixed in ' . $row['cve_fixed_in'] . '.' : '' ); } if ( 'patch' === $row['behind'] ) { $parts[] = sprintf( '%s is out%s.', $row['cycle_latest'], null === $row['fix_age_days'] ? '' : sprintf( ' (released %s, %d day%s ago)', $row['cycle_date'], $row['fix_age_days'], 1 === $row['fix_age_days'] ? '' : 's' ) ); } if ( true === $row['pkg_fix'] ) { $parts[] = sprintf( '%s already packages %s %s: upgrade it on the host or rebuild the image.', $distro, $row['pkg_name'], $row['pkg_version'] ); } elseif ( false === $row['pkg_fix'] && ( $row['cves'] > 0 || 'patch' === $row['behind'] || 'past' === $row['eol_state'] ) ) { $parts[] = sprintf( '%s has not packaged a newer build yet (%s %s), so there is nothing to install today.', $distro, $row['pkg_name'], $row['pkg_version'] ); } if ( false === $row['cve_checked'] ) { $parts[] = 'The CVE feed did not answer for this one, so it is unchecked, not clean.'; } if ( '' === $row['cycle'] && '' === $row['eol'] ) { $parts[] = 'No release data for this version, so its age and support cannot be shown.'; } if ( empty( $parts ) ) { $parts[] = 'current' === $row['behind'] ? 'Up to date.' : 'Supported, no known vulnerabilities.'; } return implode( ' ', $parts ); } /** * The colour of the worst row. Unverifiable components make it at least amber. * * @param array $rows Rows. * @param bool $unverifiable Whether anything could not be measured. * @return string */ public static function worst( array $rows, bool $unverifiable ): string { $worst = $unverifiable ? 'amber' : 'green'; foreach ( $rows as $r ) { if ( 'red' === ( $r['rag'] ?? '' ) ) { return 'red'; } if ( 'amber' === ( $r['rag'] ?? '' ) ) { $worst = 'amber'; } } return $worst; } // ── Versions (pure) ──────────────────────────────────────────────── /** * Numeric parts of a version. ImageMagick writes "7.1.2-33" and Alpine packages the same * release with a dot before the 33, so "-" and "." both separate. A trailing letter (OpenSSL 1.1.1w) is a * further part, numbered from a = 1. * * @param string $v Version. * @return int[] */ public static function parts( string $v ): array { $out = array(); foreach ( preg_split( '/[.\-]/', trim( $v ) ) as $p ) { if ( preg_match( '/^(\d+)([a-z])?$/i', (string) $p, $m ) ) { $out[] = (int) $m[1]; if ( isset( $m[2] ) && '' !== $m[2] ) { $out[] = ord( strtolower( $m[2] ) ) - 96; } } else { break; } } return $out; } /** * Compares two versions by parts(): -1, 0 or 1. Missing parts count as 0. * * @param string $a One version. * @param string $b Another. * @return int */ public static function vcmp( string $a, string $b ): int { $x = self::parts( $a ); $y = self::parts( $b ); $n = max( count( $x ), count( $y ) ); for ( $i = 0; $i < $n; $i++ ) { $c = ( $x[ $i ] ?? 0 ) <=> ( $y[ $i ] ?? 0 ); if ( 0 !== $c ) { return $c; } } return 0; } /** * A package version without its Alpine revision: "8.23.0-r0" is 8.23.0. * * @param string $v Package version. * @return string */ public static function pkg_upstream( string $v ): string { return (string) preg_replace( '/-r\d+$/', '', trim( $v ) ); } // ── Alerting ─────────────────────────────────────────────────────── /** * Alerts once when a component that needs an update can now get it from the package * manager. Keyed on component, installed version and package version, so a newer package * later alerts again and an unchanged one never repeats. * * @param array $rows Rows. * @param string $os The host's OS name. * @return int Lines alerted on. */ private static function alert_new_package_fixes( array $rows, string $os ): int { $prev = get_option( self::OPTION_FIX_SEEN, array() ); $prev = is_array( $prev ) ? array_map( 'strval', $prev ) : array(); $seen = array(); $lines = array(); $red = false; foreach ( $rows as $r ) { if ( true !== ( $r['pkg_fix'] ?? null ) ) { continue; } if ( 0 === (int) $r['cves'] && 'patch' !== $r['behind'] && 'past' !== $r['eol_state'] ) { continue; } $key = $r['key'] . '|' . $r['installed'] . '|' . $r['pkg_version']; $seen[] = $key; if ( in_array( $key, $prev, true ) ) { continue; } $red = $red || 'red' === $r['rag']; $lines[] = sprintf( '%s %s: the package manager now has %s %s. %s', $r['name'], $r['installed'], $r['pkg_name'], $r['pkg_version'], $r['advice'] ); } update_option( self::OPTION_FIX_SEEN, $seen, false ); if ( empty( $lines ) || '1' !== (string) CSDT_Options::get( 'csdt_telegram_vuln_found' ) ) { return count( $lines ); } if ( is_callable( array( 'CSDT_Threat_Monitor', 'send_threat_alert' ) ) ) { $site = wp_specialchars_decode( (string) ( get_bloginfo( 'name' ) ?: home_url() ), ENT_QUOTES ); $url = admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=security' ); CSDT_Threat_Monitor::send_threat_alert( sprintf( 'CSDT: host software update now available (%s)', $site ), 'An update that was waiting on the operating system' . ( '' !== $os ? ' (' . $os . ')' : '' ) . " has been packaged, so it can be installed now.\n\n" . implode( "\n\n", $lines ) . "\n\nHost software review: " . $url, $red ? 'high' : 'low', 'package', $url, CSDT_Alert_Events::SECURITY ); } return count( $lines ); } // ── Admin ────────────────────────────────────────────────────────── /** * The "Check host software" button. */ public static function ajax_check(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } wp_send_json_success( self::for_js( self::check() ) ); } /** * The stored result as the panel script reads it. * * @param array|null $last Stored result. * @return array */ public static function for_js( ?array $last ): array { if ( null === $last ) { return array( 'ran' => false ); } $fmt = static function ( int $ts ): string { if ( $ts <= 0 ) { return ''; } $s = function_exists( 'wp_date' ) ? wp_date( 'j M Y H:i', $ts ) : false; return is_string( $s ) && '' !== $s ? $s : gmdate( 'j M Y H:i', $ts ) . ' UTC'; }; return array( 'ran' => true, 'when' => $fmt( (int) $last['ts'] ), 'rows' => array_values( (array) $last['rows'] ), 'unverifiable' => array_values( (array) ( $last['unverifiable'] ?? array() ) ), 'os' => (string) ( $last['os']['pretty'] ?? '' ), 'releases_at' => $fmt( (int) ( $last['releases_at'] ?? 0 ) ), 'releases_ok' => ! empty( $last['releases_ok'] ), 'releases_err' => (string) ( $last['releases_err'] ?? '' ), 'cve_ok' => ! empty( $last['cve_ok'] ), 'cve_reason' => (string) ( $last['cve_reason'] ?? '' ), 'rag' => (string) ( $last['rag'] ?? 'amber' ), ); } /** * The Security tab panel. */ public static function render_panel(): void { ?>
🧱 'What it checks', 'rec' => 'Info', 'html' => '

PHP, the database server, the web server, curl, ImageMagick, SQLite, Redis, Memcached, OpenSSL and the operating system: everything PHP can measure about its own host. For each it shows the installed version, the newest release in that branch and overall with their dates, when the branch stops receiving security fixes, and the known vulnerabilities (CVEs) that affect the installed version.

A WordPress update cannot change any of these. The fix is the host, its package manager, the server image or the control panel.

', ), array( 'name' => 'How the colour is decided', 'rec' => 'Info', 'html' => '

Red: a high or critical CVE, one known to be exploited, or a branch past its end of support. Update now.

Amber: a medium CVE, support ending within ' . (int) self::EOL_SOON_DAYS . ' days, or a component that could not be checked. Plan it.

Green: supported with no known vulnerabilities, or only low-severity ones. A newer release may still be listed; it is worth taking, not urgent.

', ), array( 'name' => 'Package manager column', 'rec' => 'Info', 'html' => '

On Alpine Linux, for the libraries PHP itself loads (curl, ImageMagick, OpenSSL, SQLite), the table shows the newest package in your Alpine branch. When it is newer than what is installed, the fix can be installed today. When it is not, the upstream release exists but your operating system has not packaged it yet, and the daily vulnerability re-check alerts you once when it does.

', ), array( 'name' => 'Where the data comes from', 'rec' => 'Info', 'html' => '

Release and support dates come from one daily snapshot served by the CloudScale API, which collects them from endoflife.date, the curl project, ImageMagick\'s releases and Alpine\'s package index. Nothing is sent with that request. Vulnerabilities come from the same CVE lookup the security scan uses, one product and version per request, never your site address.

It runs when you click the button, and once a day if the daily vulnerability re-check is on. The weekly report shows the last result and makes no request of its own.

', ), ) ); ?>