array( tab, panel id ). * * A row that says OFF or NOT WORKING with nothing to click leaves the reader hunting * through eleven tabs (asked for 2026-10-28: "if you say Off you have to provide a link"). * tests/liveness-fix-links-test.php fails the build when a check has no entry here, or an * entry names a panel id that no longer renders. * * test_account_veto has no entry on purpose: it has no setting, it is code that must be * hooked, and its detail text says what to do. * * @return array */ public static function fix_links(): array { return array( 'blocklist_enforcement' => array( 'login', 'cs-panel-brute-force' ), 'two_factor' => array( 'login', 'cs-panel-2fa' ), 'hidden_login' => array( 'login', 'cs-panel-hide-login' ), 'autoblock' => array( 'login', 'cs-panel-auto-block' ), 'probe_blocker' => array( 'login', 'cs-panel-auto-block' ), 'file_integrity' => array( 'security', 'cs-panel-integrity' ), 'scheduled_tasks' => array( 'security', 'cs-panel-threat-monitor' ), 'vuln_recheck' => array( 'security', 'cs-panel-cve-alerts' ), // No switch of its own; when it is not working the cause is WP-Cron. 'persistence_watch' => array( 'debug', 'cs-panel-cron-status' ), 'host_alarms' => array( 'activity', 'cs-panel-host-alarms' ), 'request_limiter' => array( 'firewall', 'cs-panel-ratelimit' ), 'country_rules' => array( 'login', 'cs-panel-geoblock' ), 'alert_delivery' => array( 'mail', 'cs-panel-notify-channels' ), 'firewall' => array( 'firewall', 'cs-panel-firewall' ), 'ip_reputation' => array( 'firewall', 'cs-panel-ip-reputation' ), 'shields' => array( 'firewall', 'cs-panel-shield' ), 'ip_resolution' => array( 'firewall', 'cs-panel-ip-source' ), ); } /** * The admin URL that lands on a check's control, or '' when it has none. * * @param string $key Check key from checks(). * @return string */ public static function fix_url( string $key ): string { $links = self::fix_links(); if ( empty( $links[ $key ] ) ) { return ''; } return admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=' . $links[ $key ][0] ) . '#' . $links[ $key ][1]; } /** * Evaluates every control. * * Each entry: label, expected (should it be on), live (is it working, null when it * cannot be judged here), detail (what to do), request_only (needs a visitor). * * @return array> */ public static function checks(): array { $out = array(); // ── Always-on controls ─────────────────────────────────────────── // Registration checks. These are the passkey-gate shape: the code is present // and correct, and nothing ever calls it. $out['blocklist_enforcement'] = array( 'label' => __( 'IP blocklist enforcement', 'cloudscale-cyber-devtools' ), 'expected' => true, 'live' => (bool) has_action( 'plugins_loaded', array( 'CSDT_Login', 'enforce_ip_blocklist' ) ), 'detail' => __( 'The blocklist is not hooked into the request, so blocked addresses are not being refused at all. Nothing in the settings would show this.', 'cloudscale-cyber-devtools' ), ); // The one control this project treats as absolute, and the one that actually // failed: a test account switched site-wide 2FA off on 2026-08-07 because this // filter had not been registered on that request. $out['test_account_veto'] = array( 'label' => __( 'Test accounts cannot weaken login security', 'cloudscale-cyber-devtools' ), 'expected' => true, 'live' => (bool) has_filter( 'pre_update_option', array( 'CSDT_Test_Accounts', 'veto_login_security_write' ) ), 'detail' => __( 'An automated test account could switch off two-factor authentication or the hidden login page. This has happened before. Treat as urgent.', 'cloudscale-cyber-devtools' ), ); $method = (string) CSDT_Options::get( 'csdt_devtools_2fa_method' ); $out['two_factor'] = array( 'label' => __( 'Two-factor authentication', 'cloudscale-cyber-devtools' ), 'expected' => true, 'live' => 'off' !== $method, 'detail' => __( 'Site-wide 2FA is set to off. If you did not do that deliberately, treat it as a compromise of the admin account or of a test session.', 'cloudscale-cyber-devtools' ), ); // ── Controls that are on only if configured ────────────────────── // expected=false means "off on purpose", which is reported and never alerted. $hide_on = CSDT_Options::get( 'csdt_devtools_login_hide_enabled' ) === '1'; $out['hidden_login'] = array( 'label' => __( 'Hidden login page', 'cloudscale-cyber-devtools' ), 'expected' => $hide_on, 'live' => $hide_on && '' !== trim( (string) CSDT_Options::get( 'csdt_devtools_login_slug' ) ), 'detail' => __( 'Hiding the login page is switched on but no slug is set, so the default login URL is still the live one.', 'cloudscale-cyber-devtools' ), ); $autoblock_on = CSDT_Options::get( CSDT_Auto_Block::OPTION_ENABLED ) === '1'; $counters_ok = class_exists( 'CSDT_Storage' ) ? CSDT_Storage::is_available() : false; $counters_bad = (int) get_option( CSDT_Auto_Block::OPTION_COUNTERS_BROKEN, 0 ) > 0; // Labelled by WHAT IT COUNTS, not as "automatic IP blocking" in general. // // That label was reported as a bug, and it was one. It read "OFF" while 51 addresses // sat in the blocklist and a new one arrived every few minutes, because those blocks // come from the 7-day wp-login probe rule below, which writes through add_entry() // directly and never consults this switch. Both statements were true and together they // were misleading, which is worse than either being wrong. $out['autoblock'] = array( 'label' => __( 'Threshold blocking (failed logins, firewall, 404s)', 'cloudscale-cyber-devtools' ), 'expected' => $autoblock_on, 'live' => $autoblock_on && $counters_ok && ! $counters_bad, 'detail' => __( 'Threshold blocking is switched on but its event counter is unreachable, so no threshold can ever be crossed and nothing will be blocked.', 'cloudscale-cyber-devtools' ), ); // The second blocker, reported separately because it is governed separately. It has its // own threshold, its own 7-day window and its own tally, and it keeps working when the // switch above is off. $probe_threshold = class_exists( 'CSDT_Auto_Block' ) && is_callable( array( 'CSDT_Auto_Block', 'probe_7d_threshold' ) ) ? (int) CSDT_Auto_Block::probe_7d_threshold() : 0; $probe_on = $probe_threshold > 0; // What can silently kill it: the per-address tally it reads is written when a request // reaches the login page. If that option has never appeared on a public site, nothing is // being counted and the rule can never fire however hostile the traffic. $probe_stats = get_option( 'csdt_wplogin_blocked_stats', null ); $probe_recording = is_array( $probe_stats ) && ! empty( $probe_stats ); $out['probe_blocker'] = array( 'label' => __( 'Probe blocking (wp-login attempts over 7 days)', 'cloudscale-cyber-devtools' ), 'expected' => $probe_on, 'live' => $probe_on && $probe_recording, 'detail' => __( 'Probe blocking has a threshold set but nothing is being counted, so it can never fire. The tally is written when a request reaches the login page; if it is empty on a public site, that recording has stopped.', 'cloudscale-cyber-devtools' ), ); $integrity_on = class_exists( 'CSDT_Integrity' ) && CSDT_Integrity::is_enabled(); $ir = class_exists( 'CSDT_Integrity' ) ? CSDT_Integrity::last_result() : array(); $fresh = ! empty( $ir['ts'] ) && ( time() - (int) $ir['ts'] ) < self::INTEGRITY_STALE; $out['file_integrity'] = array( 'label' => __( 'File integrity verification', 'cloudscale-cyber-devtools' ), 'expected' => $integrity_on, 'live' => $integrity_on && ! empty( $ir['verified'] ) && ! empty( $ir['done'] ) && $fresh, 'detail' => __( 'File integrity is switched on but has not completed a verified pass recently. Either the checksums cannot be fetched, or the scheduled task is not running.', 'cloudscale-cyber-devtools' ), ); // If this is stale, EVERY scheduled control above is stale with it, which makes // it the most valuable single signal here. $tm_on = CSDT_Options::get( 'csdt_threat_monitor_enabled' ) === '1'; $last_run = (int) get_option( 'csdt_threat_last_run', 0 ); $out['scheduled_tasks'] = array( 'label' => __( 'Scheduled security tasks are running', 'cloudscale-cyber-devtools' ), 'expected' => $tm_on, 'live' => $tm_on && $last_run > 0 && ( time() - $last_run ) < self::CRON_STALE, 'detail' => __( 'The security monitor has not run recently, so file integrity, probe detection and new-admin alerts are all inert. Usually WP-Cron is blocked or no system cron is driving it.', 'cloudscale-cyber-devtools' ), ); // Judged on its last summary, because a cron that stopped firing and a feed // that stopped answering both leave the same silence, and both mean a CVE // published this week is not known here. $vr_present = class_exists( 'CSDT_Vuln_Recheck' ); $vr_status = $vr_present ? CSDT_Vuln_Recheck::status() : array( 'ran' => false, 'fresh' => false, 'ok' => false, 'summary' => array() ); $vr_sched = $vr_present && (bool) wp_next_scheduled( CSDT_Vuln_Recheck::CRON_HOOK ); // Expected only where the owner opted in. The re-check is off by default (see // CSDT_Vuln_Recheck::OPTION_ENABLED) and unschedules itself when off, so "not // scheduled" on a site that never switched it on is the correct state. Reporting // it as "enabled but not working" paged the owner about a switch that was off. $vr_wanted = $vr_present && CSDT_Vuln_Recheck::is_enabled(); $out['vuln_recheck'] = array( 'label' => __( 'Daily vulnerability re-check', 'cloudscale-cyber-devtools' ), 'expected' => $vr_wanted, 'live' => $vr_wanted && $vr_sched && ( ! $vr_status['ran'] || ( $vr_status['fresh'] && $vr_status['ok'] ) ), 'detail' => ! $vr_sched ? __( 'The daily vulnerability re-check is not scheduled, so a CVE published against an installed component is only found when somebody runs a scan by hand.', 'cloudscale-cyber-devtools' ) : ( $vr_status['ran'] && ! $vr_status['ok'] ? sprintf( /* translators: %s: the reason the last run recorded. */ __( 'The last daily vulnerability re-check did not complete: %s. Components were not checked against the feed on that run.', 'cloudscale-cyber-devtools' ), (string) ( $vr_status['summary']['reason'] ?? 'unknown' ) ) : __( 'The daily vulnerability re-check has not run for more than two days, so a CVE published this week against an installed component is not known here. Usually WP-Cron is blocked or no system cron is driving it.', 'cloudscale-cyber-devtools' ) ), ); // The persistence watch has a baseline and a tick. No baseline means nothing // has ever been compared; a stale tick means a new mu-plugin sits unreported. $pw_present = class_exists( 'CSDT_Persistence_Watch' ); $pw_status = $pw_present ? CSDT_Persistence_Watch::status() : array( 'enabled' => false, 'ran' => false, 'fresh' => false, 'baselined' => false, 'summary' => array() ); $pw_sched = $pw_present && (bool) wp_next_scheduled( CSDT_Persistence_Watch::CRON_HOOK ); $out['persistence_watch'] = array( 'label' => __( 'Persistence watch (wp-config, .htaccess, mu-plugins, drop-ins)', 'cloudscale-cyber-devtools' ), 'expected' => $pw_present && $pw_status['enabled'], 'live' => $pw_present && $pw_status['enabled'] && $pw_sched && $pw_status['baselined'] && $pw_status['fresh'], 'detail' => ! $pw_sched ? __( 'The persistence watch is switched on but not scheduled, so a new mu-plugin or a rewritten wp-config.php is reported by nothing.', 'cloudscale-cyber-devtools' ) : ( ! $pw_status['baselined'] ? __( 'The persistence watch has no baseline yet, so it has never compared anything. It records one on its first run; if this persists, the scheduled task is not firing.', 'cloudscale-cyber-devtools' ) : __( 'The persistence watch has not run for more than fifteen minutes, so a change to wp-config.php, .htaccess, a mu-plugin or a drop-in would go unreported. Usually WP-Cron is blocked or no system cron is driving it.', 'cloudscale-cyber-devtools' ) ), ); // Server health alarms: a switch that is on with no schedule, or with readings // that stopped arriving, is an alarm that cannot fire. $ha_present = class_exists( 'CSDT_Host_Alarms' ); $ha_status = $ha_present ? CSDT_Host_Alarms::status() : array( 'enabled' => false, 'scheduled' => false, 'ran' => false, 'fresh' => false ); $out['host_alarms'] = array( 'label' => __( 'Server health alarms', 'cloudscale-cyber-devtools' ), 'expected' => $ha_present && $ha_status['enabled'], 'live' => $ha_present && $ha_status['enabled'] && $ha_status['scheduled'] && ( ! $ha_status['ran'] || $ha_status['fresh'] ), 'detail' => ! $ha_status['scheduled'] ? __( 'Server health alarms are switched on but not scheduled, so a server running out of memory or disk is reported by nothing.', 'cloudscale-cyber-devtools' ) : __( 'Server health alarms have not taken a reading for more than fifteen minutes, so a server in trouble would go unreported. Usually WP-Cron is blocked or no system cron is driving it.', 'cloudscale-cyber-devtools' ), ); // The limiter counts in the persistent object cache and nowhere else, so with // no such cache it is inert in every mode. That is the one way it can read as // running while counting nothing, and it is judged here for that reason. $rl_present = class_exists( 'CSDT_Request_Limiter' ); $rl_status = $rl_present ? CSDT_Request_Limiter::status() : array( 'mode' => 'off', 'enabled' => false, 'store' => false, 'inert_at' => 0 ); $out['request_limiter'] = array( 'label' => __( 'Request rate limits (REST, admin-ajax, XML-RPC)', 'cloudscale-cyber-devtools' ), 'expected' => $rl_present && $rl_status['enabled'], 'live' => $rl_present && $rl_status['enabled'] && $rl_status['store'] && (bool) has_action( 'plugins_loaded', array( 'CSDT_Request_Limiter', 'enforce' ) ), 'detail' => ! $rl_status['store'] ? __( 'Request rate limiting is switched on but there is no persistent object cache to count in, so nothing is limited in any mode. Install Redis or Memcached under Performance > Object Cache.', 'cloudscale-cyber-devtools' ) : __( 'Request rate limiting is switched on but is not hooked into the request, so nothing is being counted. Nothing in the settings would show this.', 'cloudscale-cyber-devtools' ), ); $geo_on = CSDT_Options::get( 'csdt_geoblock_enabled' ) === '1'; $out['country_rules'] = array( 'label' => __( 'Country rules', 'cloudscale-cyber-devtools' ), 'expected' => $geo_on, 'live' => $geo_on && ! empty( CSDT_Geo_Block::countries() ), 'detail' => __( 'Country rules are switched on but no countries are listed, so no visitor is ever refused.', 'cloudscale-cyber-devtools' ), ); // ── Alerting itself ───────────────────────────────────────────── // If this is inert, every other finding here goes unheard, which is why it is // checked rather than assumed. // Intent to alert is derived from the things that RAISE alerts, never from the // credentials themselves, that would be circular: clearing the token would make // "expected" false too, and a wiped channel would read as OFF rather than broken. // // The first version of this check keyed on six csdt_telegram_* toggles. Verified // against the live site: all six are unset while Telegram is configured and // alerts are demonstrably arriving, because those toggles are opt-ins for extra // alert types rather than a master switch. So the single most important row here // reported OFF on a site that actively depends on it. $raises_alerts = CSDT_Options::get( 'csdt_threat_monitor_enabled' ) === '1' || CSDT_Options::get( CSDT_Auto_Block::OPTION_ENABLED ) === '1' || ( class_exists( 'CSDT_Integrity' ) && CSDT_Integrity::is_enabled() ); foreach ( array( 'csdt_telegram_login_valid_user', 'csdt_telegram_login_invalid_user', 'csdt_telegram_rest_auth_fail', 'csdt_telegram_scan_result', 'csdt_telegram_threat_monitor', 'csdt_telegram_php_errors', ) as $opt ) { // Default '1', matching every production read of these options. Reading them // with a default of '0' meant an UNSET option counted as "not wanted" here // while the alerting code treats unset as wanted, the real root of // alert_delivery reporting OFF on a site that depends on it. A liveness check // must read a setting exactly as the code it describes reads it, or it // describes something else. if ( get_option( $opt, '1' ) === '1' ) { $raises_alerts = true; break; } } $tg_ready = class_exists( 'CSDT_Telegram' ) && CSDT_Telegram::is_configured(); // Outcome of the last real attempt, recorded by CSDT_Threat_Monitor. Configured // is not the same as working: a revoked bot token, a bot removed from the chat, // or blocked outbound HTTPS all leave the settings looking perfect. Deliberately // NOT a check for "no alerts recently", on a healthy site silence is correct, so // absence of alerts proves nothing. A FAILED attempt proves something. $last = get_option( 'csdt_alert_delivery_last', array() ); $last_ok = ! is_array( $last ) || ! array_key_exists( 'ok', $last ) || (bool) $last['ok']; $out['alert_delivery'] = array( 'label' => __( 'Alert delivery (Telegram)', 'cloudscale-cyber-devtools' ), 'expected' => $raises_alerts, 'live' => $raises_alerts && $tg_ready && $last_ok, 'detail' => $last_ok ? __( 'This site raises security alerts but Telegram has no bot token or chat ID, so those alerts have nowhere to go. Findings would be recorded and never seen. Use Send Test to confirm delivery once configured.', 'cloudscale-cyber-devtools' ) : __( 'The last alert this site tried to send did not reach Telegram, so the settings look correct while delivery is failing. Usually a revoked bot token, the bot removed from the chat, or outbound HTTPS blocked. Use Send Test after fixing it.', 'cloudscale-cyber-devtools' ), ); // The firewall does not get to be the fifth control that silently does nothing. // Registered in the same step as the engine skeleton, before any rule exists, so // the transition from "off, no rules" to "on with rules" is visible either way. // Literal 'off' in the fallback, NOT CSDT_Firewall::MODE_OFF, referencing a // constant on the class whose absence is being guarded against is a fatal error // on exactly the path the guard exists to cover. Same shape as guarding with // method_exists() on a private method: the check passes and the next line dies. $fw_present = class_exists( 'CSDT_Firewall' ); $fw_mode = $fw_present ? CSDT_Firewall::mode() : 'off'; $fw_on = $fw_present && 'off' !== $fw_mode; $fw_rules = $fw_on ? count( CSDT_Firewall::rules() ) : 0; // Third condition, added with the rules themselves: an engine that matches but has // nowhere to write the match is inert in the way that matters. Log mode's entire // output IS the record, so "hooked, with rules, and no storage" must read red // rather than green, that combination would be the fifth control here found // reporting success while doing nothing. $fw_store = ! $fw_on || ( class_exists( 'CSDT_Storage' ) && CSDT_Storage::is_available() ); $out['firewall'] = array( 'label' => __( 'Request firewall', 'cloudscale-cyber-devtools' ), 'expected' => $fw_on, 'live' => $fw_on && (bool) has_action( 'plugins_loaded', array( 'CSDT_Firewall', 'enforce' ) ) && $fw_rules > 0 && $fw_store, 'detail' => $fw_on && 0 === $fw_rules ? __( 'The firewall is switched on but has no rules loaded, so it inspects every request and can never match anything. Switch it off, or load rules.', 'cloudscale-cyber-devtools' ) : ( ! $fw_store ? __( 'The firewall is inspecting requests but its event table is missing, so nothing it finds is recorded. In Log-only mode that means the whole feature produces no output: it looks active and tells you nothing. Re-save any Login Security setting to reinstall the table.', 'cloudscale-cyber-devtools' ) : __( 'The firewall is switched on but is not hooked into the request, so nothing is being inspected. Nothing in the settings would show this.', 'cloudscale-cyber-devtools' ) ), ); // Same shape as the firewall check above, and the same reasoning: an enabled // feature checking against an EMPTY list (never fetched, or every refresh since // has failed) inspects every request and can never match anything, which reads // identically to "nothing hostile is out there" rather than "this has not // actually loaded a list yet". $ir_present = class_exists( 'CSDT_Ip_Reputation' ); $ir_mode = $ir_present ? CSDT_Ip_Reputation::mode() : 'off'; $ir_on = $ir_present && 'off' !== $ir_mode; $ir_count = $ir_on ? count( CSDT_Ip_Reputation::cidrs() ) : 0; $ir_store = ! $ir_on || ( class_exists( 'CSDT_Storage' ) && CSDT_Storage::is_available() ); $out['ip_reputation'] = array( 'label' => __( 'IP reputation blocking', 'cloudscale-cyber-devtools' ), 'expected' => $ir_on, 'live' => $ir_on && (bool) has_action( 'plugins_loaded', array( 'CSDT_Ip_Reputation', 'enforce' ) ) && $ir_count > 0 && $ir_store, 'detail' => $ir_on && 0 === $ir_count ? __( 'IP reputation checking is switched on but no list has ever been fetched, so it cannot match anything. Use "Refresh now" on the Firewall tab, or wait for the daily background job.', 'cloudscale-cyber-devtools' ) : ( ! $ir_store ? __( 'IP reputation checking is running but its event table is missing, so nothing it finds is recorded. In Log-only mode that means the whole feature produces no output.', 'cloudscale-cyber-devtools' ) : __( 'IP reputation checking is switched on but is not hooked into the request, so nothing is being checked. Nothing in the settings would show this.', 'cloudscale-cyber-devtools' ) ), ); // Same reasoning again, with the failure mode a shield has that the two controls // above do not: a shield is a list of endpoint names captured at the moment it was // enabled. If the component later renames or stops registering those endpoints, // the shield stays switched on, keeps reading as ACTIVE, and refuses nothing. That // is indistinguishable on screen from a shield quietly doing its job, so it is // judged here rather than left to be discovered. // // Literal class_exists guard and no reference to a CSDT_Shield constant in the // fallback, for the same reason the firewall check spells 'off' out. $sh_present = class_exists( 'CSDT_Shield' ); $sh_active = $sh_present ? CSDT_Shield::active() : array(); // Suspended is a DELIBERATE choice, not a fault, so it is not expected to be // live and must not read red. The panel says loudly that shields are suspended; // this check reporting a problem as well would train people to ignore it. $sh_susp = $sh_present && CSDT_Shield::is_suspended(); $sh_on = ! empty( $sh_active ) && ! $sh_susp; $sh_empty = array(); foreach ( $sh_active as $sh_slug => $sh_shield ) { $sh_ajax = is_array( $sh_shield ) && isset( $sh_shield['ajax'] ) ? (array) $sh_shield['ajax'] : array(); $sh_rest = is_array( $sh_shield ) && isset( $sh_shield['rest'] ) ? (array) $sh_shield['rest'] : array(); if ( empty( $sh_ajax ) && empty( $sh_rest ) ) { $sh_empty[] = (string) $sh_slug; } } $sh_store = ! $sh_on || ( class_exists( 'CSDT_Storage' ) && CSDT_Storage::is_available() ); $out['shields'] = array( 'label' => __( 'Vulnerable component shields', 'cloudscale-cyber-devtools' ), 'expected' => $sh_on, 'live' => $sh_on && (bool) has_action( 'plugins_loaded', array( 'CSDT_Shield', 'enforce' ) ) && empty( $sh_empty ) && $sh_store, 'detail' => ! empty( $sh_empty ) ? sprintf( /* translators: %s: comma-separated list of component slugs. */ __( 'A shield is active for %s but it lists no endpoints, so it refuses nothing while still reading as active. Use "Re-scan surfaces" on the Firewall tab, then lift and re-enable it.', 'cloudscale-cyber-devtools' ), implode( ', ', $sh_empty ) ) : ( ! $sh_store ? __( 'Shields are active but the event table is missing, so refusals are not recorded. The shields themselves still refuse; only the record of them is lost.', 'cloudscale-cyber-devtools' ) : __( 'Shields are active but are not hooked into the request, so nothing is being refused. Nothing in the settings would show this.', 'cloudscale-cyber-devtools' ) ), ); // ── Request-only ──────────────────────────────────────────────── // Judged in the admin panel, where a visitor exists. NEVER alerted from cron: // get_client_ip() is empty there, so asking would report "broken" every run and // this whole check would be muted within a day. $out['ip_resolution'] = array( 'label' => __( 'Visitor IP resolution', 'cloudscale-cyber-devtools' ), 'expected' => true, 'live' => self::is_request_context() ? ! CSDT_DevTools::ip_detection_is_broken() : null, 'detail' => __( 'Visitor addresses are not resolving to public IPs, so every event is attributed to your proxy. Auto-blocking, rate limits and the attack map are all inert while this is true.', 'cloudscale-cyber-devtools' ), 'request_only' => true, ); return $out; } /** Whether there is a real visitor whose request context can be inspected. */ public static function is_request_context(): bool { if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { return false; } return ! empty( $_SERVER['REMOTE_ADDR'] ); } /** * Controls that are meant to be on and demonstrably are not. * * Skips request-only checks unless a request context exists, and skips anything * deliberately switched off. Both exclusions are what keep this alertable rather * than noisy. * * @param array|null $checks Pre-computed checks, or null to evaluate. * @return array> */ public static function inert( ?array $checks = null ): array { $checks = null === $checks ? self::checks() : $checks; $bad = array(); foreach ( $checks as $key => $c ) { if ( empty( $c['expected'] ) ) { continue; // Off on purpose is not a fault. } if ( null === $c['live'] ) { continue; // Could not be judged here; not evidence of anything. } if ( ! $c['live'] ) { $bad[ $key ] = $c; } } return $bad; } /** * Clears a recorded delivery failure once Telegram is reachable again. * * The delivery record only changes when an alert is sent, and a healthy site sends * few. So one failed send, for example an alert raised while the server was still * booting and had no outbound HTTPS yet, stayed on record for days and this check * reported a working channel as broken. A failure that is on record is therefore * re-tested here with a call that sends no message. A channel that is really broken * fails the probe too and stays reported. * * Runs from run() only, never from checks(), so no admin page load makes this call. */ private static function recheck_delivery(): void { $last = get_option( 'csdt_alert_delivery_last', array() ); if ( ! is_array( $last ) || ! array_key_exists( 'ok', $last ) || $last['ok'] ) { return; } if ( class_exists( 'CSDT_Site_Role' ) && CSDT_Site_Role::is_standby() ) { return; } if ( true !== self::telegram_reachable() ) { return; } update_option( 'csdt_alert_delivery_last', array( 'ts' => time(), 'ok' => true, 'probed' => true, ), false ); } /** * Asks Telegram whether the configured bot can still reach the configured chat, * without sending a message. * * One getChat call answers the three things a failed send can mean: the token is * still valid, the bot is still in the chat, and outbound HTTPS works. * * Lives here, not in CSDT_Telegram, because that class is a copy shared with the * other CloudScale plugins and only this check needs the question asked. * * @return bool|null True reachable, false not, null when it cannot be asked * (muted or not configured). */ private static function telegram_reachable(): ?bool { if ( ! class_exists( 'CSDT_Telegram' ) || ! CSDT_Telegram::is_configured() ) { return null; } if ( is_callable( array( 'CSDT_Telegram', 'is_muted' ) ) && CSDT_Telegram::is_muted() ) { return null; } $creds = CSDT_Telegram::credentials(); $token = $creds['token']; $chat_id = $creds['chat_id']; if ( '' === $token || '' === $chat_id ) { return null; } $response = wp_remote_post( 'https://api.telegram.org/bot' . rawurlencode( $token ) . '/getChat', array( 'user-agent' => CSDT_Telegram::USER_AGENT, 'timeout' => 10, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'chat_id' => $chat_id ) ), ) ); $r = CSDT_Http::json( $response ); return 200 === $r['code'] && ! empty( $r['data']['ok'] ); } /** * One evaluation pass. Safe to call from cron. * * @return array The stored result. */ public static function run(): array { self::recheck_delivery(); $checks = self::checks(); $inert = self::inert( $checks ); $result = array( 'ts' => time(), 'inert' => array_keys( $inert ), 'state' => array_map( static fn( $c ) => array( 'label' => (string) $c['label'], 'expected' => (bool) $c['expected'], 'live' => $c['live'], ), $checks ), ); update_option( self::OPTION_RESULT, $result, false ); // A standby deliberately disarms its security crons (see the disarm list in the // bootstrap), so on a standby "switched on but not scheduled" is the correct and // expected state, not a fault. The result is still stored for the security panel, // but no alert is sent: a copy that emails "your protection is off" names the // PRIMARY'S site through get_bloginfo() and pages the owner about a machine that // is doing exactly what a standby is meant to do. This backs up the disarm of // csdt_threat_monitor: even if some path re-arms the hook, no alert leaks. if ( class_exists( 'CSDT_Site_Role' ) && CSDT_Site_Role::is_standby() ) { return $result; } if ( ! empty( $inert ) ) { self::alert( $inert ); } return $result; } /** * Alerts once per distinct set of inert controls. * * Keyed on the set rather than on time, so a control that stays broken does not * page hourly, but a NEW control going inert always does, including when it joins * one that was already broken. */ private static function alert( array $inert ): void { $signature = md5( implode( '|', array_keys( $inert ) ) ); $seen = get_option( self::OPTION_ALERTED, array() ); $seen = is_array( $seen ) ? $seen : array(); if ( isset( $seen[ $signature ] ) ) { return; } $lines = array(); foreach ( $inert as $c ) { $lines[] = '• ' . $c['label'] . "\n " . $c['detail']; } $site = wp_specialchars_decode( (string) ( get_bloginfo( 'name' ) ?: home_url() ), ENT_QUOTES ); $url = admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=security' ); $count = count( $inert ); $subject = sprintf( /* translators: 1: number of inert controls, 2: site name */ _n( 'CSDT: ⚠️ %1$d security control is switched on but not working, %2$s', 'CSDT: ⚠️ %1$d security controls are switched on but not working, %2$s', $count, 'cloudscale-cyber-devtools' ), $count, $site ); $body = sprintf( "These controls are enabled in the settings but are demonstrably not doing anything on %s.\n\nThis is not a report of an attack. It is a report of protection that is absent while appearing to be present, which is the more dangerous of the two because nothing else will tell you.\n\n%s\n\nSecurity dashboard: %s", home_url(), implode( "\n\n", $lines ), $url ); // is_callable(), NOT method_exists(): method_exists() answers true for a PRIVATE // method, so the guard would pass and the call would fatal. is_callable() // respects visibility from this scope, which is the question actually being asked. if ( is_callable( array( 'CSDT_Threat_Monitor', 'send_threat_alert' ) ) ) { $delivered = false !== CSDT_Threat_Monitor::send_threat_alert( $subject, $body, 'urgent', 'rotating_light,warning', $url, CSDT_Alert_Events::SECURITY ); } else { // Alert delivery is one of the things this class checks, so it must not depend // on a single channel to report that a channel is broken. $delivered = is_callable( array( 'CSDT_Threat_Monitor', 'send_alert_email' ) ) ? CSDT_Threat_Monitor::send_alert_email( (string) get_option( 'admin_email' ), $subject, $body ) : (bool) wp_mail( get_option( 'admin_email' ), $subject, $body ); } // Remembered only once it went out. This used to be written before the send, so a // first alert held by the Telegram cap or lost to a failed POST marked this set of // inert controls as reported, and it was never reported again. if ( $delivered ) { $seen[ $signature ] = time(); update_option( self::OPTION_ALERTED, array_slice( $seen, -30, 30, true ), false ); } } // ── Admin UI ───────────────────────────────────────────────────────── /** * Renders the control status panel. * * Three states, deliberately distinct, because collapsing them is what makes a * status panel useless: WORKING, OFF (a choice, not a fault) and NOT WORKING (on * and doing nothing). A request-only check with no verdict says so rather than * guessing. */ public static function render_panel(): void { $checks = self::checks(); $inert = self::inert( $checks ); $stored = self::last_result(); ?>
🩺 'Why this panel exists', 'rec' => 'Read this', 'html' => 'Every other alert in this plugin is triggered by an event: a failed login, a changed file, a PHP error. That can only ever tell you what happened. It cannot tell you what stopped happening.

In August 2026 four separate protections were found doing nothing while reporting nothing: a build check that had never run once, the guard that stops test accounts weakening 2FA, the buttons that verify alerting works, and four audit checks that reported no administrator had 2FA on a site where it is enforced. Every one was found by a person noticing, or by a check written afterwards. None by the alerting.

This panel asks a different question of each protection: is it meant to be on, and is it demonstrably working?', ), array( 'name' => 'Off is not broken', 'rec' => 'By design', 'html' => 'A protection you have deliberately switched off shows as OFF and never raises an alert. Only something that is switched on and not working is a fault.

That distinction is the whole difference between a panel worth reading and one that gets ignored. If this listed every unused feature as a problem, you would stop looking at it within a week, and then it would be one more thing reporting nothing.', ), array( 'name' => 'Why some rows say "cannot check here"', 'rec' => 'Honest limits', 'html' => 'A few signals only exist while a real visitor is being served. Visitor IP resolution is one: the check runs on a schedule, and a scheduled task has no visitor, so asking would answer "broken" every single time.

Rather than guess, those rows are only judged when you load this page, and are never alerted from the scheduled run. A check that cried wolf on every run would be muted within a day.', ), array( 'name' => 'When it alerts', 'rec' => 'Info', 'html' => 'Once per distinct set of failures, not once per run, so a protection that stays broken does not message you every five minutes. A newly broken protection always alerts, including when it joins one that was already broken.

Alert delivery is itself one of the things checked, so if Telegram is unconfigured this falls back to email rather than reporting a broken channel through that channel.', ), ) ); ?>
✓

$c ) : ?>
' . esc_html__( 'OFF', 'cloudscale-cyber-devtools' ) . ''; } elseif ( null === $c['live'] ) { echo '' . esc_html__( 'CANNOT CHECK HERE', 'cloudscale-cyber-devtools' ) . ''; } elseif ( $c['live'] ) { echo '' . esc_html__( 'WORKING', 'cloudscale-cyber-devtools' ) . ''; } else { echo '' . esc_html__( 'NOT WORKING', 'cloudscale-cyber-devtools' ) . ''; } // OFF and NOT WORKING both say where to act; WORKING needs nothing. if ( '' !== $fix_url && ( empty( $c['expected'] ) || false === $c['live'] ) ) { $fix_label = __( 'Fix', 'cloudscale-cyber-devtools' ); if ( empty( $c['expected'] ) ) { $fix_label = __( 'Turn on', 'cloudscale-cyber-devtools' ); } echo ' ' . esc_html( $fix_label ) . ' →'; } ?>