sent_at}. One alert per event per site, whatever the cron does. */ const OPTION_SENT = 'csdt_php_advisory_sent'; /** '1' sends alerts. Declared in CSDT_Options::DEFAULTS. */ const OPTION_ENABLED = 'csdt_php_advisory_alerts'; /** {checked_at, fetched_at, ok, error, generated_at, events, evaluated_runtime, retries}. */ const OPTION_STATE = 'csdt_php_advisory_state'; /** Hourly tick; it fetches every 6 hours, or every hour while a notice is open. */ const CRON_HOOK = 'csdt_php_advisory_poll'; /** Poll interval with no open notice. */ const POLL_INTERVAL = 6 * HOUR_IN_SECONDS; /** Poll interval while an advance notice is open. */ const OPEN_INTERVAL = HOUR_IN_SECONDS; /** The hourly tick fires a little early or late; this much early still counts. */ const TICK_SLACK = 5 * MINUTE_IN_SECONDS; /** A notice stays open this long after its scheduled time while no fix for the branch is seen. */ const OPEN_GRACE = 48 * HOUR_IN_SECONDS; /** A release older than this is alerted only when this site is still behind it. */ const RECENT_DAYS = 30; /** De-duplication entries older than this are pruned (the proxy keeps 400 days too). */ const PRUNE_DAYS = 400; /** A failed delivery is tried again on the next run, at most this many times. */ const MAX_ATTEMPTS = 3; /** The snapshot holds at most 200 short events. Anything far larger is not one. */ const MAX_BYTES = 524288; /** Payload format this code understands. */ const FORMAT = 1; /** Branch states. */ const BRANCH_ACTIVE = 'active'; const BRANCH_SECURITY_ONLY = 'security_only'; const BRANCH_SUPPORTED = 'supported'; const BRANCH_EOL = 'end_of_life'; const BRANCH_UNKNOWN = 'unknown'; /** Patch states. */ const PATCH_PATCHED = 'patched'; const PATCH_NOT_PATCHED = 'not_patched'; const PATCH_NONE = 'no_patch'; const PATCH_UNKNOWN = 'unknown'; /** * Test-only clock. Null in production. * * @var int|null */ public static $now_override = null; /** Current time. */ private static function now(): int { return null !== self::$now_override ? self::$now_override : time(); } /** Whether alerts are on. */ public static function is_enabled(): bool { return '1' === (string) CSDT_Options::get( 'csdt_php_advisory_alerts' ); } // ── Runtime version ──────────────────────────────────────────────── /** * Hooked to admin_init with accepted_args 0. Records the web server's PHP when it changed. * * @return void */ public static function observe_runtime(): void { try { $web = ! ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) && ! ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) && ! ( defined( 'WP_CLI' ) && WP_CLI ) && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ); self::record_runtime( PHP_VERSION, PHP_SAPI, $web ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Stores the runtime when it came from a web request and differs from the record. * * @param string $version PHP_VERSION. * @param string $sapi PHP_SAPI. * @param bool $is_web Whether this is an admin page load (not AJAX, cron, REST or CLI). * @return bool Whether anything was written. */ public static function record_runtime( string $version, string $sapi, bool $is_web ): bool { if ( ! $is_web || ! self::is_web_sapi( $sapi ) ) { return false; } $version = self::clean_version( $version ); if ( '' === $version ) { return false; } $old = get_option( self::OPTION_RUNTIME, array() ); $old = is_array( $old ) ? $old : array(); if ( $version === (string) ( $old['version'] ?? '' ) && $sapi === (string) ( $old['sapi'] ?? '' ) ) { return false; } update_option( self::OPTION_RUNTIME, array( 'version' => $version, 'sapi' => $sapi, 'seen_at' => self::now(), ), false ); return true; } /** Whether a SAPI name is a web server's: anything but the command line ones. */ private static function is_web_sapi( string $sapi ): bool { return '' !== $sapi && ! in_array( $sapi, array( 'cli', 'phpdbg', 'embed', 'cli-server' ), true ); } /** * The recorded web runtime, or null when none (or one from a CLI) is recorded. * * @return array{version:string,sapi:string,seen_at:int}|null */ public static function runtime(): ?array { $r = get_option( self::OPTION_RUNTIME, array() ); if ( ! is_array( $r ) ) { return null; } $version = self::clean_version( (string) ( $r['version'] ?? '' ) ); $sapi = (string) ( $r['sapi'] ?? '' ); if ( '' === $version || ! self::is_web_sapi( $sapi ) ) { return null; } return array( 'version' => $version, 'sapi' => $sapi, 'seen_at' => (int) ( $r['seen_at'] ?? 0 ), ); } /** "8.3.12-1+ubuntu24.04" is 8.3.12. '' when it is not a PHP version at all. */ public static function clean_version( string $v ): string { return preg_match( '/^(\d+\.\d+\.\d+)/', trim( $v ), $m ) ? $m[1] : ''; } /** "8.3.12" is in branch 8.3. */ public static function branch_of( string $version ): string { return preg_match( '/^(\d+\.\d+)\./', $version, $m ) ? $m[1] : ''; } // ── The feed ─────────────────────────────────────────────────────── /** * Validates the snapshot. Each event is checked strictly and a bad one is dropped, never * repaired. * * @param mixed $data Decoded JSON. * @return array>|string Events, newest first, or why the whole answer is refused. */ public static function normalise_feed( $data ) { if ( ! is_array( $data ) || self::FORMAT !== ( $data['format'] ?? null ) ) { return 'the answer was not a PHP advisory snapshot'; } if ( ! isset( $data['events'] ) || ! is_array( $data['events'] ) ) { return 'the snapshot has no event list'; } $out = array(); $seen = array(); foreach ( array_slice( $data['events'], 0, 200 ) as $raw ) { $ev = self::normalise_event( $raw ); if ( null === $ev || isset( $seen[ $ev['id'] ] ) ) { continue; } $seen[ $ev['id'] ] = true; $out[] = $ev; } usort( $out, static function ( array $a, array $b ): int { return $b['published_ts'] <=> $a['published_ts']; } ); return $out; } /** * One event, or null. * * @param mixed $r Event as decoded. * @return array|null */ private static function normalise_event( $r ): ?array { if ( ! is_array( $r ) ) { return null; } $id = $r['id'] ?? null; $type = $r['type'] ?? null; $title = self::plain( $r['title'] ?? null, 200 ); $pub = self::iso_ts( $r['published'] ?? null ); $url = self::php_net_url( $r['url'] ?? null ); if ( ! is_string( $id ) || 1 !== preg_match( '/^php\.net:[A-Za-z0-9._-]{1,80}$/D', $id ) || '' === $title || null === $pub ) { return null; } $ev = array( 'id' => $id, 'type' => '', 'title' => $title, 'published_ts' => $pub, 'url' => $url, ); if ( 'advance_notice' === $type ) { $ev['type'] = 'advance_notice'; $ev['scheduled_ts'] = self::iso_ts( $r['scheduled_at'] ?? null ); $ev['summary'] = self::plain( $r['summary'] ?? '', 300 ); return $ev; } if ( 'release' === $type ) { $version = is_string( $r['version'] ?? null ) && 1 === preg_match( '/^\d+\.\d+\.\d+$/D', $r['version'] ) ? $r['version'] : ''; $branch = is_string( $r['branch'] ?? null ) ? $r['branch'] : ''; if ( '' === $version || self::branch_of( $version ) !== $branch || ! is_bool( $r['security'] ?? null ) ) { return null; } $ev['type'] = 'release'; $ev['version'] = $version; $ev['branch'] = $branch; $ev['security'] = $r['security']; $ev['changelog_url'] = self::php_net_url( $r['changelog_url'] ?? null ); return $ev; } return null; } /** Plain one-line text of at most $max characters, '' for anything else. */ private static function plain( $v, int $max ): string { if ( ! is_string( $v ) || 1 !== preg_match( '//u', $v ) ) { return ''; } $v = trim( (string) preg_replace( '/[\x00-\x1F\x7F<>]+/', ' ', $v ) ); return function_exists( 'mb_substr' ) ? mb_substr( $v, 0, $max, 'UTF-8' ) : substr( $v, 0, $max ); } /** A php.net https URL, or ''. Links in an alert must go where they say. */ private static function php_net_url( $v ): string { return is_string( $v ) && 1 === preg_match( '#^https://www\.php\.net/[A-Za-z0-9._~/\#?=&%-]{0,300}$#D', $v ) ? $v : ''; } /** ISO 8601 UTC ("2026-10-21T14:00:00Z") as a Unix time, or null. */ private static function iso_ts( $v ): ?int { if ( ! is_string( $v ) || 1 !== preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/D', $v ) ) { return null; } $ts = strtotime( $v ); return false === $ts ? null : $ts; } // ── Branch status ────────────────────────────────────────────────── /** * This branch's support state from the /v1/software-releases snapshot. * * The snapshot carries the end of security support. It distinguishes "active" from * "security fixes only" only when a cycle carries an end-of-active-support date * (eoas); until then a branch with security support is "supported". * * @param array|null $releases The snapshot, or null. * @param string $version Runtime version. * @param int $now Unix time. * @return array{state:string,eol:string,newest:string} */ public static function branch_status( ?array $releases, string $version, int $now ): array { $out = array( 'state' => self::BRANCH_UNKNOWN, 'eol' => '', 'newest' => '', ); $php = is_array( $releases['products']['php'] ?? null ) ? $releases['products']['php'] : null; $branch = self::branch_of( $version ); if ( null === $php || '' === $branch ) { return $out; } $out['newest'] = (string) ( $php['latest']['version'] ?? '' ); $cycle = CSDT_Host_Software::match_cycle( (array) ( $php['cycles'] ?? array() ), $version ); if ( null === $cycle || (string) ( $cycle['cycle'] ?? '' ) !== $branch ) { return $out; } $out['eol'] = (string) ( $cycle['eol'] ?? '' ); $eol = CSDT_Host_Software::eol_state( $out['eol'], ! empty( $cycle['maintained'] ), $now ); if ( 'past' === $eol ) { $out['state'] = self::BRANCH_EOL; return $out; } if ( 'unknown' === $eol ) { return $out; } $eoas = (string) ( $cycle['eoas'] ?? '' ); if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $eoas ) ) { $ts = strtotime( $eoas . ' 23:59:59 UTC' ); $out['state'] = false !== $ts && $ts < $now ? self::BRANCH_SECURITY_ONLY : self::BRANCH_ACTIVE; return $out; } $out['state'] = self::BRANCH_SUPPORTED; return $out; } /** How a branch state reads. */ public static function branch_label( string $state ): string { $labels = array( self::BRANCH_ACTIVE => 'active support', self::BRANCH_SECURITY_ONLY => 'security fixes only', self::BRANCH_SUPPORTED => 'receiving security fixes', self::BRANCH_EOL => 'end of life, no security fixes', self::BRANCH_UNKNOWN => 'support status unknown', ); return $labels[ $state ] ?? $labels[ self::BRANCH_UNKNOWN ]; } // ── Deciding (pure) ──────────────────────────────────────────────── /** The newest advance notice, or null. */ public static function latest_notice( array $events ): ?array { foreach ( $events as $ev ) { if ( 'advance_notice' === ( $ev['type'] ?? '' ) ) { return $ev; } } return null; } /** * Security releases for a branch published at or after a time, newest first. * * @param array $events Events. * @param string $branch "8.3", or '' for any branch. * @param int $since Unix time. * @return array> */ private static function security_releases( array $events, string $branch, int $since ): array { $out = array(); foreach ( $events as $ev ) { if ( 'release' === ( $ev['type'] ?? '' ) && ! empty( $ev['security'] ) && ( '' === $branch || $branch === $ev['branch'] ) && (int) $ev['published_ts'] >= $since ) { $out[] = $ev; } } usort( $out, static function ( array $a, array $b ): int { return CSDT_Host_Software::vcmp( (string) $b['version'], (string) $a['version'] ) ?: ( $b['published_ts'] <=> $a['published_ts'] ); } ); return $out; } /** * Whether a notice is open: its scheduled time is ahead, or passed within OPEN_GRACE with * no security release for this branch (any branch when it is unknown) since the notice. * * @param array $notice Notice. * @param array $events Events. * @param string $branch Branch or ''. * @param int $now Unix time. * @return bool */ public static function notice_is_open( array $notice, array $events, string $branch, int $now ): bool { $ref = (int) ( $notice['scheduled_ts'] ?? 0 ); if ( $ref <= 0 ) { $ref = (int) $notice['published_ts']; } if ( $now < $ref ) { return true; } if ( $now - $ref > self::OPEN_GRACE ) { return false; } return empty( self::security_releases( $events, $branch, (int) $notice['published_ts'] ) ); } /** * The fixed version for a branch: the newest security release published at or after the * notice, or with no notice the newest security release for the branch. A notice that has * closed with no release for this branch since counts as no notice. * * @param array $events Events. * @param string $branch Branch. * @param array|null $notice Latest notice. * @param bool $open Whether that notice is open. * @return array|null Null while an open notice has no fix yet. */ public static function fixed_release( array $events, string $branch, ?array $notice, bool $open ): ?array { if ( '' === $branch ) { return null; } if ( null !== $notice ) { $since = self::security_releases( $events, $branch, (int) $notice['published_ts'] ); if ( ! empty( $since ) || $open ) { return $since[0] ?? null; } } $all = self::security_releases( $events, $branch, 0 ); return $all[0] ?? null; } /** * The status the panel and the dashboard widget show. * * @param array $events Events. * @param array|null $runtime runtime(). * @param array $branch branch_status(). * @param int $now Unix time. * @return array */ public static function status( array $events, ?array $runtime, array $branch, int $now ): array { $version = null === $runtime ? '' : (string) $runtime['version']; $br = self::branch_of( $version ); $notice = self::latest_notice( $events ); $open = null !== $notice && self::notice_is_open( $notice, $events, $br, $now ); $fixed = self::fixed_release( $events, $br, $notice, $open ); $last = null; foreach ( $events as $ev ) { if ( 'advance_notice' === $ev['type'] || ( ! empty( $ev['security'] ) && ( '' === $br || $br === $ev['branch'] ) ) ) { $last = $ev; break; } } $patch = self::PATCH_UNKNOWN; if ( '' !== $version ) { if ( self::BRANCH_EOL === $branch['state'] ) { $patch = self::PATCH_NONE; } elseif ( null !== $fixed ) { $patch = CSDT_Host_Software::vcmp( $version, (string) $fixed['version'] ) >= 0 ? self::PATCH_PATCHED : self::PATCH_NOT_PATCHED; } elseif ( $open ) { $patch = self::PATCH_NONE; } } return array( 'version' => $version, 'branch' => $br, 'branch_state' => (string) $branch['state'], 'eol' => (string) $branch['eol'], 'notice' => $notice, 'notice_open' => $open, 'fixed' => $fixed, 'last' => $last, 'patch' => $patch, ); } /** * The alerts this run owes, given what has already been sent. * * Pure: no WordPress beyond the time formatting, no clock but $now. * * @param array $events Events, newest first. * @param array|null $runtime runtime(). * @param array $branch branch_status(). * @param array $sent OPTION_SENT. * @param int $now Unix time. * @return array */ public static function plan( array $events, ?array $runtime, array $branch, array $sent, int $now ): array { $st = self::status( $events, $runtime, $branch, $now ); $version = $st['version']; $br = $st['branch']; $notice = $st['notice']; $fixed = $st['fixed']; $eol = self::BRANCH_EOL === $st['branch_state']; $alerts = array(); $here = '' === $version ? 'This site\'s PHP version is not known yet: open any admin page once and it is recorded.' : sprintf( 'This site serves PHP %s (branch %s, %s).', $version, $br, self::branch_label( $st['branch_state'] ) ); // 1. Advance notice: once per notice, only while it is open. if ( null !== $notice && $st['notice_open'] && ! isset( $sent[ 'notice:' . $notice['id'] ] ) ) { $when = (int) ( $notice['scheduled_ts'] ?? 0 ) > 0 ? 'Release scheduled for ' . self::when( (int) $notice['scheduled_ts'] ) . '.' : 'php.net has not given a release time.'; $alerts[] = array( 'key' => 'notice:' . $notice['id'], 'kind' => 'notice', 'level' => 'warning', 'subject' => 'PHP security release announced', 'body' => implode( "\n\n", array_filter( array( 'php.net: ' . $notice['title'] . '. ' . $when, (string) ( $notice['summary'] ?? '' ), $here, $eol ? '' : 'What to do: be ready to update PHP on the host (package manager, server image or hosting control panel) when the release is out. You will get one alert when it is published and one when this site runs it.', (string) $notice['url'], ) ) ), 'mark' => array(), ); } // 2. Release published for this branch. if ( null !== $fixed && '' !== $version && ! isset( $sent[ 'release:' . $fixed['id'] ] ) ) { $affected = CSDT_Host_Software::vcmp( $version, (string) $fixed['version'] ) < 0; $recent = $now - (int) $fixed['published_ts'] <= self::RECENT_DAYS * DAY_IN_SECONDS; if ( $affected || $recent ) { $alerts[] = array( 'key' => 'release:' . $fixed['id'], 'kind' => 'release', 'level' => $affected ? 'error' : 'info', 'subject' => sprintf( 'PHP %s security release: this site is %s', $fixed['version'], $affected ? 'affected' : 'not affected' ), 'body' => implode( "\n\n", array_filter( array( sprintf( 'php.net published PHP %s, a security release for branch %s, on %s.', $fixed['version'], $fixed['branch'], self::when( (int) $fixed['published_ts'] ) ), $here, $affected ? sprintf( 'Affected: %s is older than %s. Update PHP to %s or later on the host (package manager, server image or hosting control panel). A WordPress update cannot do this. You will get one alert when this site runs the fixed version.', $version, $fixed['version'], $fixed['version'] ) : sprintf( 'Not affected: %s already includes this fix. Nothing to do.', $version ), (string) ( '' !== $fixed['changelog_url'] ? $fixed['changelog_url'] : $fixed['url'] ), ) ) ), 'mark' => $affected ? array( 'unpatched:' . $fixed['id'] ) : array(), ); } } // 3. End-of-life branch: once per notice, or per release day with no open notice. if ( $eol && '' !== $version ) { $anchor = null; $title = ''; $at = 0; if ( null !== $notice && $st['notice_open'] ) { $anchor = 'eol:' . $notice['id']; $title = $notice['title']; $at = (int) ( $notice['scheduled_ts'] ?? 0 ) > 0 ? (int) $notice['scheduled_ts'] : (int) $notice['published_ts']; } else { $rel = self::security_releases( $events, '', $now - self::RECENT_DAYS * DAY_IN_SECONDS ); if ( ! empty( $rel ) ) { usort( $rel, static function ( array $a, array $b ): int { return $b['published_ts'] <=> $a['published_ts']; } ); // The releases a notice announced were covered by that notice's alert. $covered = null !== $notice && isset( $sent[ 'eol:' . $notice['id'] ] ) && (int) $rel[0]['published_ts'] >= (int) $notice['published_ts']; if ( ! $covered ) { $anchor = 'eol:' . gmdate( 'Y-m-d', (int) $rel[0]['published_ts'] ); $title = $rel[0]['title']; $at = (int) $rel[0]['published_ts']; } } } if ( null !== $anchor && ! isset( $sent[ $anchor ] ) ) { $alerts[] = array( 'key' => $anchor, 'kind' => 'eol', 'level' => 'error', 'subject' => sprintf( 'PHP %s is end of life: this site will not get the security fix', $br ), 'body' => implode( "\n\n", array( sprintf( 'php.net: %s (%s).', $title, self::when( $at ) ), sprintf( 'This site serves PHP %s. Branch %s stopped receiving security fixes%s, so no release will fix it.', $version, $br, '' !== $st['eol'] ? ' on ' . $st['eol'] : '' ), sprintf( 'What to do: move to a supported PHP branch%s on the host, after checking your plugins and theme support it.', '' !== $branch['newest'] ? ' (newest: ' . $branch['newest'] . ')' : '' ), ) ), 'mark' => array(), ); } } // 4. Patched: once, after this site was told it was affected. if ( null !== $fixed && '' !== $version && isset( $sent[ 'unpatched:' . $fixed['id'] ] ) && ! isset( $sent[ 'patched:' . $fixed['id'] ] ) && CSDT_Host_Software::vcmp( $version, (string) $fixed['version'] ) >= 0 ) { $alerts[] = array( 'key' => 'patched:' . $fixed['id'], 'kind' => 'patched', 'level' => 'info', 'subject' => sprintf( 'PHP patched: this site now runs %s', $version ), 'body' => sprintf( "This site now serves PHP %s (recorded %s), which includes the security fix released in %s.\n\nNothing more to do for this release.", $version, self::when( (int) ( $runtime['seen_at'] ?? $now ) ), $fixed['version'] ), 'mark' => array(), ); } return $alerts; } /** * A time in the site's timezone and in UTC: "Wed 21 Oct 2026 16:00 SAST (21 Oct 2026 14:00 UTC)". * * @param int $ts Unix time. * @return string */ public static function when( int $ts ): string { $utc = gmdate( 'j M Y H:i', $ts ) . ' UTC'; $local = function_exists( 'wp_date' ) ? wp_date( 'D j M Y H:i T', $ts ) : false; return is_string( $local ) && '' !== $local ? $local . ' (' . $utc . ')' : $utc; } // ── Running ──────────────────────────────────────────────────────── /** The stored state. */ public static function state(): array { $s = get_option( self::OPTION_STATE, array() ); return is_array( $s ) ? $s : array(); } /** The stored events. */ private static function events(): array { $s = self::state(); return is_array( $s['events'] ?? null ) ? $s['events'] : array(); } /** * Keeps the hourly tick scheduled while alerts are on, and removes it when they are off. * * @return void */ public static function sync_schedule(): void { if ( ! function_exists( 'wp_next_scheduled' ) ) { return; } $hook = self::CRON_HOOK; $scheduled = wp_next_scheduled( $hook ); if ( self::is_enabled() ) { if ( ! $scheduled ) { wp_schedule_event( time() + 120, 'hourly', $hook ); } return; } if ( $scheduled ) { wp_clear_scheduled_hook( $hook ); } } /** The poll interval now: hourly while a notice is open, else six-hourly. */ public static function interval(): int { $events = self::events(); $notice = self::latest_notice( $events ); $runtime = self::runtime(); $branch = null === $runtime ? '' : self::branch_of( $runtime['version'] ); return null !== $notice && self::notice_is_open( $notice, $events, $branch, self::now() ) ? self::OPEN_INTERVAL : self::POLL_INTERVAL; } /** * WP-Cron callback, every hour. Fetches when the interval is due; otherwise re-evaluates the * stored feed only when the recorded runtime changed (the patched alert), which costs no * request. * * @return void */ public static function cron_run(): void { try { if ( ! self::is_enabled() ) { return; } $state = self::state(); $due = self::now() - (int) ( $state['checked_at'] ?? 0 ) >= self::interval() - self::TICK_SLACK; if ( $due ) { self::check( true ); return; } $runtime = self::runtime(); if ( null !== $runtime && $runtime['version'] !== (string) ( $state['evaluated_runtime'] ?? '' ) && ! empty( $state['ok'] ) ) { self::evaluate( true ); } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Fetches the snapshot, stores it, and (when $alert) sends what is owed. * * @param bool $alert Whether to send alerts. * @return array{ok:bool,error:string,sent:int} */ public static function check( bool $alert ): array { $resp = wp_remote_get( self::ENDPOINT, array( 'timeout' => 8, 'sslverify' => true, 'user-agent' => CSDT_DevTools::USER_AGENT, 'headers' => array( 'Accept' => 'application/json' ), 'limit_response_size' => self::MAX_BYTES, ) ); $r = CSDT_Http::json( $resp, self::MAX_BYTES ); $error = ''; $feed = null; if ( 200 !== $r['code'] ) { $error = 0 === $r['code'] ? (string) $r['error'] : 'HTTP ' . (int) $r['code']; } else { $feed = self::normalise_feed( $r['data'] ); if ( is_string( $feed ) ) { $error = $feed; } } $state = self::state(); $state['checked_at'] = self::now(); if ( '' !== $error ) { // The last good events are kept and shown; nothing is decided from a failed fetch. $state['ok'] = false; $state['error'] = $error; update_option( self::OPTION_STATE, $state, false ); return array( 'ok' => false, 'error' => $state['error'], 'sent' => 0, ); } $state['ok'] = true; $state['error'] = ''; $state['fetched_at'] = self::now(); $state['generated_at'] = is_string( $r['data']['generated_at'] ?? null ) ? substr( $r['data']['generated_at'], 0, 25 ) : ''; $state['events'] = $feed; update_option( self::OPTION_STATE, $state, false ); return array( 'ok' => true, 'error' => '', 'sent' => self::evaluate( $alert ), ); } /** * Decides from the stored events and sends what is owed. Marks an alert sent only when a * channel delivered it; after MAX_ATTEMPTS undelivered tries it is marked anyway. * * @param bool $alert Whether to send. * @return int Alerts delivered. */ public static function evaluate( bool $alert ): int { $runtime = self::runtime(); $rel = CSDT_Host_Software::releases( true ); $branch = self::branch_status( is_array( $rel['data'] ) ? $rel['data'] : null, null === $runtime ? '' : $runtime['version'], self::now() ); $sent = self::sent(); $state = self::state(); $retries = is_array( $state['retries'] ?? null ) ? $state['retries'] : array(); $count = 0; if ( $alert && self::is_enabled() ) { foreach ( self::plan( self::events(), $runtime, $branch, $sent, self::now() ) as $a ) { $ok = self::deliver( $a ); if ( $ok ) { ++$count; } $tries = (int) ( $retries[ $a['key'] ] ?? 0 ) + 1; if ( $ok || $tries >= self::MAX_ATTEMPTS ) { $sent[ $a['key'] ] = self::now(); foreach ( $a['mark'] as $k ) { $sent[ $k ] = self::now(); } unset( $retries[ $a['key'] ] ); } else { $retries[ $a['key'] ] = $tries; } } update_option( self::OPTION_SENT, self::prune( $sent, self::now() ), false ); } $state = self::state(); $state['retries'] = $retries; $state['evaluated_runtime'] = null === $runtime ? '' : $runtime['version']; update_option( self::OPTION_STATE, $state, false ); return $count; } /** OPTION_SENT, as stored. */ public static function sent(): array { $s = get_option( self::OPTION_SENT, array() ); return is_array( $s ) ? $s : array(); } /** * Drops entries older than PRUNE_DAYS. * * @param array $sent Map. * @param int $now Unix time. * @return array */ public static function prune( array $sent, int $now ): array { $cut = $now - self::PRUNE_DAYS * DAY_IN_SECONDS; return array_filter( $sent, static function ( $at ) use ( $cut ): bool { return (int) $at >= $cut; } ); } /** * Telegram through CSDT_Alert_Events SECURITY, and email to the admin address. * * @param array $a One planned alert. * @return bool Whether any channel delivered it. */ private static function deliver( array $a ): bool { $site = wp_specialchars_decode( (string) ( get_bloginfo( 'name' ) ?: home_url() ), ENT_QUOTES ); $url = admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=security' ) . '#cs-panel-php-advisory'; $subj = sprintf( 'CSDT: %s (%s)', $a['subject'], $site ); $body = $a['body'] . "\n\nPHP Advisory Alerts: " . $url; $tg = CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, $subj . "\n\n" . $body, 'PHP Advisory Alerts', (string) $a['level'] ); $mail = false; if ( is_callable( array( 'CSDT_Threat_Monitor', 'send_alert_email' ) ) ) { $mail = CSDT_Threat_Monitor::send_alert_email( (string) get_option( 'admin_email' ), $subj, $body ); } return $tg || $mail; } // ── Admin ────────────────────────────────────────────────────────── /** The status for the current site, from stored data only (no request). */ public static function current_status(): array { $runtime = self::runtime(); $rel = CSDT_Host_Software::releases( false ); $branch = self::branch_status( is_array( $rel['data'] ) ? $rel['data'] : null, null === $runtime ? '' : $runtime['version'], self::now() ); $st = self::status( self::events(), $runtime, $branch, self::now() ); $state = self::state(); $st['runtime'] = $runtime; $st['checked_at'] = (int) ( $state['checked_at'] ?? 0 ); $st['fetched_at'] = (int) ( $state['fetched_at'] ?? 0 ); $st['error'] = (string) ( $state['error'] ?? '' ); $st['enabled'] = self::is_enabled(); return $st; } /** How a patch state reads. */ public static function patch_label( string $patch ): string { $labels = array( self::PATCH_PATCHED => 'Patched', self::PATCH_NOT_PATCHED => 'Not patched', self::PATCH_NONE => 'No patch available', self::PATCH_UNKNOWN => 'Unknown', ); return $labels[ $patch ] ?? $labels[ self::PATCH_UNKNOWN ]; } /** Colours for a patch state: background, text. */ private static function patch_colours( string $patch ): array { $map = array( self::PATCH_PATCHED => array( '#dcfce7', '#166534' ), self::PATCH_NOT_PATCHED => array( '#fee2e2', '#991b1b' ), self::PATCH_NONE => array( '#fef3c7', '#92400e' ), ); return $map[ $patch ] ?? array( '#f1f5f9', '#334155' ); } /** * The status block, escaped. Rendered on page load and returned by Check now. * * @param array $st current_status(). * @return string */ public static function status_html( array $st ): string { $c = self::patch_colours( (string) $st['patch'] ); $rows = array(); $rows[] = array( 'PHP served by the web server', '' === $st['version'] ? 'Not recorded yet. It is read on the next admin page load.' : $st['version'] . ( null !== $st['runtime'] && (int) $st['runtime']['seen_at'] > 0 ? ' (seen ' . self::when( (int) $st['runtime']['seen_at'] ) . ')' : '' ), ); $rows[] = array( 'Branch', '' === $st['branch'] ? 'Unknown' : $st['branch'] . ': ' . self::branch_label( (string) $st['branch_state'] ) . ( '' !== $st['eol'] ? ', security support until ' . $st['eol'] : '' ), ); $last = 'None in the last 400 days.'; if ( is_array( $st['last'] ) ) { $last = $st['last']['title'] . ', ' . self::when( (int) $st['last']['published_ts'] ); if ( 'advance_notice' === $st['last']['type'] && (int) ( $st['last']['scheduled_ts'] ?? 0 ) > 0 ) { $last .= '. Release due ' . self::when( (int) $st['last']['scheduled_ts'] ); } } $rows[] = array( 'Last advisory', $last ); $fixed = is_array( $st['fixed'] ) ? ' (fix: PHP ' . $st['fixed']['version'] . ')' : ''; $rows[] = array( 'This site', self::patch_label( (string) $st['patch'] ) . $fixed ); $feed = (int) $st['fetched_at'] > 0 ? 'Feed read ' . self::when( (int) $st['fetched_at'] ) . '.' : 'Feed not read yet.'; if ( '' !== $st['error'] ) { $feed .= ' Last check failed: ' . $st['error'] . '.'; } $rows[] = array( 'Feed', $feed . ( $st['enabled'] ? '' : ' Alerts are off.' ) ); $h = '' . esc_html( self::patch_label( (string) $st['patch'] ) ) . ''; $h .= ''; foreach ( $rows as $r ) { $h .= ''; } return $h . '
' . esc_html( $r[0] ) . '' . esc_html( $r[1] ) . '
'; } /** The Check now button: fetches, decides and sends what is owed. */ 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 ); } $r = self::check( true ); $st = self::current_status(); if ( $r['ok'] ) { /* translators: %d: number of alerts sent. */ $msg = sprintf( _n( 'Checked. %d alert sent.', 'Checked. %d alerts sent.', (int) $r['sent'], 'cloudscale-cyber-devtools' ), (int) $r['sent'] ); } else { /* translators: %s: why the check failed. */ $msg = sprintf( __( 'Check failed: %s. The last good feed is still shown.', 'cloudscale-cyber-devtools' ), $r['error'] ); } wp_send_json_success( array( 'ok' => $r['ok'], 'msg' => $msg, 'html' => self::status_html( $st ), ) ); } /** Saves the alerts switch. */ public static function ajax_save(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $on = '1' === sanitize_text_field( wp_unslash( $_POST['enabled'] ?? '0' ) ) ? '1' : '0'; update_option( self::OPTION_ENABLED, $on, false ); self::sync_schedule(); wp_send_json_success( array( 'msg' => '1' === $on ? __( 'Saved. PHP advisory alerts are on.', 'cloudscale-cyber-devtools' ) : __( 'Saved. PHP advisory alerts are off and nothing is fetched on a schedule.', 'cloudscale-cyber-devtools' ), ) ); } /** One line for the dashboard widget. */ public static function render_widget_line(): void { try { $st = self::current_status(); } catch ( \Throwable $e ) { return; } $c = self::patch_colours( (string) $st['patch'] ); $href = admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=security' ) . '#cs-panel-php-advisory'; $text = '' === $st['version'] ? __( 'version not recorded yet', 'cloudscale-cyber-devtools' ) : $st['version'] . ', ' . self::branch_label( (string) $st['branch_state'] ); ?>
🚨 'What it alerts on', 'rec' => 'Info', 'html' => '

Four alerts, each sent once: an advance notice that php.net has scheduled a security release, with the release time in your timezone and in UTC; the release for your PHP branch, saying whether this site is affected and what to do; an end of life warning when your branch will not get the fix; and patched, when this site runs the fixed version.

Alerts go to Telegram (when set up) and to the site admin email address.

', ), array( 'name' => 'Which PHP it checks', 'rec' => 'Info', 'html' => '

The PHP your web server runs, read when an administrator opens an admin page. WP-CLI and server cron jobs often use a different PHP, so their version is never used.

Branch support (active, security fixes only, end of life) comes from the same release data as the Host Software panel.

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

The CloudScale API reads php.net\'s news feed every 30 minutes and serves one snapshot. This site fetches it every 6 hours, and hourly while a release is announced but not yet out. Nothing about your site is sent: each site decides for itself whether it is affected.

If the feed cannot be read the panel says so and no alert is sent. Nothing on your site is changed.

', ), array( 'name' => 'Check now and the switch', 'rec' => 'Info', 'html' => '

Check now reads the snapshot immediately and sends any alert that is due, once. Send PHP advisory alerts is off by default; Turn on PHP advisory alerts, or tick it and save, to start the scheduled check and the alerts. Untick it and save to stop both.

The daily CVE check for plugins, themes, WordPress and server software is the CVE Alerts panel below.

', ), ) ); ?>