version. Keys: core, php, php_cli, theme:, plugin:. * * @return array */ public static function components(): array { $c = array( 'core' => (string) get_bloginfo( 'version' ) ); $runtime = class_exists( 'CSDT_Php_Advisory' ) ? CSDT_Php_Advisory::runtime() : null; $c['php'] = null === $runtime ? '' : (string) $runtime['version']; // The PHP running this tick as well. It is not the web server's (that is 'php' above), but // an image rebuild changes both, and this one is seen on the next tick rather than only // after an administrator next opens an admin page. On a host where they differ it is just // a second, stable value. $c['php_cli'] = PHP_VERSION; $theme = wp_get_theme(); if ( is_object( $theme ) ) { $c[ 'theme:' . $theme->get_stylesheet() ] = (string) $theme->get( 'Version' ); $parent = $theme->parent(); if ( is_object( $parent ) ) { $c[ 'theme:' . $parent->get_stylesheet() ] = (string) $parent->get( 'Version' ); } } if ( ! function_exists( 'get_plugins' ) && is_readable( CSDT_Paths::root_file( 'wp-admin/includes/plugin.php' ) ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all = function_exists( 'get_plugins' ) ? (array) get_plugins() : array(); foreach ( self::active_plugin_files() as $file ) { $c[ 'plugin:' . $file ] = (string) ( $all[ $file ]['Version'] ?? '' ); } return $c; } /** * Active plugin files, network-activated ones included. * * @return array */ private static function active_plugin_files(): array { $files = array_map( 'strval', (array) get_option( 'active_plugins', array() ) ); if ( function_exists( 'is_multisite' ) && is_multisite() ) { $files = array_merge( $files, array_map( 'strval', array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ) ); } $files = array_values( array_unique( array_filter( $files ) ) ); sort( $files, SORT_STRING ); return $files; } /** * sha256 over the sorted "key=version" lines. Order of the input does not matter. * * @param array $components components(). * @return string */ public static function fingerprint( array $components ): string { ksort( $components, SORT_STRING ); $lines = array(); foreach ( $components as $k => $v ) { $lines[] = $k . '=' . $v; } return hash( 'sha256', implode( "\n", $lines ) ); } /** * What changed between two component maps: rows of {key, from, to}, '' meaning absent. * * The web server's PHP version going from unknown to known is not a change: it is the first * admin page load after install recording it. * * @param array $old Before. * @param array $new After. * @return array */ public static function diff( array $old, array $new ): array { $keys = array_unique( array_merge( array_keys( $old ), array_keys( $new ) ) ); sort( $keys, SORT_STRING ); $rows = array(); foreach ( $keys as $k ) { $from = (string) ( $old[ $k ] ?? '' ); $to = (string) ( $new[ $k ] ?? '' ); if ( $from === $to || ( 'php' === $k && '' === $from ) ) { continue; } $rows[] = array( 'key' => (string) $k, 'from' => $from, 'to' => $to, ); } return $rows; } /** * Two diffs as one: the earliest "from" and the latest "to" per component; a component that * went back to where it started drops out. * * @param array $first Older diff. * @param array $second Newer diff. * @return array */ public static function merge_diff( array $first, array $second ): array { $by = array(); foreach ( array_merge( $first, $second ) as $row ) { if ( ! is_array( $row ) || ! isset( $row['key'] ) ) { continue; } $k = (string) $row['key']; if ( ! isset( $by[ $k ] ) ) { $by[ $k ] = array( 'key' => $k, 'from' => (string) ( $row['from'] ?? '' ), 'to' => (string) ( $row['to'] ?? '' ), ); continue; } $by[ $k ]['to'] = (string) ( $row['to'] ?? '' ); } ksort( $by, SORT_STRING ); return array_values( array_filter( $by, static function ( array $r ): bool { return $r['from'] !== $r['to']; } ) ); } /** * A component's name, from its key. * * @param string $key core, php, php_cli, plugin: or theme:. * @return string */ public static function component_label( string $key ): string { if ( 'core' === $key ) { return 'WordPress core'; } if ( 'php' === $key ) { return 'PHP (web server)'; } if ( 'php_cli' === $key ) { return 'PHP (scheduled tasks)'; } if ( 0 === strpos( $key, 'plugin:' ) ) { return 'Plugin ' . substr( $key, 7 ); } if ( 0 === strpos( $key, 'theme:' ) ) { return 'Theme ' . substr( $key, 6 ); } return $key; } /** * One changed component, in words. * * @param array $row A diff row. * @return string */ public static function describe_change( array $row ): string { $name = self::component_label( (string) ( $row['key'] ?? '' ) ); $from = (string) ( $row['from'] ?? '' ); $to = (string) ( $row['to'] ?? '' ); if ( '' === $from ) { return $name . ' added (' . ( '' === $to ? 'no version' : $to ) . ')'; } if ( '' === $to ) { return $name . ' removed or deactivated (was ' . $from . ')'; } return $name . ' ' . $from . ' to ' . $to; } // ── Detection ────────────────────────────────────────────────────── /** The stored fingerprint record, or array(). */ public static function stored(): array { $s = get_option( self::OPTION_FINGERPRINT, array() ); return is_array( $s ) ? $s : array(); } /** The pending run's record, or array(). */ public static function pending(): array { $p = get_option( self::OPTION_PENDING, array() ); return is_array( $p ) ? $p : array(); } /** * Compares the running site with the stored fingerprint, and stores the new one when it * differs (or when none was stored yet). Writes nothing when nothing changed. * * @return array{state:string,diff:array} state: baseline (first sight), same, or changed. */ public static function detect(): array { $comps = self::components(); $hash = self::fingerprint( $comps ); $stored = self::stored(); if ( '' === (string) ( $stored['hash'] ?? '' ) ) { self::store_fingerprint( $hash, $comps, self::now(), array() ); return array( 'state' => 'baseline', 'diff' => array(), ); } if ( hash_equals( (string) $stored['hash'], $hash ) ) { return array( 'state' => 'same', 'diff' => array(), ); } $diff = self::diff( is_array( $stored['components'] ?? null ) ? $stored['components'] : array(), $comps ); if ( array() === $diff ) { // Only the PHP version became known: recorded, not verified. self::store_fingerprint( $hash, $comps, (int) ( $stored['changed_at'] ?? 0 ), is_array( $stored['diff'] ?? null ) ? $stored['diff'] : array() ); return array( 'state' => 'baseline', 'diff' => array(), ); } self::store_fingerprint( $hash, $comps, self::now(), $diff ); return array( 'state' => 'changed', 'diff' => $diff, ); } /** Writes the fingerprint record. */ private static function store_fingerprint( string $hash, array $comps, int $changed_at, array $diff ): void { update_option( self::OPTION_FINGERPRINT, array( 'hash' => $hash, 'components' => $comps, 'at' => self::now(), 'changed_at' => $changed_at, 'diff' => $diff, ), false ); } /** * Books one verification SETTLE_DELAY from now, or merges into the one already pending. * * @param string $trigger change, upgrader. * @param array $diff What changed, possibly empty (the upgrader hook does not say). * @return bool Whether a new run was booked (false: merged into the pending one). */ public static function request_run( string $trigger, array $diff ): bool { $pending = self::pending(); if ( array() !== $pending && wp_next_scheduled( self::RUN_HOOK ) ) { $pending['diff'] = self::merge_diff( is_array( $pending['diff'] ?? null ) ? $pending['diff'] : array(), $diff ); if ( false === strpos( (string) ( $pending['trigger'] ?? '' ), $trigger ) ) { $pending['trigger'] = trim( (string) ( $pending['trigger'] ?? '' ) . '+' . $trigger, '+' ); } update_option( self::OPTION_PENDING, $pending, false ); return false; } update_option( self::OPTION_PENDING, array( 'trigger' => $trigger, 'diff' => $diff, 'detected_at' => self::now(), ), false ); wp_schedule_single_event( self::now() + self::SETTLE_DELAY, 'csdt_upgrade_verify_run' ); return true; } /** * WP-Cron callback, every five minutes: one option read, and a write only on a change. * * @return string What happened: off, standby, baseline, same, changed, error. */ public static function tick(): string { try { if ( ! self::is_enabled() || ! self::in_background() ) { return 'off'; } if ( self::is_standby() ) { return 'standby'; } $d = self::detect(); if ( 'changed' === $d['state'] ) { self::request_run( 'change', $d['diff'] ); } return $d['state']; } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); return 'error'; } } /** * Hooked to upgrader_process_complete with accepted_args 0. Books a run; the run itself * works out what changed, because the upgrader's own request may still hold stale versions. * * @return void */ public static function on_upgrade(): void { try { if ( ! self::is_enabled() || self::is_front_end() || self::is_standby() ) { return; } self::request_run( 'upgrader', array() ); } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } /** * WP-Cron callback for the pending run. * * @return void */ public static function run_scheduled(): void { try { if ( ! self::is_enabled() || ! self::in_background() || self::is_standby() ) { return; } $pending = self::pending(); delete_option( self::OPTION_PENDING ); $d = self::detect(); $diff = self::merge_diff( is_array( $pending['diff'] ?? null ) ? $pending['diff'] : array(), $d['diff'] ); self::run( (string) ( $pending['trigger'] ?? 'change' ), $diff, (int) ( $pending['detected_at'] ?? self::now() ) ); } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } // ── A run ────────────────────────────────────────────────────────── /** * Runs every check, stores the run, alerts when owed. * * @param string $trigger change, upgrader, manual. * @param array $diff What changed. * @param int $since When the change was detected; fatals before this are not counted. * @return array The run record. */ public static function run( string $trigger, array $diff, int $since ): array { $checks = self::run_checks( $diff, $since ); $record = array( 'ts' => self::now(), 'trigger' => $trigger, 'diff' => $diff, 'checks' => $checks, 'verdict' => self::verdict( $checks ), 'alerted' => false, ); // Verify now is answered on screen, so it never alerts: the person who clicked is reading // the result, and a test session's click must not page the owner (it did, 2026-10-21). $record['alerted'] = 'manual' === $trigger ? false : self::maybe_alert( $record ); self::store_run( $record ); return $record; } /** The stored runs, newest first. */ public static function runs(): array { $r = get_option( self::OPTION_RUNS, array() ); return is_array( $r ) ? array_values( array_filter( $r, 'is_array' ) ) : array(); } /** Prepends a run and keeps MAX_RUNS. */ private static function store_run( array $record ): void { $runs = self::runs(); array_unshift( $runs, $record ); update_option( self::OPTION_RUNS, array_slice( $runs, 0, self::MAX_RUNS ), false ); } /** * The worst result: fail, then warn, else pass. Skips do not count. * * @param array $checks Check results. * @return string */ public static function verdict( array $checks ): string { $statuses = array_column( $checks, 'status' ); if ( in_array( self::FAIL, $statuses, true ) ) { return self::FAIL; } return in_array( self::WARN, $statuses, true ) ? self::WARN : self::PASS; } /** * The front page's usual time: the median of its time in the last BASELINE_RUNS passing runs. * * @param array $runs runs(), newest first. * @return int Milliseconds, 0 when there is no passing run yet. */ public static function front_baseline( array $runs ): int { $times = array(); foreach ( $runs as $run ) { foreach ( (array) ( $run['checks'] ?? array() ) as $c ) { if ( 'front' === ( $c['id'] ?? '' ) && self::PASS === ( $c['status'] ?? '' ) && (int) ( $c['ms'] ?? 0 ) > 0 ) { $times[] = (int) $c['ms']; } } if ( count( $times ) >= self::BASELINE_RUNS ) { break; } } if ( array() === $times ) { return 0; } sort( $times, SORT_NUMERIC ); $n = count( $times ); $mid = intdiv( $n, 2 ); return 0 === $n % 2 ? (int) round( ( $times[ $mid - 1 ] + $times[ $mid ] ) / 2 ) : $times[ $mid ]; } /** * A passing front page that took more than SLOW_FACTOR times its baseline becomes a warning. * * @param array $check The front page result. * @param int $baseline front_baseline(). * @return array */ public static function apply_baseline( array $check, int $baseline ): array { if ( self::PASS === $check['status'] && $baseline > 0 && (int) $check['ms'] > self::SLOW_FACTOR * $baseline ) { $check['status'] = self::WARN; $check['reason'] = sprintf( 'Answered, but in %d ms, more than %d times its usual %d ms.', (int) $check['ms'], self::SLOW_FACTOR, $baseline ); } return $check; } /** * Runs one check, timed. A check that throws is a warning that it could not run, never a pass. * * @param string $id Check id. * @param string $label What it is called. * @param callable $fn Returns array( status, reason ). * @return array{id:string,label:string,status:string,reason:string,ms:int} */ private static function timed( string $id, string $label, callable $fn ): array { $t0 = microtime( true ); try { list( $status, $reason ) = $fn(); } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ . ' ' . $id ); $status = self::WARN; $reason = 'The check could not run: ' . substr( $e->getMessage(), 0, 160 ); } return array( 'id' => $id, 'label' => $label, 'status' => (string) $status, 'reason' => (string) $reason, 'ms' => (int) round( ( microtime( true ) - $t0 ) * 1000 ), ); } /** * Every check, in order. * * @param array $diff What changed. * @param int $since When the change was detected. * @return array */ public static function run_checks( array $diff, int $since ): array { $baseline = self::front_baseline( self::runs() ); $out = array(); $out[] = self::apply_baseline( self::timed( 'front', 'Front page', static function (): array { return self::check_front_page(); } ), $baseline ); $out[] = self::timed( 'login', 'Login page', static function (): array { return self::check_login(); } ); $out[] = self::timed( 'rest', 'REST API', static function (): array { return self::check_rest(); } ); $out[] = self::timed( 'ajax', 'Admin AJAX', static function (): array { return self::check_ajax(); } ); $out[] = self::timed( 'fatals', 'PHP fatals', static function () use ( $since ): array { return self::check_fatals( $since ); } ); $out[] = self::timed( 'versions', 'Versions landed', static function () use ( $diff ): array { return self::check_versions( $diff ); } ); $out[] = self::timed( 'core', 'Core files', static function () use ( $diff ): array { return self::check_core( $diff ); } ); $out[] = self::timed( 'cron', 'Scheduled tasks', static function (): array { return self::check_cron(); } ); return $out; } // ── The checks ───────────────────────────────────────────────────── /** * Arguments for a loopback request: the plugin's user agent, the marker header, the site's * own TLS rule. CSDT_Auto_Block::sign_self_request() adds the self-signature on the way out. * * @param array $extra More arguments. * @return array */ private static function probe_args( array $extra = array() ): array { return array_merge( array( 'timeout' => self::HTTP_TIMEOUT, 'redirection' => 3, 'user-agent' => CSDT_DevTools::USER_AGENT, 'headers' => array( self::PROBE_HEADER => '1', 'Cache-Control' => 'no-cache', ), 'sslverify' => csdt_loopback_ssl_verify(), ), $extra ); } /** * Why a loopback page is not a working page, or '' when it is. * * @param array $h CSDT_Http::json() result. * @param bool $is_html Whether the page must end with . * @return string */ public static function page_problem( array $h, bool $is_html ): string { if ( 0 === (int) $h['code'] ) { return 'No answer: ' . substr( (string) $h['error'], 0, 160 ); } if ( false !== stripos( (string) $h['body'], self::CRITICAL_ERROR ) ) { return 'WordPress showed its critical error page (HTTP ' . (int) $h['code'] . ').'; } if ( (int) $h['code'] < 200 || (int) $h['code'] >= 300 ) { return 'HTTP ' . (int) $h['code'] . '.'; } if ( $is_html && false === stripos( (string) $h['body'], '' ) ) { return 'The page stopped before : something cut it short.'; } return ''; } /** 1. The front page, past any page cache. */ public static function check_front_page(): array { $url = add_query_arg( 'csdt_uv', (string) self::now(), home_url( '/' ) ); $args = self::probe_args(); $h = CSDT_Http::json( wp_remote_get( $url, $args ), 1 ); $why = self::page_problem( $h, true ); if ( '' !== $why ) { return array( self::FAIL, $why ); } return array( self::PASS, 'HTTP ' . (int) $h['code'] . ', a complete page.' ); } /** Whether Hide Login is on with a slug, so the login page lives at that address. */ public static function login_hidden(): bool { return '1' === (string) CSDT_Options::get( 'csdt_devtools_login_hide_enabled' ) && '' !== trim( (string) CSDT_Options::get( 'csdt_devtools_login_slug' ), '/ ' ); } /** * 2. The login page, at its real address. While Hide Login is on that is the slug: a request * for wp-login.php would be refused by design and recorded as a login probe from this site. * The address is never put in the reason, which reaches the alert channel. */ public static function check_login(): array { $hidden = self::login_hidden(); $url = $hidden ? home_url( '/' . trim( (string) CSDT_Options::get( 'csdt_devtools_login_slug' ), '/ ' ) . '/' ) : wp_login_url(); $args = self::probe_args(); $h = CSDT_Http::json( wp_remote_get( $url, $args ), 1 ); $what = $hidden ? 'The hidden login page' : 'The login page'; $why = self::page_problem( $h, false ); if ( '' !== $why ) { return array( self::FAIL, $what . ': ' . $why ); } if ( 200 !== (int) $h['code'] ) { return array( self::FAIL, $what . ' answered HTTP ' . (int) $h['code'] . ' where 200 was expected.' ); } return array( self::PASS, $what . ' answered HTTP 200.' ); } /** 3. The REST index answers JSON with its namespaces. */ public static function check_rest(): array { $args = self::probe_args(); $h = CSDT_Http::json( wp_remote_get( rest_url(), $args ), 2097152 ); if ( 0 === (int) $h['code'] ) { return array( self::FAIL, 'No answer: ' . substr( (string) $h['error'], 0, 160 ) ); } if ( 200 !== (int) $h['code'] ) { return array( self::FAIL, 'HTTP ' . (int) $h['code'] . ' from the REST index.' ); } if ( ! is_array( $h['data'] ) ) { return array( self::FAIL, 'HTTP 200, but not JSON: something other than WordPress answered, or PHP printed an error first.' ); } if ( ! isset( $h['data']['namespaces'] ) || ! is_array( $h['data']['namespaces'] ) ) { return array( self::FAIL, 'JSON, but without the namespaces list the REST index always has.' ); } return array( self::PASS, sprintf( 'JSON with %d namespaces.', count( $h['data']['namespaces'] ) ) ); } /** 4. admin-ajax.php with an action nobody registered: core answers 400 "0" once admin has loaded. */ public static function check_ajax(): array { $args = self::probe_args( array( 'body' => array( 'action' => self::UNKNOWN_ACTION ) ) ); $h = CSDT_Http::json( wp_remote_post( admin_url( 'admin-ajax.php' ), $args ), 1 ); if ( 0 === (int) $h['code'] ) { return array( self::FAIL, 'No answer: ' . substr( (string) $h['error'], 0, 160 ) ); } if ( false !== stripos( (string) $h['body'], self::CRITICAL_ERROR ) || (int) $h['code'] >= 500 ) { return array( self::FAIL, 'admin-ajax.php failed to load (HTTP ' . (int) $h['code'] . '): a plugin or theme fatals while admin starts.' ); } // Core prints "0"; some stacks strip that one byte (seen on prod, 2026-10-21: 400 with an // empty body). Either way the 400 itself is core's own answer, which needs admin loaded. if ( 400 === (int) $h['code'] && in_array( trim( (string) $h['body'] ), array( '0', '' ), true ) ) { return array( self::PASS, 'HTTP 400, as core answers an unknown action: admin loads without errors.' ); } return array( self::FAIL, sprintf( 'HTTP %d "%s" where core answers 400 "0".', (int) $h['code'], substr( sanitize_text_field( (string) $h['body'] ), 0, 40 ) ) ); } /** * 5. No PHP fatal since the change was detected: from the PHP log when it is readable, and from * the shutdown handler's fatal store (CSDT_Monitor::FATAL_STORE_OPTION) either way. * * @param int $since When the change was detected. */ public static function check_fatals( int $since ): array { $f = CSDT_Monitor::fatals_since( $since ); $store = ! empty( $f['store'] ); if ( 0 === (int) $f['readable'] && ! $store ) { return array( self::SKIP, 'No PHP error log is readable here and the fatal recorder is not running, so fatals cannot be counted.' ); } if ( (int) $f['count'] > 0 ) { return array( self::FAIL, sprintf( '%d PHP fatal error(s) since the change. Newest: %s', (int) $f['count'], substr( (string) $f['latest'], 0, 160 ) ) ); } if ( 0 === (int) $f['readable'] ) { return array( self::PASS, '0 fatals since the change, counted by the plugin\'s fatal recorder (the PHP log is not a readable file here).' ); } return array( self::PASS, '0 fatals since the change.' ); } /** * The version a component reports now, read from disk rather than from this process's caches, * or '' when it is not there. * * @param string $key A component key. * @return string */ public static function landed( string $key ): string { if ( 'core' === $key ) { $file = CSDT_Paths::root_file( 'wp-includes/version.php' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local core file, read for its version line; never a URL $src = is_readable( $file ) ? (string) file_get_contents( $file, false, null, 0, 8192 ) : ''; return preg_match( '/\$wp_version\s*=\s*[\'"]([^\'"]+)[\'"]/', $src, $m ) ? $m[1] : ''; } if ( 'php' === $key ) { $rt = class_exists( 'CSDT_Php_Advisory' ) ? CSDT_Php_Advisory::runtime() : null; return null === $rt ? '' : (string) $rt['version']; } if ( 'php_cli' === $key ) { return PHP_VERSION; } if ( 0 === strpos( $key, 'plugin:' ) ) { $file = substr( $key, 7 ); if ( ! in_array( $file, self::active_plugin_files(), true ) ) { return ''; } $path = CSDT_Paths::plugins_dir() . '/' . $file; return is_readable( $path ) ? (string) ( get_file_data( $path, array( 'Version' => 'Version' ) )['Version'] ?? '' ) : ''; } if ( 0 === strpos( $key, 'theme:' ) ) { $slug = substr( $key, 6 ); $theme = wp_get_theme(); $live = array( $theme->get_stylesheet() ); if ( is_object( $theme->parent() ) ) { $live[] = $theme->parent()->get_stylesheet(); } if ( ! in_array( $slug, $live, true ) ) { return ''; } $path = get_theme_root( $slug ) . '/' . $slug . '/style.css'; return is_readable( $path ) ? (string) ( get_file_data( $path, array( 'Version' => 'Version' ) )['Version'] ?? '' ) : ''; } return ''; } /** * 6. Every changed component reports the version the diff says. A half-applied update, or * this plugin's new files with its old code still loaded, fails here. * * @param array $diff What changed. */ public static function check_versions( array $diff ): array { if ( array() === $diff ) { return array( self::SKIP, 'No component change was recorded for this run.' ); } $bad = array(); $self = function_exists( 'plugin_basename' ) ? 'plugin:' . plugin_basename( CSDT_PLUGIN_FILE ) : ''; foreach ( $diff as $row ) { $key = (string) ( $row['key'] ?? '' ); $want = (string) ( $row['to'] ?? '' ); $have = self::landed( $key ); if ( $have !== $want ) { $bad[] = sprintf( '%s reports %s, expected %s', self::component_label( $key ), '' === $have ? 'nothing' : $have, '' === $want ? 'nothing' : $want ); } elseif ( $key === $self && '' !== $want && CSDT_DevTools::VERSION !== $want ) { $bad[] = sprintf( 'this plugin\'s files are %s but the code running is %s (OPcache or a stale copy)', $want, CSDT_DevTools::VERSION ); } } if ( array() !== $bad ) { return array( self::FAIL, 'Half applied: ' . implode( '; ', array_slice( $bad, 0, 4 ) ) . ( count( $bad ) > 4 ? sprintf( '; and %d more', count( $bad ) - 4 ) : '' ) . '.' ); } return array( self::PASS, sprintf( '%d changed component(s), each at the version recorded.', count( $diff ) ) ); } /** * 7. Core files against WordPress's checksums, when core changed. Skipped while file integrity * is off, so a default install makes no request it did not make before. * * @param array $diff What changed. */ public static function check_core( array $diff ): array { if ( ! in_array( 'core', array_column( $diff, 'key' ), true ) ) { return array( self::SKIP, 'WordPress core did not change.' ); } if ( ! class_exists( 'CSDT_Integrity' ) || ! CSDT_Integrity::is_enabled() ) { return array( self::SKIP, 'Core file integrity is off, so WordPress.org\'s checksums are not fetched.' ); } $r = CSDT_Integrity::verify_core_now( self::CORE_BUDGET ); if ( false === $r['ok'] ) { return array( self::FAIL, sprintf( '%d core file(s) differ from WordPress\'s checksums: %s', count( $r['modified'] ), implode( ', ', array_slice( $r['modified'], 0, 5 ) ) ) ); } if ( null === $r['ok'] && 'time-budget' === $r['reason'] ) { return array( self::WARN, sprintf( 'Not finished in %d seconds; %d files checked, none differ.', self::CORE_BUDGET, (int) $r['checked'] ) ); } if ( null === $r['ok'] ) { return array( self::SKIP, 'WordPress.org\'s checksums could not be fetched, so core files were not verified.' ); } return array( self::PASS, sprintf( '%d core files match WordPress\'s checksums.', (int) $r['checked'] ) ); } /** 8. No csdt_ event overdue by more than CRON_OVERDUE: cron still runs. */ public static function check_cron(): array { if ( ! function_exists( '_get_cron_array' ) ) { return array( self::SKIP, 'The schedule cannot be read here.' ); } $late = array(); $cut = self::now() - self::CRON_OVERDUE; foreach ( (array) _get_cron_array() as $ts => $hooks ) { if ( (int) $ts >= $cut || ! is_array( $hooks ) ) { continue; } foreach ( array_keys( $hooks ) as $hook ) { if ( 0 === strpos( (string) $hook, 'csdt_' ) ) { $late[ (string) $hook ] = max( (int) ( $late[ (string) $hook ] ?? 0 ), self::now() - (int) $ts ); } } } if ( array() !== $late ) { arsort( $late ); $names = array(); foreach ( array_slice( $late, 0, 4, true ) as $hook => $secs ) { $names[] = sprintf( '%s (%d min late)', $hook, (int) floor( $secs / 60 ) ); } return array( self::FAIL, sprintf( '%d scheduled task(s) overdue by more than %d minutes: %s. WP-Cron has stopped running them.', count( $late ), (int) ( self::CRON_OVERDUE / 60 ), implode( ', ', $names ) ) ); } return array( self::PASS, sprintf( 'No CloudScale task is more than %d minutes overdue.', (int) ( self::CRON_OVERDUE / 60 ) ) ); } // ── Alerts ───────────────────────────────────────────────────────── /** * The alert for a run: subject, body and level. Pure. * * @param array $run A run record. * @param string $site Site name. * @param string $url The panel's address. * @return array{subject:string,body:string,level:string} */ public static function alert_text( array $run, string $site, string $url ): array { $verdict = (string) ( $run['verdict'] ?? self::PASS ); $words = array( self::FAIL => 'FAILED', self::WARN => 'passed with warnings', self::PASS => 'passed', ); $lines = array(); $diff = (array) ( $run['diff'] ?? array() ); $lines[] = array() === $diff ? 'What changed: nothing recorded (' . (string) ( $run['trigger'] ?? '' ) . ').' : 'What changed:'; foreach ( $diff as $row ) { $lines[] = '- ' . self::describe_change( (array) $row ); } $flag = array(); foreach ( (array) ( $run['checks'] ?? array() ) as $c ) { if ( in_array( $c['status'] ?? '', array( self::FAIL, self::WARN ), true ) ) { $flag[] = '- ' . ( self::FAIL === $c['status'] ? 'FAIL ' : 'WARN ' ) . $c['label'] . ': ' . $c['reason']; } } if ( array() !== $flag ) { $lines[] = ''; $lines[] = self::FAIL === $verdict ? 'Failing checks:' : 'Warnings:'; $lines = array_merge( $lines, $flag ); } else { $lines[] = ''; $lines[] = 'Every check passed.'; } $lines[] = ''; $lines[] = 'Nothing was rolled back or changed: this is a report.'; $lines[] = 'Upgrade Verification: ' . $url; return array( 'subject' => sprintf( 'CSDT: Upgrade verification %s (%s)', $words[ $verdict ] ?? $verdict, $site ), 'body' => implode( "\n", $lines ), 'level' => self::FAIL === $verdict ? 'error' : ( self::WARN === $verdict ? 'warning' : 'info' ), ); } /** * Sends the run's alert when one is owed: always on a failure, otherwise only when the owner * asked to hear about passes. Telegram when set up, else the admin email. * * @param array $run A run record. * @return bool Whether an alert was delivered. */ public static function maybe_alert( array $run ): bool { if ( self::FAIL !== ( $run['verdict'] ?? '' ) && ! self::notify_pass() ) { return false; } try { $site = wp_specialchars_decode( (string) ( get_bloginfo( 'name' ) ?: home_url() ), ENT_QUOTES ); $url = admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=activity' ) . '#cs-panel-upgrade-verify'; $a = self::alert_text( $run, $site, $url ); $tg = class_exists( 'CSDT_Alert_Events' ) && CSDT_Alert_Events::send( CSDT_Alert_Events::GENERAL, $a['subject'] . "\n\n" . $a['body'], 'Upgrade Verification', $a['level'] ); if ( $tg ) { return true; } return is_callable( array( 'CSDT_Threat_Monitor', 'send_alert_email' ) ) && CSDT_Threat_Monitor::send_alert_email( (string) get_option( 'admin_email' ), $a['subject'], $a['body'] ); } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); return false; } } // ── Admin ────────────────────────────────────────────────────────── /** Pill colours for a verdict: background, text. */ private static function colours( string $status ): array { $map = array( self::PASS => array( '#dcfce7', '#166534' ), self::WARN => array( '#fef3c7', '#92400e' ), self::FAIL => array( '#fee2e2', '#991b1b' ), ); return $map[ $status ] ?? array( '#f1f5f9', '#334155' ); } /** A timestamp in the site's timezone. */ private static function when( int $ts ): string { return function_exists( 'wp_date' ) ? (string) wp_date( 'Y-m-d H:i T', $ts ) : gmdate( 'Y-m-d H:i', $ts ) . ' UTC'; } /** * One run's table of checks, escaped. * * @param array $run A run record. * @return string */ private static function run_table( array $run ): string { $h = ''; $diff = (array) ( $run['diff'] ?? array() ); if ( array() !== $diff ) { $items = array(); foreach ( $diff as $row ) { $items[] = esc_html( self::describe_change( (array) $row ) ); } $h .= '

' . esc_html__( 'What changed:', 'cloudscale-cyber-devtools' ) . ' ' . implode( '; ', $items ) . '

'; } $h .= ''; foreach ( (array) ( $run['checks'] ?? array() ) as $c ) { $col = self::colours( (string) ( $c['status'] ?? '' ) ); $h .= '' . '' . '' . ''; } return $h . '
' . esc_html( (string) ( $c['label'] ?? '' ) ) . '' . esc_html( strtoupper( (string) ( $c['status'] ?? '' ) ) ) . '' . esc_html( (string) ( $c['reason'] ?? '' ) ) . '' . esc_html( (int) ( $c['ms'] ?? 0 ) . ' ms' ) . '
'; } /** * The status block and run history, escaped. Rendered on page load and returned after every action. * * @return string */ public static function status_html(): string { $runs = self::runs(); $stored = self::stored(); $pending = self::pending(); if ( self::is_standby() ) { $pill = array( 'Standby copy: never runs', '#fef3c7', '#92400e' ); } elseif ( ! self::is_enabled() ) { $pill = array( 'Off', '#f1f5f9', '#334155' ); } elseif ( array() === $runs ) { $pill = array( 'On: no run yet', '#f1f5f9', '#334155' ); } else { $v = (string) ( $runs[0]['verdict'] ?? '' ); $words = array( self::PASS => 'Last run passed', self::WARN => 'Last run passed with warnings', self::FAIL => 'Last run FAILED', ); $col = self::colours( $v ); $pill = array( $words[ $v ] ?? $v, $col[0], $col[1] ); } $rows = array(); $rows[] = array( 'Last run', array() === $runs ? 'None yet. One runs two minutes after a change is detected, or click Verify now.' : self::when( (int) ( $runs[0]['ts'] ?? 0 ) ) . ' (' . (string) ( $runs[0]['trigger'] ?? '' ) . ')' ); $rows[] = array( 'Fingerprint', '' === (string) ( $stored['hash'] ?? '' ) ? 'Not taken yet: the first check takes it within 5 minutes.' : substr( (string) $stored['hash'], 0, 16 ) . ', taken ' . self::when( (int) ( $stored['at'] ?? 0 ) ) . ', ' . count( (array) ( $stored['components'] ?? array() ) ) . ' components' ); $rows[] = array( 'Last change seen', (int) ( $stored['changed_at'] ?? 0 ) > 0 && array() !== (array) ( $stored['diff'] ?? array() ) ? self::when( (int) $stored['changed_at'] ) : 'None since the fingerprint was first taken.' ); if ( array() !== $pending ) { $rows[] = array( 'Pending', 'A verification is booked for ' . self::when( (int) ( $pending['detected_at'] ?? 0 ) + self::SETTLE_DELAY ) . '.' ); } $rows[] = array( 'Alerts', self::notify_pass() ? 'On every run (failures and passes).' : 'On failures only.' ); $h = '' . esc_html( $pill[0] ) . ''; $h .= ''; foreach ( $rows as $r ) { $h .= ''; } $h .= '
' . esc_html( $r[0] ) . '' . esc_html( $r[1] ) . '
'; if ( array() !== $runs ) { $h .= '
' . self::run_table( $runs[0] ) . '
'; } if ( count( $runs ) > 0 ) { $h .= '
' . esc_html( sprintf( 'Run history (%d of the last %d kept)', count( $runs ), self::MAX_RUNS ) ) . ''; foreach ( $runs as $i => $run ) { $col = self::colours( (string) ( $run['verdict'] ?? '' ) ); $h .= '
' . '' . esc_html( strtoupper( (string) ( $run['verdict'] ?? '' ) ) ) . ' ' . esc_html( self::when( (int) ( $run['ts'] ?? 0 ) ) . ', ' . (string) ( $run['trigger'] ?? '' ) . ( ! empty( $run['alerted'] ) ? ', alert sent' : '' ) ) . '' . self::run_table( $run ) . '
'; } $h .= '
'; } return $h; } /** One AJAX answer: a message and the fresh status block. */ private static function reply( bool $ok, string $msg, array $extra = array() ): void { $data = array_merge( array( 'ok' => $ok, 'msg' => $msg, 'html' => self::status_html(), ), $extra ); if ( $ok ) { wp_send_json_success( $data ); } wp_send_json_error( $data ); } /** * Verify now: runs every check immediately. Allowed for the test session: it changes nothing * on the site, it only adds a run to the history (and alerts on a failure, as any run does). */ public static function ajax_verify(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } if ( self::is_standby() ) { self::reply( false, __( 'This site is a standby copy, which never verifies or alerts.', 'cloudscale-cyber-devtools' ) ); return; } try { $stored = self::stored(); $pending = self::pending(); $diff = array() !== $pending ? (array) ( $pending['diff'] ?? array() ) : (array) ( $stored['diff'] ?? array() ); $since = max( (int) ( $stored['changed_at'] ?? 0 ), self::now() - self::MANUAL_WINDOW ); $run = self::run( 'manual', $diff, $since ); } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); self::reply( false, __( 'The verification could not run. The error is in the PHP log.', 'cloudscale-cyber-devtools' ) ); return; } $words = array( self::PASS => __( 'Verified: every check passed.', 'cloudscale-cyber-devtools' ), self::WARN => __( 'Verified with warnings: see the checks below.', 'cloudscale-cyber-devtools' ), self::FAIL => __( 'Verification FAILED: see the checks below.', 'cloudscale-cyber-devtools' ), ); self::reply( true, $words[ $run['verdict'] ] ?? '', array( 'run' => $run ) ); } /** Saves the switch and the pass-alert choice. Registered with refuse_test_session. */ 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' ) ); $pass = '1' === sanitize_text_field( wp_unslash( $_POST['notify_pass'] ?? '0' ) ); update_option( self::OPTION_ENABLED, $on ? '1' : '0' ); update_option( self::OPTION_NOTIFY_PASS, $pass ? '1' : '0' ); if ( ! $on ) { // Switched back on later, the first check starts from a fresh fingerprint instead of // verifying everything that changed while it was off. delete_option( self::OPTION_FINGERPRINT ); delete_option( self::OPTION_PENDING ); } self::sync_schedule(); self::reply( true, $on ? __( 'Saved. Changes to core, PHP, the theme and plugins are checked every 5 minutes.', 'cloudscale-cyber-devtools' ) : __( 'Saved. Upgrade verification is off: nothing is checked or sent.', 'cloudscale-cyber-devtools' ) ); } /** The panel, on the Activity tab after the Out of Band Heartbeat. */ public static function render_panel(): void { ?>
✅ 'What it does', 'rec' => 'Read this first', 'html' => '

Every 5 minutes this site compares a fingerprint of what it runs: the WordPress version, the PHP version the web server uses, the active theme and every active plugin with their versions. When any of them changes, by an update in WordPress, a file copy or a rebuilt server image, it waits 2 minutes for caches to settle and then checks that the site still works.

It is a report only. It never rolls anything back, deactivates a plugin or changes a setting.

', ), array( 'name' => 'The checks', 'rec' => 'Info', 'html' => '

Front page: a complete page, with no critical error, and a warning when it is more than 3 times slower than usual (the middle of the last 5 passing runs). Login page: answers 200, at its hidden address when Hide Login is on. REST API: the index answers JSON. Admin AJAX: admin starts without a fatal error. PHP fatals: none logged since the change. Versions landed: every changed part reports the version it changed to, so a half-applied update fails. Core files: after a WordPress update, the files match WordPress\'s checksums (only while file integrity is on). Scheduled tasks: none of this plugin\'s tasks is more than 30 minutes late.

The requests go from this server to its own address, marked as the site\'s own, and never to the default login page while it is hidden.

', ), array( 'name' => 'Alerts', 'rec' => 'Failures only by default', 'html' => '

A failed run sends one alert, to Telegram when it is set up, otherwise to the site admin email, listing what changed and each failing check. Tick Also tell me when it passes to hear about every run. Nothing is sent to anyone else: the checks contact only this site (and WordPress.org\'s checksum list, which file integrity already uses, only when that is on).

', ), array( 'name' => 'Verify now and the switch', 'rec' => 'Safe', 'html' => '

Verify now runs every check immediately and adds the result to the history; it changes nothing on the site. The last 20 runs are kept. Untick Check after every change and save to stop it entirely. A standby copy of this site never runs it.

', ), ) ); ?>