tags served from WordPress.org's static host when // it (sometimes wrongly) decides a browser lacks native emoji support; if that host is // ever unreachable the images break. Native rendering is all we need, so skip the // polyfill outright. add_action( 'init', function () { // The site owner's switch (Settings, "Skip WordPress's emoji image script"), on by default. if ( '1' !== get_option( 'csdt_emoji_polyfill_off', '1' ) ) { return; } remove_action( 'wp_head', 'print_emoji_detection_script', 7 ); remove_action( 'wp_print_styles', 'print_emoji_styles' ); remove_action( 'embed_head', 'print_emoji_detection_script' ); remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' ); remove_filter( 'the_content_feed', 'wp_staticize_emoji' ); remove_filter( 'comment_text_rss', 'wp_staticize_emoji' ); add_filter( 'tiny_mce_plugins', function ( $plugins ) { return is_array( $plugins ) ? array_diff( $plugins, array( 'wpemoji' ) ) : $plugins; } ); // Matched on the host EXACTLY: not on a substring, and not on one exact URL. // // There are two ways to get this wrong and this filter has now had both. array_diff() // against one full URL literal stopped removing anything the moment core used the // other form it has shipped, the scheme-relative one, a registered filter quietly // doing nothing. Replacing it with strpos() then over-matched in the other direction: // WordPress.org's plugin-screenshot host CONTAINS the emoji host as a suffix, so a hint // naming it would have been stripped as well. // // Entries arrive in more than one shape: a full URL, a scheme-relative one, or for // preconnect an attribute array keyed 'href'. Anything unrecognised is KEPT, the job // here is to remove one known hint, so in doubt it must not remove. add_filter( 'wp_resource_hints', function ( $urls ) { if ( ! is_array( $urls ) ) { return $urls; } // The emoji hint is recognised by its PATH, which is core's and stable, rather than by // naming the host it points at. Core adds it as the full emoji_svg_url value, so the // path is present when this filter runs. An entry with no path (a bare host some other // plugin added) is never matched, which is the safe direction: in doubt, keep. return array_values( array_filter( $urls, static function ( $u ) { if ( is_array( $u ) ) { $u = isset( $u['href'] ) ? $u['href'] : ''; } if ( ! is_string( $u ) || '' === $u ) { return true; } return false === strpos( (string) wp_parse_url( $u, PHP_URL_PATH ), '/images/core/emoji/' ); } ) ); } ); }, 1 ); // wp-admin/includes/admin-filters.php re-registers the admin_print_scripts/ // admin_print_styles emoji hooks, and that file loads after `init` has already // fired, so the removal above never catches them on admin pages. Remove again // on admin_init, which runs after admin-filters.php has loaded. add_action( 'admin_init', function () { if ( '1' !== get_option( 'csdt_emoji_polyfill_off', '1' ) ) { return; } remove_action( 'admin_print_scripts', 'print_emoji_detection_script' ); remove_action( 'admin_print_styles', 'print_emoji_styles' ); }, 1 ); // The plugin's own main file. CSDT_Paths derives every other location from it. define( 'CSDT_PLUGIN_FILE', __FILE__ ); // A deactivated plugin must leave no scheduled task behind: WP-Cron would keep firing hooks // nothing handles. Activation registers them again through the normal boot path. register_deactivation_hook( __FILE__, 'csdt_devtools_clear_scheduled_tasks' ); /** * Marks a FRESH install as needing no defaults migration. * * The custom 404 page and the performance monitor are off on a new install. Sites that * predate that change keep them on, done by a one-time migration that writes the old * default wherever the owner never chose a value. WordPress does not fire this hook when a * plugin is UPDATED, only when it is activated, so stamping the flag here is what separates * "new site, leave it off" from "existing site, keep what it had". */ register_activation_hook( __FILE__, 'csdt_devtools_stamp_fresh_install' ); function csdt_devtools_stamp_fresh_install(): void { // Only when nothing of ours exists yet: re-activating an existing site must not be // mistaken for a new one. if ( false === get_option( 'csdt_devtools_login_slug', false ) && false === get_option( 'csdt_option_prefix_migrated', false ) ) { update_option( 'csdt_presentation_defaults_migrated', '1' ); update_option( 'csdt_integrity_default_migrated', '1' ); } } function csdt_devtools_clear_scheduled_tasks(): void { foreach ( (array) _get_cron_array() as $csdt_hooks ) { foreach ( array_keys( (array) $csdt_hooks ) as $csdt_hook ) { if ( 0 === strpos( (string) $csdt_hook, 'csdt_' ) ) { wp_clear_scheduled_hook( (string) $csdt_hook ); } } } } // Pricing, update these two constants to reprice the Managed AI subscription everywhere. define( 'CSDT_PRICE_USD', '10.00' ); // exact charge amount sent to PayPal define( 'CSDT_PRICE_TAG', '$10' ); // display label used in UI strings require_once plugin_dir_path( __FILE__ ) . 'includes/class-paths.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-request.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-ajax.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-options.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-http.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-alert-window.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/trait-csdt-keys-panel.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/trait-csdt-code-block.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/trait-csdt-client-ip.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/trait-csdt-admin-assets.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/trait-csdt-home-panel.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/trait-csdt-security-panel.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/trait-csdt-debug-panels.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/trait-csdt-login-panels.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/trait-csdt-dashboard-widget.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-tab-intro.php'; /** * Whether a request this plugin makes to the site's OWN URL should verify TLS. * * Default false, the same default core uses for its own loopbacks (spawn_cron(), the * Site Health loopback test), because the certificate a site presents to itself is * routinely the one its proxy or tunnel terminates rather than the public one. * A site that has switched core's loopback verification on with the usual one-liner * (__return_true on https_local_ssl_verify) gets verification here too, and the * plugin's own csdt_loopback_ssl_verify filter decides anything else. This plugin * never runs core's filter itself: invoking a hook it does not own is a review finding. * One call site, so the question is answered once rather than twenty-six times. * * @return bool */ function csdt_loopback_ssl_verify(): bool { $verify = false !== has_filter( 'https_local_ssl_verify', '__return_true' ); return (bool) apply_filters( 'csdt_loopback_ssl_verify', $verify ); } require_once plugin_dir_path( __FILE__ ) . 'includes/class-cs-passkey.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-ai-dispatcher.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-csp.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-sec-headers.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-site-audit.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-vuln-scan.php'; // Deterministic (AI-free) CVE lookup used between the two judgement passes. // Loaded here rather than lazily from class-vuln-scan.php, because the deep scan // in class-site-audit.php also calls it and must not depend on load order. require_once plugin_dir_path( __FILE__ ) . 'includes/class-vuln-feed.php'; // Active verification (broker-run nuclei). Loaded eagerly for the same reason as // the CVE feed: the deep scan pulls its last result in as evidence. require_once plugin_dir_path( __FILE__ ) . 'includes/class-nuclei.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-dir-probe.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-host-software.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-php-advisory.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-php-critical.php'; // Upgrade Verification: checks the site still works after core, PHP, a theme or a plugin changes. require_once plugin_dir_path( __FILE__ ) . 'includes/class-upgrade-verify.php'; // Copy-paste remediation recipes. Replaces the old one-click fixes for anything // that needed filesystem or system-level privileges, see class-remediation.php. require_once plugin_dir_path( __FILE__ ) . 'includes/class-remediation.php'; // Which route each finding's fix takes: applied here, generated as a script, a // human decision, or nothing. Loaded after class-remediation.php because it // validates recipe ids against CSDT_Remediation::known_ids(). require_once plugin_dir_path( __FILE__ ) . 'includes/class-fix-routes.php'; // What order to fix them in. Asks CloudScale for ordering and warnings, data // only, never code, and works with the broker unreachable. Loaded after // class-fix-routes.php, whose prerequisites() it merges with. require_once plugin_dir_path( __FILE__ ) . 'includes/class-remediation-plan.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-geo.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-login.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-smtp.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-perf-monitor.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-custom-404.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-pdf-export.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-image-brief.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-thumbnails.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-optimizer.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-threat-monitor.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-monitor.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-cloudscale-site-role.php'; // Promotion: re-arms every schedule the standby disarm cleared, and `wp csdt crons` checks it. require_once plugin_dir_path( __FILE__ ) . 'includes/class-standby-rearm.php'; // Encrypted option values (the heartbeat token). Loaded before the heartbeat that reads it. require_once plugin_dir_path( __FILE__ ) . 'includes/class-secrets.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-uptime-heartbeat.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-standby-banner.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-storage.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-integrity.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-integrity-repair.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-db-malware.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-geo-block.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-firewall.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-crawler-verify.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-virtual-patch.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-live-traffic.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-ip-reputation.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-shield.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-vuln-recheck.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-cve-checks.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-vuln-autopatch.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-persistence-watch.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-request-limiter.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-control-liveness.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-auto-block.php'; // Before the shared classes, because two of them render WP_Error messages through it. A // timeout WordPress reports as "20000 milliseconds" is a 20-second ceiling set in this file's // own wp_remote_* calls; CSDT_Error_Text::in_seconds() says so in those units. require_once plugin_dir_path( __FILE__ ) . 'includes/class-cloudscale-error-text.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-cloudscale-telegram.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-alert-log.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-alert-events.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-activity-log.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-enum-guard.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-comment-guard.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-cf-comment-rule.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-cf-health.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-edge-cloudflare.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-edge-waf.php'; // CloudScale Edge Gateway (edge/1 contract): pairing, the signed /edge/state and /edge/events // routes, the forward header and its panel. Dormant until a gateway is paired. require_once plugin_dir_path( __FILE__ ) . 'includes/class-edge-sig.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-edge-event-store.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-edge-gateway.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-edge-gateway-panel.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-lockdown.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-comment-flood.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-host-alarms.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-tls-expiry.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-webhook.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-security-report.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-site-health.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-password-policy.php'; // Public tier names for models CloudScale serves. Loaded here, next to the other shared classes, // because managed_model_label() is called during admin render and a late require would silently // fall back to the generic label. require_once plugin_dir_path( __FILE__ ) . 'includes/class-cloudscale-model-names.php'; // CloudScale's AI model list, so a retired model is fixed without a release (see the class). require_once plugin_dir_path( __FILE__ ) . 'includes/class-cloudscale-model-feed.php'; // method_exists: only one plugin's copy of the shared class loads, and SEO 4.21.655 shipped one // without register_opt_in(); calling it unguarded would fatal if that copy loaded first. if ( method_exists( 'CSDT_Model_Feed', 'register_opt_in' ) ) { CSDT_Model_Feed::register_opt_in( array( 'CSDT_AI_Dispatcher', 'model_feed_enabled' ) ); } CSDT_Telegram::register_ajax(); require_once plugin_dir_path( __FILE__ ) . 'includes/class-cloudscale-licence.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-code-migrator.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-git-provider.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-github-sync.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-managed-ai.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/class-rest-diag.php'; // `wp csdt fix`, apply the audit's fixes from a shell, for the sites that have // one. Loaded only under WP-CLI: the file declares classes whose whole purpose is // to talk to WP_CLI, and parsing it on every web request buys nothing. Registered // here rather than on a hook because WP-CLI reads the command list during the // plugin load, before init. if ( defined( 'WP_CLI' ) && WP_CLI ) { require_once plugin_dir_path( __FILE__ ) . 'includes/class-cli.php'; CSDT_CLI::register(); CSDT_Standby_Rearm::register_cli(); } /** * Returns the singleton CSDT_Licence instance for the Cyber DevTools plugin. */ function csdt_licence(): CSDT_Licence { static $inst = null; if ( null !== $inst ) { return $inst; } $opt_key = CSDT_Managed_AI::OPTION; $map_get = static function () use ( $opt_key ): array { $o = get_option( $opt_key, array() ); return array( 'license_key' => (string) ( $o['proxy_license_key'] ?? '' ), 'status' => (string) ( $o['proxy_status'] ?? '' ), 'session_id' => (string) ( $o['proxy_session_id'] ?? '' ), 'email' => (string) ( $o['proxy_email'] ?? '' ), 'subscription_code' => (string) ( $o['proxy_subscription_code'] ?? '' ), 'usage' => (int) ( $o['proxy_usage'] ?? 0 ), 'limit' => (int) ( $o['proxy_limit'] ?? CSDT_Managed_AI::DEFAULT_LIMIT ), 'boost' => (int) ( $o['proxy_boost'] ?? 0 ), 'img_usage' => (int) ( $o['proxy_img_usage'] ?? 0 ), 'img_limit' => (int) ( $o['proxy_img_limit'] ?? 0 ), 'img_boost' => (int) ( $o['proxy_img_boost'] ?? 0 ), 'ai_model' => (string) ( $o['proxy_model'] ?? '' ), 'cost_mtd' => (int) ( $o['proxy_cost_mtd'] ?? 0 ), 'cost_cap' => (int) ( $o['proxy_cost_cap'] ?? 0 ), 'reset_date' => (string) ( $o['proxy_reset_date'] ?? '' ), 'status_ts' => (int) ( $o['proxy_status_ts'] ?? 0 ), 'subscribed_at' => (string) ( $o['proxy_subscribed_at'] ?? '' ), ); }; $map_set = static function ( array $s ) use ( $opt_key ): void { $o = get_option( $opt_key, array() ); $fields = array( 'license_key' => 'proxy_license_key', 'status' => 'proxy_status', 'session_id' => 'proxy_session_id', 'email' => 'proxy_email', 'subscription_code' => 'proxy_subscription_code', 'usage' => 'proxy_usage', 'limit' => 'proxy_limit', 'boost' => 'proxy_boost', 'img_usage' => 'proxy_img_usage', 'img_limit' => 'proxy_img_limit', 'img_boost' => 'proxy_img_boost', 'ai_model' => 'proxy_model', 'cost_mtd' => 'proxy_cost_mtd', 'cost_cap' => 'proxy_cost_cap', 'reset_date' => 'proxy_reset_date', 'status_ts' => 'proxy_status_ts', 'subscribed_at' => 'proxy_subscribed_at', ); foreach ( $fields as $norm => $stored ) { if ( array_key_exists( $norm, $s ) ) { $o[ $stored ] = $s[ $norm ]; } } // Managed mode rides on proxy_enabled, which the shared licence // component does not know about. Without this, connecting a licence // via the shared save-key/recover/PayPal handlers left the dashboard // tiles showing "Subscribe β†’" even though the key was stored. if ( 'active' === ( $s['status'] ?? '' ) && '' !== (string) ( $s['license_key'] ?? ( $o['proxy_license_key'] ?? '' ) ) ) { $o['proxy_enabled'] = 1; } elseif ( array_key_exists( 'license_key', $s ) && '' === (string) $s['license_key'] ) { $o['proxy_enabled'] = 0; } update_option( $opt_key, $o ); }; $inst = new CSDT_Licence( array( 'product' => 'cyber', 'nonce_name' => CSDT_Managed_AI::NONCE, 'action_prefix' => 'csdt_managed_ai', 'plugin_name' => 'Cyber and Dev Tools', 'paypal_mode' => ( defined( 'PAYPAL_MODE' ) ? PAYPAL_MODE : ( getenv( 'PAYPAL_MODE' ) ?: 'live' ) ), 'paypal_client_id' => ( 'live' === ( defined( 'PAYPAL_MODE' ) ? PAYPAL_MODE : ( getenv( 'PAYPAL_MODE' ) ?: 'live' ) ) ) ? ( defined( 'PAYPAL_LIVE_CLIENT_ID' ) ? PAYPAL_LIVE_CLIENT_ID : getenv( 'PAYPAL_LIVE_CLIENT_ID' ) ) : ( defined( 'PAYPAL_SANDBOX_CLIENT_ID' ) ? PAYPAL_SANDBOX_CLIENT_ID : getenv( 'PAYPAL_SANDBOX_CLIENT_ID' ) ), 'paypal_secret' => ( 'live' === ( defined( 'PAYPAL_MODE' ) ? PAYPAL_MODE : ( getenv( 'PAYPAL_MODE' ) ?: 'live' ) ) ) ? ( defined( 'PAYPAL_LIVE_SECRET' ) ? PAYPAL_LIVE_SECRET : getenv( 'PAYPAL_LIVE_SECRET' ) ) : ( defined( 'PAYPAL_SANDBOX_SECRET' ) ? PAYPAL_SANDBOX_SECRET : getenv( 'PAYPAL_SANDBOX_SECRET' ) ), 'paypal_plan_id' => ( 'live' === ( defined( 'PAYPAL_MODE' ) ? PAYPAL_MODE : ( getenv( 'PAYPAL_MODE' ) ?: 'live' ) ) ) ? ( defined( 'PAYPAL_DEVTOOLS_LIVE_PLAN_ID' ) ? PAYPAL_DEVTOOLS_LIVE_PLAN_ID : getenv( 'PAYPAL_DEVTOOLS_LIVE_PLAN_ID' ) ) : ( defined( 'PAYPAL_DEVTOOLS_SANDBOX_PLAN_ID' ) ? PAYPAL_DEVTOOLS_SANDBOX_PLAN_ID : getenv( 'PAYPAL_DEVTOOLS_SANDBOX_PLAN_ID' ) ), 'paypal_amount' => CSDT_PRICE_USD, 'paypal_billing_text' => 'Billed ' . CSDT_PRICE_TAG . ' USD/month Β· Cancel anytime', 'usage_label' => 'AI requests', 'refund_days' => CSDT_Managed_AI::REFUND_DAYS, 'contact' => true, 'meters' => array( array( 'label' => 'Image generations', 'usage_key' => 'img_usage', 'limit_key' => 'img_limit', 'boost_key' => 'img_boost', 'unit' => 'images', 'slug' => 'img', ), ), 'boosts' => array( array( 'type' => 'boost', 'label' => '⚑ Boost +200 credits, $10', ), array( 'type' => 'image_boost', 'label' => 'πŸ–Ό Image boost +15 images, $5', ), ), 'paypal_currency' => 'USD', 'paypal_return_url' => admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&cs_paypal_return=1' ), 'paypal_cancel_url' => admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG ), 'get_state' => $map_get, 'set_state' => $map_set, 'on_deactivate' => static function ( string $reason, string $detail = '' ): void { CSDT_Alert_Events::send( CSDT_Alert_Events::GENERAL, "⚠️ Cyber DevTools, the licence stored on this site changed to: $reason" . ( $detail ? "\nContext: $detail" : '' ) . "\nSite: " . home_url( '/' ) . "\n(Existing-licence status change, NOT a new purchase attempt.)", 'devtools-licence', 'warning' ); }, 'notify' => static function ( string $event, string $detail, string $level ): void { $emoji = ( 'error' === $level ) ? '🚨' : ( ( 'warning' === $level ) ? '⚠️' : 'ℹ️' ); CSDT_Alert_Events::send( CSDT_Alert_Events::GENERAL, "$emoji Cyber DevTools, $event\n$detail", 'devtools-licence', $level ); }, 'action_aliases' => array( 'csdt_managed_ai_poll_session' => 'poll', 'csdt_managed_ai_refresh_status' => 'sync_status', ), ) ); return $inst; } /** * Absolute path of the WordPress debug log, read-only. * * Resolution order: the administrator's relocated path, then WP_DEBUG_LOG when it holds a path, * then core's default. WordPress exposes no API for that default, wp_upload_dir() is the wrong * directory and plugin_dir_path() is the wrong tree, so this function is the ONE place in the * plugin that names the wp-content constant, and it is listed in .wporg-prefixes.json as a * read-only exemption. Five call sites each derived this path independently before, which meant * five identical constant references for WordPress.org to flag and five places to change if the * resolution order was ever wrong. * * Nothing here opens, creates or writes the file: callers only stat it, read it, or print the * path as advice. * * @since 1.10.691 * @return string Absolute path. Not guaranteed to exist, callers must check. */ function csdt_devtools_wp_debug_log_path(): string { $relocated = (string) get_option( 'csdt_debug_log_path', '' ); if ( '' !== $relocated ) { return $relocated; } if ( defined( 'WP_DEBUG_LOG' ) && is_string( WP_DEBUG_LOG ) && '' !== WP_DEBUG_LOG ) { return (string) WP_DEBUG_LOG; } return CSDT_Paths::content_dir() . '/debug.log'; } /** * Whether the current request is an admin/REST/WP-CLI/cron context or comes * from a logged-in WordPress user (detected via the auth cookie, since * current_user_can() cannot be used this early, user auth is not yet * bootstrapped). Used to scope diagnostic-only behaviour (the debug-logging * ini_set calls) away from every anonymous frontend page view, * per WordPress.org's "don't force PHP settings globally" guideline. * * @since 1.10.276 * @return bool */ function csdt_devtools_is_diagnostic_context(): bool { $is_ctx = ( defined( 'WP_ADMIN' ) && WP_ADMIN ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( isset( $_SERVER['REQUEST_URI'] ) && strpos( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), '/wp-admin' ) !== false ); if ( $is_ctx ) { return true; } // Cookie NAMES only, read in the one place this plugin reads $_COOKIE. return CSDT_Request::has_login_cookie(); } /** * CloudScale Code Block, main plugin class. * * Handles block registration, shortcode, admin tools, settings, * and the code block migration tool. * * @package CSDT_DevTools * @since 1.0.0 */ class CSDT_DevTools { use CSDT_Keys_Panel; use CSDT_Code_Block; use CSDT_Client_IP; use CSDT_Admin_Assets; use CSDT_Home_Panel; use CSDT_Security_Panel; use CSDT_Debug_Panels; use CSDT_Login_Panels; use CSDT_Dashboard_Widget; const VERSION = '1.10.1077'; // UTC date this version was released, shown beside it in the banner. Stamped by the release // build together with VERSION, never by hand. const RELEASED = '2026-10-28'; // One User-Agent for every request this plugin makes. WordPress's default is // 'WordPress/; https://', which puts the site's address in a request the // readme says never carries it. Name and version only, never a URL. const USER_AGENT = 'CloudScale-Cyber-DevTools/' . self::VERSION; // Must match the version banner inside assets/highlight.min.js. A mismatch is a // WordPress.org rejection: the reviewer reads the version out of the minified file // AND the readme, and an understated number reads as a stale bundled library. // scripts/wp-review-repeat-offenders.php asserts all three agree. const HLJS_VERSION = '11.12.0'; // Mermaid, bundled as assets/mermaid.min.js (upstream's own dist build, unmodified) and // loaded only on a page that has a Mermaid code block. Must match the version string // inside that file and the readme's credits; tests/code-block-assets-test.php checks both. const MERMAID_VERSION = '12.2.0'; const CHARTJS_VERSION = '4.5.1'; // Vega, Vega-Lite and Vega's expression interpreter, bundled as assets/vega.min.js, // assets/vega-lite.min.js and assets/vega-interpreter.js and loaded only on a page that // has a Vega code block. Each must match the version inside its file and the readme. const VEGA_VERSION = '6.4.0'; const VEGA_LITE_VERSION = '6.5.0'; const VEGA_INTERPRETER_VERSION = '2.3.2'; // Code block languages drawn as a chart instead of highlighted. assets/cs-code-block.js // holds the same two lists; tests/code-block-assets-test.php fails the build if they differ. const CHART_LANGS = array( 'chart', 'chartjs', 'chart.js', 'chart-js' ); const VEGA_LANGS = array( 'vega', 'vega-lite', 'vegalite' ); const TOOLS_SLUG = 'cloudscale-cyber-devtools'; /** * Every tab slug that renders the Diagnostics panel. * * `debug` is the one in the tab bar; `logs`, `sql` and `404` are aliases kept so older links, * from alerts, from the published help docs, from a bookmark, still land somewhere useful. * * ONE LIST, BECAUSE THE PANEL AND ITS SCRIPTS MUST AGREE. The dispatch rendered the panel for * all four while the enqueues named different subsets by hand: cs-debug.js on `debug` only, * cs-server-logs.js on `debug` and `logs`, cs-404-admin.js on `debug` and `404`. So `tab=sql` * rendered the full Diagnostics panel and loaded NONE of the three, and `tab=logs` and * `tab=404` each loaded one, every button on those pages rendered, looked enabled, and did * nothing. Found 2026-08-15 by tests/button-wiring-test.php on its first complete run, after it * learned to read the in_array() branch of the dispatch. * * Anything added to this list must be added nowhere else. * * Slugs that all render the Diagnostics page. They are ALIASES, not separate screens. * * `sql` was dropped 2026-08-29, completing plugin-split-plan.md stage 1: the SQL Runner tool * moved to its own plugin, both fix routes that fell back to `tab=sql` were re-pointed to drop * that fallback, and the pointer panel was deleted. See archive/removed-sql-tab-2026-08-29.php. * * @var string[] */ const DIAGNOSTICS_TABS = array( 'debug', 'logs', '404' ); /** * How old a Site Audit may get before the dashboard widget shows it red. Four weeks, asked for * 2026-09-30. The audit is not scheduled (it spends AI credit), so this is the cadence the * widget asks the admin to keep, with a Run Audit Now button beside the age. * * @var int Seconds. */ const AUDIT_STALE_AFTER = 28 * DAY_IN_SECONDS; /** * The Images tab and the slug that used to be a second tab beside it. * * `ai-images` was its own tab until 2026-08-18, and both tabs were always rendered by the same * class (CSDT_Thumbnails) off the same enqueue, the split was in the bar, not in the code. It * put "Featured Images" six tabs away from the "Default Featured Image" panel that decides what * happens when generation is not used, so the two halves of one decision were never on screen * together. * * `ai-images` stays a working slug rather than becoming a 404, for the ordinary reason: it has * been a bookmarkable URL for months. Dropping it is the silent dead deep link this estate has * already been bitten by, and keeping it costs one array entry. * * Canonical FIRST. render_tab_intro() and the bar both take element 0 as the slug to link to, so * reordering this array changes which URL the product advertises. */ const IMAGE_TABS = array( 'thumbnails', 'ai-images' ); // Nonce action strings, single source of truth used across all extracted classes. const MIGRATE_NONCE = 'csdt_devtools_code_migrate_action'; const SECURITY_NONCE = 'csdt_devtools_security_nonce'; const OPTIMIZER_NONCE = 'csdt_optimizer_nonce'; const LOGS_NONCE = 'csdt_devtools_server_logs'; const DEBUG_NONCE = 'csdt_debug_nonce'; const FPM_NONCE = 'csdt_fpm_nonce'; const SITE_AUDIT_NONCE = 'csdt_site_audit_nonce'; const PERF_NONCE = 'csdt_devtools_perf_monitor_nonce'; const LOGIN_NONCE = 'csdt_devtools_login_nonce'; // Toolbar icons for a rendered code block. These are constants because build_html() is now // string concatenation rather than a captured template -- five multi-line SVG literals inline // in that expression would bury the markup they decorate. Fixed literals, no interpolation, // which is why build_html() prints them without escaping. const ICON_LINES_SVG = '123'; const ICON_SUN_SVG = ''; const ICON_MOON_SVG = ''; const ICON_COPY_SVG = ''; const ICON_CHECK_SVG = ''; const SMTP_NONCE = 'csdt_devtools_smtp_nonce'; // Auth transient key prefixes (suffixed at call-site). const LOGIN_2FA_TRANSIENT = 'csdt_devtools_2fa_pending_'; const LOGIN_OTP_TRANSIENT = 'csdt_devtools_2fa_otp_'; const EMAIL_VERIFY_TRANSIENT = 'csdt_devtools_email_verify_'; const TOTP_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; // Option / REST constants. const CUSTOM_404_OPTION = 'csdt_devtools_custom_404'; const SCHEME_404_OPTION = 'csdt_devtools_404_scheme'; const HISCORE_NS = 'csdt-devtools/v1'; const SCORE_NONCE_ACTION = 'csdt_devtools_score_post'; private static $instance_count = 0; private static $assets_enqueued = false; /** * The $_SERVER keys the plugin can read a forwarded client IP from, keyed by * the value stored in the csdt_devtools_ip_source option. * * REMOTE_ADDR is deliberately absent: it is the peer address, never a * forwarded header, and is handled as the default/fallback path. * * @since 1.10.381 */ const IP_SOURCE_HEADERS = array( 'cf' => 'HTTP_CF_CONNECTING_IP', 'xff' => 'HTTP_X_FORWARDED_FOR', 'xreal' => 'HTTP_X_REAL_IP', 'true_client' => 'HTTP_TRUE_CLIENT_IP', ); /** * Absolute path to this plugin's log directory inside wp-content/uploads, * creating it (and an Apache access-deny rule) on first use. Per * WordPress.org's file-location guidance, plugin-writable storage must use * wp_upload_dir() rather than a path hardcoded under wp-content. * * @since 1.10.276 * @return string Absolute path, no trailing slash. */ /** * The release date for the banner, "21 Oct 2026", in the site's language. '' when the stamp * is not a date, so a bad build prints the version alone rather than garbage. */ public static function released_label(): string { $ts = strtotime( self::RELEASED . ' 00:00:00 UTC' ); if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', self::RELEASED ) || false === $ts ) { return ''; } return wp_date( 'j M Y', $ts, new DateTimeZone( 'UTC' ) ); } public static function get_log_dir(): string { // In the plugin-slug folder WordPress.org asks for (/cloudscale-cyber-devtools-logs // before 1.10.890; maybe_migrate_log_dir() moves an existing one). $dir = wp_upload_dir()['basedir'] . '/cloudscale-cyber-devtools/logs'; if ( ! is_dir( $dir ) ) { wp_mkdir_p( $dir ); } $htaccess = $dir . '/.htaccess'; if ( ! file_exists( $htaccess ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- one-time Apache access-deny rule for a log directory, not plugin data; @ suppresses a notice on read-only filesystems where the file simply won't be created @file_put_contents( $htaccess, "Require all denied\nDeny from all\n" ); } return $dir; } /** * Moves the pre-1.10.890 log folder (/cloudscale-cyber-devtools-logs) into * get_log_dir() and repoints the two stored log paths, once. Same shape as * CSDT_Geo::maybe_migrate_legacy_dir(): WP_Filesystem, admin or cron requests only, * a flag option, and nothing lost if it fails (log() falls back to the new folder). * * @return void */ public static function maybe_migrate_log_dir(): void { if ( get_option( 'csdt_log_dir_migrated' ) ) { return; } $legacy = wp_upload_dir()['basedir'] . '/cloudscale-cyber-devtools-logs'; $new = self::get_log_dir(); foreach ( array( 'csdt_debug_log_path', 'csdt_php_error_log_path' ) as $opt ) { $val = (string) get_option( $opt, '' ); if ( '' !== $val && 0 === strpos( $val, $legacy . '/' ) ) { update_option( $opt, $new . substr( $val, strlen( $legacy ) ), false ); } } if ( ! is_dir( $legacy ) ) { update_option( 'csdt_log_dir_migrated', '1', false ); return; } require_once ABSPATH . 'wp-admin/includes/file.php'; if ( ! WP_Filesystem() ) { return; } global $wp_filesystem; foreach ( (array) $wp_filesystem->dirlist( $legacy ) as $name => $info ) { if ( '.htaccess' === $name || 'f' !== ( $info['type'] ?? '' ) || $wp_filesystem->exists( $new . '/' . $name ) ) { continue; } $wp_filesystem->move( $legacy . '/' . $name, $new . '/' . $name ); } $wp_filesystem->rmdir( $legacy, true ); update_option( 'csdt_log_dir_migrated', '1', false ); } /** * Writes one line to the plugin's own log, in the administrator's chosen protected path. * * WHY THIS EXISTS RATHER THAN ini_set( 'error_log', … ) * ---------------------------------------------------- * Relocating diagnostics off the web root is a real security feature, and it used to be * implemented by pointing PHP's own error log at the protected path with `ini_set()`. That * changes configuration for every other plugin on the request, which is why WordPress.org * rejected it in the 18 Aug 2026 review, and it was never reliable anyway: a great many * hosts ignore `ini_set( 'error_log', … )` outright, and none of them honour it against a * hard FastCGI setting, so the guarantee it appeared to give was already conditional. * * Writing the line ourselves is both compliant and stronger: the destination no longer * depends on PHP accepting a runtime override. PHP's global error log is left exactly as * the server configured it. * * Falls back to error_log() when there is no writable plugin path, losing a diagnostic * line is worse than writing it to the server default, and error_log() is a core-sanctioned * function. Deliberately tolerant of a half-booted WordPress: several callers are shutdown * and fatal handlers, where get_option() may be unavailable. * * @since 1.10.691 * @param string $message Line to append. A timestamp and prefix are added. */ public static function log( string $message ): void { $path = ''; if ( function_exists( 'get_option' ) ) { $path = (string) ( get_option( 'csdt_php_error_log_path', '' ) ?: get_option( 'csdt_debug_log_path', '' ) ); } // THE WRITE TARGET IS CONSTRAINED HERE, not trusted from the option. // // Neither option is ever set to anything but self::get_log_dir() . '/...' today // (both write sites build the value from get_log_dir(), never from an admin-typed // field), but WordPress.org round T21 flagged this write on the shape of the line // alone, "the configured log path can direct writes outside the plugin-owned // uploads directory", and that is a fair reading in isolation: nothing at THIS // point stopped a future write site, or an option poisoned by an unrelated bug, // from pointing it anywhere on the filesystem. is_safe_log_path() is the wrong // tool for this: it is a DENY-list for the log VIEWER, built to let an admin point // at a real log file elsewhere on the server, which is the opposite of what a write // target needs. This is an ALLOW-list of one directory instead. // // THE PATH IS NORMALISED BEFORE THE PREFIX CHECK, not compared as typed. // '/logs/../../../wp-config.php' starts with '/logs/' as a plain string, so a // strpos() prefix test alone accepts it: the first draft of this gate did exactly // that and a build-time test with a real '../' payload caught it writing outside // the log directory. Collapsing '.'/'..' segments first is what makes the prefix // check mean what it says. if ( '' !== $path && function_exists( 'wp_upload_dir' ) ) { $log_dir = rtrim( self::get_log_dir(), '/' ); $resolved = array(); foreach ( explode( '/', rtrim( $path, '/' ) ) as $seg ) { if ( '' === $seg || '.' === $seg ) { continue; } if ( '..' === $seg ) { array_pop( $resolved ); continue; } $resolved[] = $seg; } $normalised = '/' . implode( '/', $resolved ); if ( 0 !== strpos( $normalised, $log_dir . '/' ) && $normalised !== $log_dir ) { $path = ''; } else { $path = $normalised; } } if ( '' === $path && function_exists( 'wp_upload_dir' ) ) { // No explicit (or no longer trusted) path: the plugin's own protected log dir is // still better than the server default, and get_log_dir() creates the deny rule // alongside it. $path = self::get_log_dir() . '/debug.log'; } if ( '' !== $path ) { $line = '[' . gmdate( 'd-M-Y H:i:s' ) . ' UTC] ' . $message . "\n"; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- appends one diagnostic line to the plugin's own log file; WP_Filesystem is not available in shutdown/fatal handlers, which is where several callers run. @ because a read-only filesystem must fall through to error_log() rather than emit a warning from inside the error path. if ( false !== @file_put_contents( $path, $line, FILE_APPEND | LOCK_EX ) ) { return; } } // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- last resort when the plugin's own log is unwritable; a dropped diagnostic is worse than the server default error_log( $message ); } /** * Registers a WP-Cron action with a Throwable safety wrapper. * * An uncaught exception inside a cron callback causes PHP-FPM to SIGSEGV, * taking the entire worker pool down. This wrapper catches any Throwable, * logs it, and returns cleanly so the worker survives. */ private static function cron_action( string $hook, callable $callback ): void { add_action( $hook, static function () use ( $hook, $callback ): void { $started = microtime( true ); $error = ''; try { $callback(); } catch ( \Throwable $e ) { $error = sprintf( '%s: %s in %s line %d', get_class( $e ), $e->getMessage(), basename( $e->getFile() ), $e->getLine() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- cron exception handler; only path to surface crashes self::log( sprintf( '[CSDT] cron "%s" exception (%s): %s in %s line %d', $hook, get_class( $e ), $e->getMessage(), $e->getFile(), $e->getLine() ) ); } self::record_cron_run( $hook, self::callable_label( $callback ), $started, $error ); } ); } /** Option holding the last run of every wrapped cron callback. Not autoloaded: only the panel reads it. */ const CRON_STATUS_OPTION = 'csdt_cron_status'; /** * A readable name for a cron callback: Class::method, a function name, or "closure". * * Several hooks run more than one callback (csdt_threat_monitor runs the threat monitor * and the liveness check), so status is kept per callback, not per hook: one failing * callback must not be hidden by a sibling that succeeded on the same tick. * * @since 1.10.927 * @param callable $callback The callback. * @return string */ private static function callable_label( callable $callback ): string { if ( is_array( $callback ) ) { $owner = is_object( $callback[0] ) ? get_class( $callback[0] ) : (string) $callback[0]; return $owner . '::' . (string) $callback[1]; } return is_string( $callback ) ? $callback : 'closure'; } /** * Remembers one run of a wrapped cron callback: when, how long, and whether it failed. * * Until 1.10.927 the wrapper caught an exception, wrote one line to a log file, and kept * nothing, so the only way to learn that a security job had been failing for a week was to * read the log. Kept in one option, keyed hook + callback, read by the Scheduled tasks * panel. A concurrent run can lose an update to another's read-modify-write; this is a * diagnostic, and the next run corrects it. * * Never throws: it runs after the callback inside the wrapper whose whole purpose is that * nothing escapes a cron worker. * * @since 1.10.927 * @param string $hook Cron hook. * @param string $label callable_label() of the callback. * @param float $started microtime( true ) at start. * @param string $error '' on success, else the exception summary. * @return void */ public static function record_cron_run( string $hook, string $label, float $started, string $error ): void { try { $now = time(); $all = get_option( self::CRON_STATUS_OPTION, array() ); $all = is_array( $all ) ? $all : array(); $key = $hook . ' ' . $label; $row = is_array( $all[ $key ] ?? null ) ? $all[ $key ] : array(); $row = array_merge( array( 'hook' => $hook, 'callback' => $label, 'runs' => 0, 'fails' => 0, 'last_ok_at' => 0, 'last_error' => '', 'last_error_at' => 0, ), $row, array( 'last_run_at' => $now, 'duration_ms' => (int) round( ( microtime( true ) - $started ) * 1000 ), 'ok' => '' === $error, ) ); ++$row['runs']; if ( '' === $error ) { $row['last_ok_at'] = $now; } else { ++$row['fails']; $row['last_error'] = substr( $error, 0, 300 ); $row['last_error_at'] = $now; } $all[ $key ] = $row; update_option( self::CRON_STATUS_OPTION, $all, false ); } catch ( \Throwable $e ) { unset( $e ); // Status is a diagnostic; the job itself already ran. } } /** * Scheduled tasks panel: every wrapped cron callback, its last run, duration and last error. * * @since 1.10.927 * @return void */ private static function render_cron_status_panel(): void { $all = get_option( self::CRON_STATUS_OPTION, array() ); $all = is_array( $all ) ? $all : array(); ksort( $all ); $failing = 0; foreach ( $all as $row ) { if ( is_array( $row ) && empty( $row['ok'] ) ) { ++$failing; } } ?>
⏱️ 'What this shows', 'rec' => 'Info', 'html' => 'Every background job this plugin runs through WP-Cron: the threat monitor, auto-block pruning, integrity checks, scheduled scans and the rest. For each one: when it last ran, how long it took, and the last error it hit, if any.

A hook that runs two jobs is listed twice, once per job, so one that fails is not hidden by one that succeeded on the same tick.', ), array( 'name' => 'Why it exists', 'rec' => 'How it works', 'html' => 'A failing background job used to leave one line in a log file and nothing else, so a security check could stop working for days with nothing on screen to say so. The run is now recorded every time, and a failed one is shown here in red until it next succeeds.', ), array( 'name' => 'What it cannot see', 'rec' => 'Honest limits', 'html' => 'A job that never starts leaves no record: if WP-Cron itself is not running, nothing here moves. Check that the "Last run" times keep advancing. A job listed as "Next due" long in the past is also a sign that WP-Cron is not being triggered.', ), ) ); ?>

0 ) : ?>


' . esc_html( sprintf( /* translators: 1: time since the error, 2: failed runs, 3: total runs */ __( '%1$s ago; %2$d of %3$d runs failed', 'cloudscale-cyber-devtools' ), human_time_diff( (int) ( $row['last_error_at'] ?? time() ) ), (int) ( $row['fails'] ?? 0 ), (int) ( $row['runs'] ?? 0 ) ) ) . ''; } ?>
user_login ) { return $display_name; } // Not the login (or a variation of it), nothing is being disclosed. if ( 0 !== strcasecmp( trim( (string) $display_name ), (string) $author->user_login ) ) { return $display_name; } /** * Label substituted for an author name that would otherwise disclose a * login. Defaults to the site name, which reads naturally in a feed. * * @param string $label Replacement label. * @param int $author_id The author whose login was withheld. */ $label = apply_filters( 'csdt_feed_author_label', get_bloginfo( 'name' ), (int) $author->ID ); $label = trim( (string) $label ); return '' !== $label ? $label : 'Editorial'; } /** * Authors whose display name IS their login, and who have published content. * * These are the accounts a feed would expose. Restricted to authors with * published posts because an account with nothing published never appears in * a feed and so is not part of this finding. * * @return array user_login values, keyed by user ID. */ public static function authors_disclosing_logins(): array { $out = array(); foreach ( get_users( array( 'fields' => array( 'ID', 'user_login', 'display_name' ) ) ) as $u ) { if ( 0 !== strcasecmp( trim( (string) $u->display_name ), (string) $u->user_login ) ) { continue; } if ( 0 === (int) count_user_posts( (int) $u->ID, 'post', true ) ) { continue; } $out[ (int) $u->ID ] = (string) $u->user_login; } return $out; } /** * Accounts whose PUBLIC name fields still carry their login. * * WordPress stores three names per user and defaults all three to the same * string at registration: * * user_login the credential typed at the login form, private * display_name the byline printed in feeds, on posts, in comments, public * user_nicename the slug in /author//, public * * When the public two still mirror the private one, every feed item and every * author archive URL publishes a *confirmed valid* username. That converts a * credential-guessing attack from "guess a username and a password" into * "guess a password", which is the entire difficulty of the attack. It is also * why the disclosure survives feature toggles: masking the feed output hides * the symptom, but the archive slug still spells the login out, so the fix * belongs in the user record rather than in a filter. * * Restricted to accounts that can actually appear publicly, ones with * published posts, or with a role that can publish. An account that never * surfaces has nothing to disclose. * * @return array}> Keyed by user ID. */ public static function users_with_login_in_public_names(): array { $out = array(); foreach ( get_users( array( 'fields' => array( 'ID', 'user_login', 'display_name', 'user_nicename' ) ) ) as $u ) { $login = (string) $u->user_login; if ( '' === $login ) { continue; } $fields = array(); if ( 0 === strcasecmp( trim( (string) $u->display_name ), $login ) ) { $fields[] = 'display name'; } // Compare against the sanitised login too: WordPress derives the slug // with sanitize_title(), so a login of "JSmith_Admin" yields the slug // "jsmith_admin". Treating those as different would miss the disclosure. if ( 0 === strcasecmp( (string) $u->user_nicename, $login ) || (string) $u->user_nicename === sanitize_title( $login ) ) { $fields[] = 'author URL slug'; } if ( empty( $fields ) ) { continue; } $user = get_userdata( (int) $u->ID ); $can = $user instanceof WP_User && $user->has_cap( 'publish_posts' ); if ( 0 === (int) count_user_posts( (int) $u->ID, 'post', true ) && ! $can ) { continue; } $out[ (int) $u->ID ] = array( 'login' => $login, 'fields' => $fields, ); } return $out; } /** * Adopt fixes that older versions installed as must-use plugin files into the * option flags used now, and delete the orphaned files. Runs once per site. * * @return void */ private static function migrate_legacy_mu_plugin_fixes() { $mu_dir = CSDT_Paths::mu_plugins_dir(); $map = array( 'csdt-disable-xmlrpc.php' => 'csdt_disable_xmlrpc', 'csdt-no-file-edit.php' => 'csdt_disable_file_edit', 'csdt-limit-revisions.php' => 'csdt_limit_revisions', 'csdt-no-user-enum.php' => 'csdt_block_user_enum', ); foreach ( $map as $file => $option ) { $path = $mu_dir . '/' . $file; if ( file_exists( $path ) ) { update_option( $option, '1' ); wp_delete_file( $path ); } } // These two only relocated the error log via an option that is already set // when the fix was applied, so just remove the orphaned files. foreach ( array( 'csdt-secure-logs.php', 'csdt-php-error-log.php' ) as $file ) { $path = $mu_dir . '/' . $file; if ( file_exists( $path ) ) { wp_delete_file( $path ); } } update_option( 'csdt_muplugin_fixes_migrated', '1' ); } /** * Preserves the pre-1.10.866 ON defaults for sites that already had the plugin. * * `add_option()`, not `update_option()`: it writes only when the row is absent, so an * owner who has already chosen either setting is never overwritten. * * @since 1.10.866 * @return void */ private static function migrate_presentation_defaults(): void { add_option( 'csdt_devtools_perf_monitor_enabled', '1' ); add_option( self::CUSTOM_404_OPTION, 1 ); update_option( 'csdt_presentation_defaults_migrated', '1' ); } /** * One-time rename of legacy option names to the csdt_ prefix (WordPress.org * requires plugin-unique option prefixes). Copies each old value to the new * key if the new key is unset; the old option is left in place so any other * CloudScale plugin still reading it is unaffected. Transients are not * migrated (they are short-lived and regenerate under the new key). * * @return void */ private static function migrate_option_prefixes() { $option_map = array( 'cloudscale_default_image_id' => 'csdt_default_image_id', ); // The Telegram options are migrated by CSDT_Telegram itself (maybe_migrate()), the // one place the old suite-wide names are read, for every CloudScale plugin alike. foreach ( $option_map as $old => $new ) { $old_val = get_option( $old, null ); if ( null !== $old_val && false === get_option( $new, false ) ) { update_option( $new, $old_val ); } } update_option( 'csdt_option_prefix_migrated', '1' ); } /** * Renames what a site saved under this plugin's pre-rename names (cs_devtools_ options, * _cs_social_formats post meta) to the csdt_ names the plugin reads. * * A value moves only when the new name holds nothing, so a setting saved since the rename * always wins; the old row is removed either way. Post meta goes through the meta API, a * batch per admin request, so a persistent object cache never serves the old layout. The * done flag is set once a request finds nothing left. * * @return void */ private static function migrate_legacy_names() { global $wpdb; $batch = 200; $left = false; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-time migration; the options API cannot list names by prefix $names = $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s LIMIT %d', $wpdb->options, $wpdb->esc_like( 'cs_devtools_' ) . '%', $batch ) ); foreach ( (array) $names as $old ) { $new = 'csdt_devtools_' . substr( $old, 12 ); $val = get_option( $old ); if ( false !== $val && false === get_option( $new ) ) { update_option( $new, $val, false ); } delete_option( $old ); } $left = count( (array) $names ) >= $batch; foreach ( array( '_cs_social_formats' => '_csdt_social_formats', '_cs_social_formats_thumb_id' => '_csdt_social_formats_thumb_id' ) as $old => $new ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-time migration; the meta API cannot list posts by key without loading each post $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT post_id FROM %i WHERE meta_key = %s LIMIT %d', $wpdb->postmeta, $old, $batch ) ); foreach ( (array) $ids as $id ) { $id = (int) $id; if ( ! metadata_exists( 'post', $id, $new ) ) { update_post_meta( $id, $new, get_post_meta( $id, $old, true ) ); } delete_post_meta( $id, $old ); } $left = $left || count( (array) $ids ) >= $batch; } if ( ! $left ) { update_option( 'csdt_legacy_names_migrated', '1' ); } } /** * Whether XML-RPC is switched on for this site, without running core's xmlrpc_enabled * filter (a plugin invoking a hook it does not own is a review finding). * * Off when xmlrpc.php is gone, when this plugin's own switch is on, or when anything has * attached __return_false to xmlrpc_enabled, which is how this plugin, the common * "Disable XML-RPC" plugins and the usual snippet all do it. A closure that returns false * is not recognised, so the answer errs towards "on": an audit reporting an exposure * that is not there is a nuisance, one that misses a real one is the failure. * * @return bool */ public static function xmlrpc_enabled(): bool { if ( ! file_exists( CSDT_Paths::root_file( 'xmlrpc.php' ) ) ) { return false; } if ( '1' === CSDT_Options::get( 'csdt_disable_xmlrpc' ) ) { return false; } return false === has_filter( 'xmlrpc_enabled', '__return_false' ); } /** * Whether the theme and plugin file editors are locked, by wp-config.php or by this plugin. */ public static function file_edit_locked(): bool { return ( defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT ) || '1' === get_option( 'csdt_disable_file_edit', '0' ); } /** * map_meta_cap: denies the three file-editor capabilities, as core does for DISALLOW_FILE_EDIT. * * @param string[] $caps Primitive capabilities required. * @param string $cap Capability being checked. * @return string[] */ public static function deny_file_edit_caps( $caps, $cap ) { return in_array( $cap, array( 'edit_files', 'edit_plugins', 'edit_themes' ), true ) ? array( 'do_not_allow' ) : $caps; } /** * Registers all plugin hooks. * * @since 1.0.0 * @return void */ public static function init() { self::maybe_migrate_autoload(); if ( is_admin() || wp_doing_cron() ) { CSDT_Geo::maybe_migrate_legacy_dir(); self::maybe_migrate_log_dir(); } CSDT_SMTP::maybe_migrate_prefix(); CSDT_SMTP::maybe_migrate_smtp_prefix(); CSDT_SMTP::maybe_migrate_usermeta_prefix(); // Probe threshold: 20 was the lowest the old menu offered and Off was the // default, so a site could accumulate dozens of scanner hits per address with // nothing blocked. Runs once; leaves a deliberate Off alone. CSDT_Auto_Block::maybe_tighten_probe_rule(); // One-click security hardening, option-driven filters applied at every boot if ( get_option( 'csdt_block_basic_auth', '0' ) === '1' ) { // Hard block, no app passwords / Basic Auth for anyone, including test accounts add_filter( 'wp_is_application_passwords_available', '__return_false' ); } elseif ( get_option( 'csdt_devtools_disable_app_passwords' ) === '1' ) { // CSDT_Test_Accounts (roles, sessions, the CI token) lives in the private // cloudscale-test-accounts companion plugin, not distributed via WP.org, see // docs/wporg-justifications.md. class_exists() guards every reference to it so // this plugin has no hard dependency on it: without the companion active, a site // with test accounts "enabled" has none to exempt, so blocking site-wide is the // correct fallback, not a silent no-op. if ( get_option( 'csdt_test_accounts_enabled', '0' ) === '1' && class_exists( 'CSDT_Test_Accounts' ) ) { // Test-account mode: block per-user (not site-wide) so test accounts still authenticate add_filter( 'wp_is_application_passwords_available_for_user', array( 'CSDT_Test_Accounts', 'filter_app_pw_for_user' ), 10, 2 ); } else { add_filter( 'wp_is_application_passwords_available', '__return_false' ); } } // Test-account cleanup cron + single-use hook (always when feature is enabled) if ( get_option( 'csdt_test_accounts_enabled', '0' ) === '1' && class_exists( 'CSDT_Test_Accounts' ) ) { add_action( 'application_password_did_authenticate', array( 'CSDT_Test_Accounts', 'test_account_after_auth' ), 10, 2 ); } if ( get_option( 'csdt_devtools_hide_wp_version' ) === '1' ) { remove_action( 'wp_head', 'wp_generator' ); add_filter( 'the_generator', '__return_empty_string' ); // Strip ?ver= query strings from enqueued scripts/styles to prevent version fingerprinting add_filter( 'style_loader_src', array( 'CSDT_Thumbnails', 'strip_asset_ver' ), 9999 ); add_filter( 'script_loader_src', array( 'CSDT_Thumbnails', 'strip_asset_ver' ), 9999 ); } // One-click "Quick Fix" hardening, applied in-plugin from option flags. // These replace must-use plugin files: a WordPress.org-hosted plugin may // not write executable code into wp-content/mu-plugins, so each fix is // applied here at boot instead. See CSDT_Site_Audit::ajax_apply_quick_fix(). if ( get_option( 'csdt_disable_xmlrpc' ) === '1' ) { add_filter( 'xmlrpc_enabled', '__return_false' ); } if ( get_option( 'csdt_disable_file_edit', '0' ) === '1' ) { // The same capability denial core applies for DISALLOW_FILE_EDIT, without defining // a core constant from a plugin. add_filter( 'map_meta_cap', array( __CLASS__, 'deny_file_edit_caps' ), 10, 2 ); } if ( get_option( 'csdt_limit_revisions', '0' ) === '1' ) { add_filter( 'wp_revisions_to_keep', static function () { return 5; }, 99 ); } if ( CSDT_Options::get( 'csdt_block_user_enum' ) === '1' ) { add_action( 'template_redirect', static function () { // `?author=N` reaches template_redirect as the parsed `author` query var; the // raw request is not read. A logged-out request for any author archive is // user enumeration, and is sent home. if ( ! is_admin() && ! is_user_logged_in() && '' !== (string) get_query_var( 'author', '' ) ) { wp_safe_redirect( home_url( '/' ), 301 ); exit; } } ); // Blocking ?author=N is only half the job: RSS, RSS2, RDF and Atom all // print the author's display name, into for the RSS family // and for Atom, and WordPress defaults display_name to the // account's user_login. So a feed hands out a valid username to anyone // who asks, which is the harder half of a brute-force already done. add_filter( 'the_author', array( __CLASS__, 'mask_feed_author_login' ) ); } // The admin's chosen safe log path used to be applied here by overriding PHP's own // error-log destination for the whole request. It is not any more: every diagnostic this // plugin emits goes through self::log(), which writes to that path directly. See the // rationale on self::log(), the override was rejected by WordPress.org for changing // configuration other plugins share, and most hosts ignored it regardless. // // Nothing needs to happen at load time now, which is the point: relocating the log is a // property of how this plugin writes, not a global it has to set on every request. // One-time migration for sites upgraded from a version that installed the // above fixes as must-use plugin files: adopt each into its option flag and // remove the now-orphaned file. if ( is_admin() && '1' !== get_option( 'csdt_muplugin_fixes_migrated', '0' ) ) { self::migrate_legacy_mu_plugin_fixes(); } // One-time migration of legacy cloudscale_* option names to the csdt_ prefix. if ( is_admin() && '1' !== get_option( 'csdt_option_prefix_migrated', '0' ) ) { self::migrate_option_prefixes(); } // Names from before the csdt_ prefix: renamed here, in batches, so nothing else in the // plugin has to read or remove an old name. if ( is_admin() && '1' !== get_option( 'csdt_legacy_names_migrated' ) ) { self::migrate_legacy_names(); } // One-time migration for the two defaults that used to be ON for every install. They // are OFF for a fresh install now, because WordPress.org objects to a plugin changing // how a site behaves without the owner choosing it: the custom 404 replaces the // theme's own 404 template, and the performance monitor installs a global error // handler on every admin request. Flipping a default silently // changes running sites too, so a site that already has the plugin keeps what it had. if ( is_admin() && '1' !== get_option( 'csdt_presentation_defaults_migrated', '0' ) ) { self::migrate_presentation_defaults(); } // File integrity now starts OFF (its checks fetch from WordPress.org, which round T21 // would not accept without an opt-in). A site that already had the plugin and never // saved the setting was running it, so it keeps it: the marker, not the setting itself, // records that, so a later save of either value still wins. New installs are stamped // at activation and skip this. if ( is_admin() && '1' !== get_option( 'csdt_integrity_default_migrated', '0' ) ) { if ( false === get_option( CSDT_Integrity::OPTION_ENABLED, false ) ) { update_option( CSDT_Integrity::OPTION_LEGACY_ON, '1', false ); } update_option( 'csdt_integrity_default_migrated', '1', false ); } // The Test Account Manager's own init hooks (roles, the login-security-option guard, // the auth-mode resolver) are registered by the cloudscale-test-accounts companion // plugin, not here, see docs/wporg-justifications.md. add_action( 'init', array( __CLASS__, 'register_block' ) ); add_action( 'init', array( __CLASS__, 'register_shortcode' ) ); add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_paste_guard_script' ) ); add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_convert_script' ) ); add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_replace_post_script' ) ); add_action( 'admin_menu', array( __CLASS__, 'add_tools_page' ) ); add_action( 'wp_dashboard_setup', array( __CLASS__, 'register_dashboard_widget' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_events_seen', array( __CLASS__, 'ajax_mark_events_seen' ) ); add_action( 'admin_init', array( __CLASS__, 'register_settings' ) ); // init, not admin_init: core 403s an unregistered ?page= slug before admin_init runs. // See redirect_legacy_slug() for the line numbers. add_action( 'admin_init', array( __CLASS__, 'redirect_legacy_slug' ), 1 ); add_action( 'admin_page_access_denied', array( __CLASS__, 'redirect_legacy_slug' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_assets' ) ); // Migration AJAX CSDT_Ajax::register( 'wp_ajax_csdt_devtools_migrate_scan', array( 'CSDT_Code_Migrator', 'ajax_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_migrate_preview', array( 'CSDT_Code_Migrator', 'ajax_preview' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_migrate_single', array( 'CSDT_Code_Migrator', 'ajax_migrate_single' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_migrate_all', array( 'CSDT_Code_Migrator', 'ajax_migrate_all' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_reset_theme_all', array( 'CSDT_Code_Migrator', 'ajax_reset_theme_all' ) ); // Managed AI (commercial pay-monthly tier), ajax + asset enqueue. CSDT_Managed_AI::register(); // SQL AJAX // Settings AJAX CSDT_Ajax::register( 'wp_ajax_csdt_devtools_save_theme_setting', array( 'CSDT_Code_Migrator', 'ajax_save_theme_setting' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_save_perf_monitor', array( 'CSDT_Code_Migrator', 'ajax_save_perf_monitor' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_object_cache_toggle', array( 'CSDT_Code_Migrator', 'ajax_object_cache_toggle' ) ); // Login security AJAX CSDT_Ajax::register( 'wp_ajax_csdt_devtools_login_save', array( 'CSDT_Login', 'ajax_login_save' ), array( 'refuse_test_session' => true ) ); // Backstop for every other path into a login-security option: WP-CLI, cron, REST, another // plugin. Priority 3 leaves room for the scoped-test-role veto at 1, so neither masks the // other. See CSDT_Login::gate_login_security_write(). add_filter( 'pre_update_option', array( 'CSDT_Login', 'gate_login_security_write' ), 3, 3 ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_bf_log_fetch', array( 'CSDT_Login', 'ajax_bf_log_fetch' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_ip_block', array( 'CSDT_Login', 'ajax_ip_block' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_ip_unblock', array( 'CSDT_Login', 'ajax_ip_unblock' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_download_dbip', array( 'CSDT_Geo', 'ajax_download_dbip' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_save_dbip_settings', array( 'CSDT_Geo', 'ajax_save_dbip_settings' ) ); self::cron_action( 'csdt_dbip_auto_update', array( 'CSDT_Geo', 'auto_update_run' ) ); // Cloudflare is contacted only after the administrator picks Cloudflare as the visitor // IP source. Until then the bundled ranges in ip_in_trusted_proxy_ranges() apply and no // request leaves the site. WordPress.org round T21 rejected the unconditional schedule. if ( 'cf' === get_option( 'csdt_devtools_ip_source', 'remote_addr' ) ) { if ( ! wp_next_scheduled( 'csdt_refresh_cf_ranges' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'weekly', 'csdt_refresh_cf_ranges' ); } } elseif ( wp_next_scheduled( 'csdt_refresh_cf_ranges' ) ) { wp_clear_scheduled_hook( 'csdt_refresh_cf_ranges' ); } if ( ! wp_next_scheduled( 'csdt_autoblock_prune' ) ) { wp_schedule_event( time() + 300, 'hourly', 'csdt_autoblock_prune' ); } if ( ! wp_next_scheduled( 'csdt_dbip_auto_update' ) ) { wp_schedule_event( time(), 'daily', 'csdt_dbip_auto_update' ); } if ( ! wp_next_scheduled( CSDT_Ip_Reputation::CRON_HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', CSDT_Ip_Reputation::CRON_HOOK ); } // Virtual patch feed. Scheduled always, like IP Reputation, and refresh() fetches // nothing while the mode is Off, which is the default. if ( ! wp_next_scheduled( CSDT_Virtual_Patch::CRON_HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', CSDT_Virtual_Patch::CRON_HOOK ); } // Daily CVE re-check, independent of any AI scan. Until 1.10.845 the feed was // consulted only when a deep scan ran, and the scheduled scan is off by // default, so a CVE published the day after a scan was invisible until the // next button press. See CSDT_Vuln_Recheck. self::cron_action( CSDT_Vuln_Recheck::CRON_HOOK, array( 'CSDT_Vuln_Recheck', 'cron_run' ) ); CSDT_Vuln_Recheck::sync_schedule(); // The vulnerable-component policy acts on what that run just stored. Same hook, same // priority, registered second, so WordPress runs it after the re-check. See // CSDT_Vuln_Autopatch. self::cron_action( CSDT_Vuln_Recheck::CRON_HOOK, array( 'CSDT_Vuln_Autopatch', 'cron_run' ) ); // wp-config.php, .htaccess, mu-plugins and drop-ins, hashed every five minutes. // File integrity covers core, plugins and themes against upstream checksums; // these files have no upstream and were watched by nothing. See // CSDT_Persistence_Watch. Scheduled further down, AFTER the cron_schedules // filter is registered: csdt_every_5min is this plugin's own interval, and // WordPress refuses to schedule on an interval it has not been told about yet. // Live for one release with the schedule call here, and it was never armed. self::cron_action( CSDT_Persistence_Watch::CRON_HOOK, array( 'CSDT_Persistence_Watch', 'cron_run' ) ); // CSDT_Host_Alarms, same arrangement: csdt_every_1min is this plugin's own interval. self::cron_action( CSDT_Host_Alarms::CRON_HOOK, array( 'CSDT_Host_Alarms', 'cron_run' ) ); // CSDT_Tls_Expiry: the daily certificate read, core's own 'daily' interval. self::cron_action( CSDT_Tls_Expiry::CRON_HOOK, array( 'CSDT_Tls_Expiry', 'cron_run' ) ); // CSDT_Php_Advisory: an hourly tick that fetches every 6 hours, hourly while a PHP // security release is announced and not yet out. The web server's PHP version is // recorded on an admin page load (admin_init, no arguments), never from CLI or cron. self::cron_action( CSDT_Php_Advisory::CRON_HOOK, array( 'CSDT_Php_Advisory', 'cron_run' ) ); add_action( 'admin_init', array( 'CSDT_Php_Advisory', 'observe_runtime' ), 20, 0 ); // CSDT_Upgrade_Verify: a five-minute fingerprint tick, and one verification booked two // minutes after a change. The upgrader hook books one too; accepted_args 0, the callback // takes nothing. Scheduled further down, after the csdt_every_5min interval exists. self::cron_action( CSDT_Upgrade_Verify::CRON_HOOK, array( 'CSDT_Upgrade_Verify', 'tick' ) ); self::cron_action( CSDT_Upgrade_Verify::RUN_HOOK, array( 'CSDT_Upgrade_Verify', 'run_scheduled' ) ); add_action( 'upgrader_process_complete', array( 'CSDT_Upgrade_Verify', 'on_upgrade' ), 30, 0 ); // CSDT_Edge_Waf: the five-minute Cloudflare reconcile (booked only while not Off), the // admin-login address hash and its AJAX actions. See docs/plan-edge-waf-push.md. self::cron_action( CSDT_Edge_Waf::CRON_HOOK, array( 'CSDT_Edge_Waf', 'cron_run' ) ); CSDT_Edge_Waf::register(); // CSDT_Edge_Gateway: the two signed edge routes, the raw sender that keeps their bytes // as signed, the admin-login address hash, and the bypass sampler, which is hooked only // while a gateway is paired. No cron. See cloudscale-edge-gateway/docs/CONTRACT.md. CSDT_Edge_Gateway::register(); CSDT_Edge_Gateway_Panel::register(); // CSDT_Lockdown: the emergency lockdown rules (each a no-op until engaged, one autoloaded // option read), its banner, engage link and AJAX. No cron. See docs/plan-emergency-lockdown.md. CSDT_Lockdown::register(); // The "new CVEs are not being checked" notice and its one-click enables. See CSDT_Cve_Checks. CSDT_Cve_Checks::register(); // Critical PHP flaws (php2shell): red notice, audit finding, one email. Built-in list, // nothing fetched, on with every opt-in switch off. See CSDT_Php_Critical. CSDT_Php_Critical::register(); // Live Traffic: one row per request that reached PHP, written on `shutdown` so the status // is final and the visitor already has the response. Registered here, at file load and // ahead of every blocker below, because a refusal exits during plugins_loaded and a hook // added later would never see the blocked requests the feed most needs to show. // `shutdown` still fires after exit(): core runs it from register_shutdown_function(). // Off by default; while off this is one autoloaded option read. See CSDT_Live_Traffic. add_action( 'shutdown', array( 'CSDT_Live_Traffic', 'record' ), 99 ); // plugins_loaded, not parse_request: parse_request never fires for // wp-login.php or xmlrpc.php, and the callback's old is_admin() guard // skipped admin-ajax.php and admin-post.php, so the blocklist covered // only front-end pages and REST. See CSDT_Login::enforce_ip_blocklist(). // Priority 0, ahead of every blocker below: a request the site made to itself teaches // it its own address BEFORE anything decides whether to refuse that address. add_action( 'plugins_loaded', array( 'CSDT_Auto_Block', 'observe_self_request' ), 0 ); add_filter( 'http_request_args', array( 'CSDT_Auto_Block', 'sign_self_request' ), 10, 2 ); add_action( 'plugins_loaded', array( 'CSDT_Login', 'enforce_ip_blocklist' ), 1 ); // Country rules run after the IP blocklist: the more specific decision // should win, and an IP block costs one autoloaded read to evaluate. add_action( 'plugins_loaded', array( 'CSDT_Geo_Block', 'enforce' ), 2 ); // Priority 3: after the blocklist (1) and country rules (2). Those are // cheaper and more specific, so an address already blocked never pays for // pattern matching. With no rules loaded this costs one autoloaded read. add_action( 'plugins_loaded', array( 'CSDT_Firewall', 'enforce' ), 3 ); // Beside the firewall: a visitor claiming to be a search crawler is judged on a // cached reverse-DNS verdict only; the lookup itself runs after the response. add_action( 'plugins_loaded', array( 'CSDT_Crawler_Verify', 'enforce' ), 3 ); // Per-CVE virtual patches; two autoloaded reads when none applies. CSDT_Virtual_Patch. add_action( 'plugins_loaded', array( 'CSDT_Virtual_Patch', 'enforce' ), 3 ); // Priority 4: volume, after shape. An address the firewall has already refused // never pays for a counter, and a request the limiter refuses has already been // through every cheaper, more specific check. Counts in the persistent object // cache only; inert and says so without one. See CSDT_Request_Limiter. add_action( 'plugins_loaded', array( 'CSDT_Request_Limiter', 'enforce' ), 4 ); // Priority 4: after everything specific to THIS site. A crowd-sourced // reputation list is the least site-tuned decision in the chain, so it // goes last, nothing pays for a CIDR walk on a request something // cheaper already decided about. add_action( 'plugins_loaded', array( 'CSDT_Ip_Reputation', 'enforce' ), 4 ); // Priority 5: appended to the end of an established chain rather than // inserted into it. A shield is the narrowest decision of the five, since // it applies to one named component's endpoints rather than to the request // as a whole. Early enough regardless: plugin FILES are loaded by now, but // an ajax action is not dispatched until admin-ajax.php fires // wp_ajax_nopriv_, and a REST route not until the server matches // it, so the vulnerable handler never runs. add_action( 'plugins_loaded', array( 'CSDT_Shield', 'enforce' ), 5 ); // Second gate for REST, once the server has parsed the route for real, so a // routing arrangement producing neither of the two URL shapes enforce() // knows about cannot slip past. One array lookup, on REST requests only. add_filter( 'rest_pre_dispatch', array( 'CSDT_Shield', 'rest_gate' ), 5, 3 ); // A signed-in visitor's page must not be stored as public. WordPress sends no // Cache-Control on a front-end render, so a proxy applying a public default // when the origin is silent hands the browser a 24-hour copy of a personalised // page. send_headers runs late enough for is_user_logged_in() to be reliable. add_action( 'send_headers', array( 'CSDT_Security_Headers', 'no_store_for_logged_in' ), 99 ); CSDT_Ajax::register( 'wp_ajax_csdt_ssh_monitor_save', array( 'CSDT_Monitor', 'ajax_ssh_monitor_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_ssh_log_clear', array( 'CSDT_Monitor', 'ajax_ssh_log_clear' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_ssh_fix_permissions', array( 'CSDT_Monitor', 'ajax_ssh_fix_permissions' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_csp_domain_hunt', array( __CLASS__, 'ajax_csp_domain_hunt' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_bf_self_test', array( 'CSDT_Threat_Monitor', 'ajax_bf_self_test' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_totp_setup_start', array( 'CSDT_Login', 'ajax_totp_setup_start' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_totp_setup_verify', array( 'CSDT_Login', 'ajax_totp_setup_verify' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_2fa_disable', array( 'CSDT_Login', 'ajax_2fa_disable' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_email_2fa_enable', array( 'CSDT_Login', 'ajax_email_2fa_enable' ) ); add_action( 'admin_init', array( 'CSDT_Login', 'email_2fa_confirm_check' ) ); add_action( 'after_password_reset', array( 'CSDT_Login', 'on_password_reset' ), 10, 1 ); add_action( 'profile_update', array( 'CSDT_Login', 'on_profile_update' ), 10, 2 ); CSDT_DevTools_Passkey::register_hooks(); // Default Featured Image CSDT_Ajax::register( 'wp_ajax_csdt_save_default_image', array( 'CSDT_Thumbnails', 'ajax_save_default_image' ) ); add_filter( 'post_thumbnail_html', array( 'CSDT_Thumbnails', 'default_image_html' ), 10, 5 ); add_filter( 'has_post_thumbnail', array( 'CSDT_Thumbnails', 'default_image_has_thumbnail' ), 10, 3 ); // Hero image: swap to 1200Γ—630 social format on single post pages. add_filter( 'post_thumbnail_html', array( 'CSDT_Thumbnails', 'hero_image_html' ), 11, 5 ); add_action( 'wp_enqueue_scripts', array( 'CSDT_Thumbnails', 'enqueue_hero_styles' ) ); // Admin-only Generate Featured Image button (injected via footer, positioned by JS). add_action( 'wp_footer', array( 'CSDT_Thumbnails', 'inject_gen_image_button' ), 5 ); add_action( 'wp_enqueue_scripts', array( 'CSDT_Thumbnails', 'enqueue_frontend_admin_scripts' ) ); // The same generator, offered where you actually notice a post has no image: inside the // block editor's "Set featured image" box. add_action( 'enqueue_block_editor_assets', array( 'CSDT_Thumbnails', 'enqueue_editor_assets' ) ); // Thumbnails / Social Preview AJAX CSDT_Ajax::register( 'wp_ajax_csdt_devtools_social_check_url', array( 'CSDT_Thumbnails', 'ajax_social_check_url' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_social_scan_posts', array( 'CSDT_Thumbnails', 'ajax_social_scan_posts' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_social_scan_media', array( 'CSDT_Thumbnails', 'ajax_social_scan_media' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_social_fix_image', array( 'CSDT_Thumbnails', 'ajax_social_fix_image' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_social_generate_formats', array( 'CSDT_Thumbnails', 'ajax_social_generate_formats' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_social_platform_save', array( 'CSDT_Thumbnails', 'ajax_social_platform_save' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_social_fix_all_batch', array( 'CSDT_Thumbnails', 'ajax_social_fix_all_batch' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_social_refresh_stale_batch', array( 'CSDT_Thumbnails', 'ajax_social_refresh_stale_batch' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_social_diagnose_formats', array( 'CSDT_Thumbnails', 'ajax_social_diagnose_formats' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_regen_thumb_scan', array( 'CSDT_Thumbnails', 'ajax_regen_thumb_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_regen_thumb_batch', array( 'CSDT_Thumbnails', 'ajax_regen_thumb_batch' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_get_post_content', array( 'CSDT_Thumbnails', 'ajax_get_post_content' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_list_revisions', array( 'CSDT_Thumbnails', 'ajax_list_revisions' ) ); // Read a version before overwriting the post with it. Same capability checks as // the restore below; see the handler for why the parent check is load-bearing. CSDT_Ajax::register( 'wp_ajax_csdt_preview_revision', array( 'CSDT_Thumbnails', 'ajax_preview_revision' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_restore_revision', array( 'CSDT_Thumbnails', 'ajax_restore_revision' ) ); add_action( 'save_post_post', array( 'CSDT_Thumbnails', 'on_post_saved' ), 100, 3 ); // Catches the "publish first, add image later" workflow where set_post_thumbnail() // fires after save_post and on_post_saved() misses the new thumbnail ID. add_action( 'added_post_meta', array( 'CSDT_Thumbnails', 'on_thumbnail_meta_updated' ), 10, 4 ); add_action( 'updated_post_meta', array( 'CSDT_Thumbnails', 'on_thumbnail_meta_updated' ), 10, 4 ); add_action( 'transition_post_status', array( 'CSDT_Thumbnails', 'on_post_status_change' ), 10, 3 ); add_action( 'admin_notices', array( 'CSDT_Thumbnails', 'social_format_admin_notice' ) ); // Serve platform-specific og:image based on crawler User-Agent. add_action( 'wp_head', array( 'CSDT_Thumbnails', 'output_crawler_og_image' ), 1 ); // Only when the Site Audit quick-fix enabled it, its undo ("Removes the // deduplication patch") deletes this option, so the hooks must honour it // or the guard is injected on every site forever regardless of the toggle. if ( get_option( 'csdt_ads_dedup', '0' ) === '1' ) { add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_ads_dedup_script' ), 0 ); // WordPress 6.9+ only, and attaching this is what makes core start the buffer, see // ads_dedup_filter_html(). Below 6.9 the server-side strip is absent; the guard // script enqueued above still wraps adsbygoogle.push and drops the // duplicate client-side, so the fix degrades rather than disappearing. No // self-managed buffer as a fallback: that is the rejected finding. if ( function_exists( 'wp_start_template_enhancement_output_buffer' ) ) { add_filter( 'wp_template_enhancement_output_buffer', array( __CLASS__, 'ads_dedup_filter_html' ) ); } } if ( CSDT_Options::get( 'csdt_crossorigin_scripts' ) === '1' ) { add_filter( 'script_loader_tag', array( __CLASS__, 'add_crossorigin_to_external_scripts' ), 10, 3 ); } CSDT_Ajax::register( 'wp_ajax_csdt_devtools_social_cf_test', array( 'CSDT_Thumbnails', 'ajax_social_cf_test' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_cf_purge', array( 'CSDT_Thumbnails', 'ajax_cf_purge' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_cf_check', array( 'CSDT_Thumbnails', 'ajax_cf_check' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_cf_save', array( 'CSDT_Thumbnails', 'ajax_cf_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_save_key', array( 'CSDT_Thumbnails', 'ajax_ai_image_save_key' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_test_key', array( 'CSDT_Thumbnails', 'ajax_ai_image_test_key' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_scan', array( 'CSDT_Thumbnails', 'ajax_ai_image_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_write_prompt', array( 'CSDT_Thumbnails', 'ajax_ai_image_write_prompt' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_generate', array( 'CSDT_Thumbnails', 'ajax_ai_image_generate' ) ); // Authenticated only. The generation loopback (CSDT_Thumbnails) carries the // admin's own cookies, so no nopriv registration is needed; registering nopriv // would leave a paid-AI endpoint fronted only by an internal capability check. CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_process', array( 'CSDT_Thumbnails', 'ajax_ai_image_process' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_poll', array( 'CSDT_Thumbnails', 'ajax_ai_image_poll' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_pending', array( 'CSDT_Thumbnails', 'ajax_ai_image_pending' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_abort', array( 'CSDT_Thumbnails', 'ajax_ai_image_abort' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_pick', array( 'CSDT_Thumbnails', 'ajax_ai_image_pick' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_discard', array( 'CSDT_Thumbnails', 'ajax_ai_image_discard' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_save_sysprompt', array( 'CSDT_Thumbnails', 'ajax_ai_image_save_sysprompt' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_ai_image_save_settings', array( 'CSDT_Thumbnails', 'ajax_ai_image_save_settings' ) ); // SMTP AJAX CSDT_Ajax::register( 'wp_ajax_csdt_devtools_smtp_save', array( 'CSDT_SMTP', 'ajax_smtp_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_smtp_test', array( 'CSDT_SMTP', 'ajax_smtp_test' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_smtp_log_clear', array( 'CSDT_SMTP', 'ajax_smtp_log_clear' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_smtp_log_toggle', array( 'CSDT_SMTP', 'ajax_smtp_log_toggle' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_smtp_log_fetch', array( 'CSDT_SMTP', 'ajax_smtp_log_fetch' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_smtp_log_view', array( 'CSDT_SMTP', 'ajax_smtp_log_view' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_notify_channels_save', array( 'CSDT_SMTP', 'ajax_notify_channels_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_vuln_scan', array( 'CSDT_Vuln_Scan', 'ajax_vuln_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_deep_scan', array( 'CSDT_Site_Audit', 'ajax_deep_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_scan_status', array( 'CSDT_Site_Audit', 'ajax_scan_status' ) ); CSDT_Auto_Block::register(); CSDT_Nuclei::register(); CSDT_Remediation::register(); CSDT_Remediation_Plan::register(); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_cancel_scan', array( 'CSDT_Site_Audit', 'ajax_cancel_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_vuln_save_key', array( 'CSDT_Vuln_Scan', 'ajax_vuln_save_key' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_security_test_key', array( 'CSDT_Vuln_Scan', 'ajax_security_test_key' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_server_logs_status', array( __CLASS__, 'ajax_server_logs_status' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_server_logs_fetch', array( __CLASS__, 'ajax_server_logs_fetch' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_logs_setup_php', array( __CLASS__, 'ajax_logs_setup_php' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_logs_custom_save', array( __CLASS__, 'ajax_logs_custom_save' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_scan_history', array( 'CSDT_Site_Audit', 'ajax_scan_history' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_save_schedule', array( 'CSDT_Site_Audit', 'ajax_save_schedule' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_dir_probe_save', array( 'CSDT_Dir_Probe', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_host_software_check', array( 'CSDT_Host_Software', 'ajax_check' ) ); // PHP Advisory Alerts. Check now only reads the feed and sends what is due, once, // like the IP reputation Refresh, so a test session may call it. The save can switch // the alerts off, so a minted test session is refused it. CSDT_Ajax::register( 'wp_ajax_csdt_php_advisory_check', array( 'CSDT_Php_Advisory', 'ajax_check' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_php_advisory_save', array( 'CSDT_Php_Advisory', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_save_notify', array( 'CSDT_Site_Audit', 'ajax_save_notify' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_save_alerts', array( __CLASS__, 'ajax_save_alerts' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_sec_headers_restore', array( 'CSDT_Security_Headers', 'ajax_sec_headers_restore' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_quick_fix', array( 'CSDT_Site_Audit', 'ajax_apply_quick_fix' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_db_orphaned_scan', array( 'CSDT_Optimizer', 'ajax_db_orphaned_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_db_identify_table', array( 'CSDT_Optimizer', 'ajax_db_identify_table' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_db_archive_tables', array( 'CSDT_Optimizer', 'ajax_db_archive_tables' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_db_trash_scan', array( 'CSDT_Optimizer', 'ajax_db_trash_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_db_restore_tables', array( 'CSDT_Optimizer', 'ajax_db_restore_tables' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_db_drop_tables', array( 'CSDT_Optimizer', 'ajax_db_drop_tables' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_sec_headers_save', array( 'CSDT_Security_Headers', 'ajax_sec_headers_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_csp_save', array( 'CSDT_CSP', 'ajax_csp_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_csp_rollback', array( 'CSDT_CSP', 'ajax_csp_rollback' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_csp_restore', array( 'CSDT_CSP', 'ajax_csp_restore' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_scan_headers', array( 'CSDT_Security_Headers', 'ajax_scan_headers' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_scan_history_item', array( 'CSDT_Security_Headers', 'ajax_scan_history_item' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_csp_violations_get', array( 'CSDT_CSP', 'ajax_csp_violations_get' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_csp_violations_clear', array( 'CSDT_CSP', 'ajax_csp_violations_clear' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_csp_fixes_get', array( 'CSDT_CSP', 'ajax_csp_fixes_get' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_csp_fixes_clear', array( 'CSDT_CSP', 'ajax_csp_fixes_clear' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_csp_apply_fix', array( 'CSDT_CSP', 'ajax_csp_apply_fix' ), array( 'refuse_test_session' => true ) ); add_action( 'send_headers', array( 'CSDT_CSP', 'output_security_headers' ) ); // Every CSDT_Test_Accounts AJAX/cron/REST/filter hook (test accounts, Playwright // roles, CI tokens, app-password blocking) is registered by the cloudscale-test- // accounts companion plugin, not here. CSDT_Ajax::register( 'wp_ajax_csdt_save_ip_source', array( __CLASS__, 'ajax_save_ip_source' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_rename_wp_user', array( __CLASS__, 'ajax_rename_wp_user' ) ); // Keep Cloudflare's trusted-proxy ranges current; a stale list silently // stops real visitor IPs resolving on proxied sites. self::cron_action( 'csdt_refresh_cf_ranges', array( __CLASS__, 'refresh_cf_ip_ranges' ) ); self::cron_action( 'csdt_autoblock_prune', array( 'CSDT_Auto_Block', 'prune' ) ); // A shield whose component has been updated, deactivated or removed is pure // breakage, so it lifts itself. Hung off the existing hourly security cron // rather than a new schedule: one more schedule is one more thing that can // stop firing, and an hour is a fine resolution for "you updated the plugin". self::cron_action( 'csdt_autoblock_prune', array( 'CSDT_Shield', 'maybe_lift_all' ) ); // Watches for silence: controls switched on that are demonstrably doing // nothing. Hung off the existing security cron rather than a new schedule, // because a liveness check on a schedule that never fires is the joke that // writes itself. self::cron_action( 'csdt_threat_monitor', array( 'CSDT_Control_Liveness', 'run' ) ); self::cron_action( 'csdt_scheduled_scan', array( 'CSDT_Site_Audit', 'run_scheduled_scan' ) ); /* * A copy must not run the scheduled audit. It spends AI credit, and it then * emails a report titled with get_bloginfo('name') -- which on a restored * standby is the PRIMARY'S name, so the finding reads as being about the live * site. The heartbeat above is refused at execution; this one is disarmed, * because an inherited schedule sitting armed on a copy is something somebody * has to reason about every time they look at `wp cron event list`. * * csdt_threat_monitor joins the list for the same reason, plus one the owner * met on 2026-09-19: it drives BOTH the threat monitor (file integrity, probe * detection, new-admin) and the control-liveness check. On a standby the other * security crons here are deliberately disarmed, so liveness would find them * inert and email "3 controls are switched on but not working" -- naming the * primary's site, about a machine behaving exactly as designed. A standby is * silent about its own inert controls because its controls are SUPPOSED to be * inert; it must not act and must not alert. CSDT_Control_Liveness::run() also * refuses to alert on a standby, so a re-armed hook cannot leak one either. */ add_action( 'init', static function (): void { if ( class_exists( 'CSDT_Site_Role' ) ) { CSDT_Site_Role::disarm( array( 'csdt_scheduled_scan', 'csdt_uptime_heartbeat', 'cloudscale_hb_fallback', 'csdt_threat_monitor', CSDT_Vuln_Recheck::CRON_HOOK, CSDT_Persistence_Watch::CRON_HOOK, CSDT_Host_Alarms::CRON_HOOK, CSDT_Tls_Expiry::CRON_HOOK, CSDT_Php_Advisory::CRON_HOOK, 'csdt_upgrade_verify_tick', 'csdt_upgrade_verify_run', CSDT_Dir_Probe::CRON_HOOK, CSDT_Dir_Probe::CRON_HOOK . '_continue', CSDT_Edge_Waf::CRON_HOOK ), 'csdt' ); } // The other direction: a standby promoted to primary books every one of them again, // through each feature's own sync, whatever did the promotion. See CSDT_Standby_Rearm. if ( class_exists( 'CSDT_Standby_Rearm' ) ) { CSDT_Standby_Rearm::maybe_rearm(); } }, 7 ); self::cron_action( 'csdt_refresh_home_html', array( 'CSDT_Site_Audit', 'refresh_home_html_cache' ) ); self::cron_action( 'csdt_ssh_monitor', array( 'CSDT_Monitor', 'monitor_ssh_failures' ) ); self::cron_action( 'csdt_php_error_monitor', array( 'CSDT_Monitor', 'monitor_php_errors' ) ); // Unmissable "this is a standby, not the live site" banner, see // class-standby-banner.php. Registered unconditionally (cheap: it checks // a host file and bails) rather than gated on is_admin(), because the // incident this exists for happened on the public-facing editor too. add_action( 'wp_enqueue_scripts', array( 'CSDT_Standby_Banner', 'enqueue' ) ); add_action( 'admin_enqueue_scripts', array( 'CSDT_Standby_Banner', 'enqueue' ) ); add_action( 'login_enqueue_scripts', array( 'CSDT_Standby_Banner', 'enqueue' ) ); add_filter( 'document_title_parts', array( 'CSDT_Standby_Banner', 'title_prefix' ) ); add_filter( 'admin_title', array( 'CSDT_Standby_Banner', 'title_prefix' ) ); add_filter( 'login_title', array( 'CSDT_Standby_Banner', 'title_prefix' ) ); // Feeds the DNS-failover Worker's staleness signal, see class-uptime-heartbeat.php. add_filter( 'cron_schedules', array( 'CSDT_Uptime_Heartbeat', 'add_cron_schedules' ) ); self::cron_action( 'csdt_uptime_heartbeat', array( 'CSDT_Uptime_Heartbeat', 'push_heartbeat' ) ); // Scheduled while on with an endpoint, removed when switched off from the panel. CSDT_Uptime_Heartbeat::sync_schedule(); // Out of Band Heartbeat panel. A test beat changes nothing, so a test session may send // one; saving, and writing or deleting the mu-plugin loader, are refused to it. CSDT_Ajax::register( 'wp_ajax_csdt_oob_heartbeat_save', array( 'CSDT_Uptime_Heartbeat', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_oob_heartbeat_test', array( 'CSDT_Uptime_Heartbeat', 'ajax_test' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_oob_heartbeat_install', array( 'CSDT_Uptime_Heartbeat', 'ajax_install_loader' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_oob_heartbeat_remove', array( 'CSDT_Uptime_Heartbeat', 'ajax_remove_loader' ), array( 'refuse_test_session' => true ) ); // Upgrade Verification panel. Verify now only reads the site (it adds a run to the // history), so a test session may run it; saving the settings is refused to it. CSDT_Ajax::register( 'wp_ajax_csdt_upgrade_verify_now', array( 'CSDT_Upgrade_Verify', 'ajax_verify' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_upgrade_verify_save', array( 'CSDT_Upgrade_Verify', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_php_error_monitor_save', array( 'CSDT_Monitor', 'ajax_php_error_monitor_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_fpm_monitor_save', array( 'CSDT_Monitor', 'ajax_fpm_monitor_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_fpm_worker_status', array( 'CSDT_Monitor', 'ajax_fpm_worker_status' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_fpm_setup_detect', array( 'CSDT_Monitor', 'ajax_fpm_setup_detect' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_fpm_setup_patch', array( 'CSDT_Monitor', 'ajax_fpm_setup_patch' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_fpm_worker_detail', array( 'CSDT_Monitor', 'ajax_fpm_worker_detail' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_opcache_stats', array( 'CSDT_Monitor', 'ajax_opcache_stats' ) ); // Admin-only, and the only way to purge. The nopriv registrations these used to have // accepted a stored token in place of a nonce, which is the "bypassable condition" // shape WordPress.org names; the front-end GET endpoint that replaced them was the // same trade in a new place and nothing in the plugin ever called it, so it is gone // too. A purge needs an administrator session and a nonce, like every other control. CSDT_Ajax::register( 'wp_ajax_csdt_opcache_flush', array( 'CSDT_Monitor', 'ajax_opcache_flush' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_lscache_purge', array( 'CSDT_Monitor', 'ajax_lscache_purge' ) ); // FPM report uses the REST endpoint csdt/v1/fpm-report (CSDT_Monitor::rest_fpm_report). self::cron_action( 'csdt_threat_monitor', array( 'CSDT_Threat_Monitor', 'monitor_threats' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_threat_monitor_save', array( 'CSDT_Threat_Monitor', 'ajax_threat_monitor_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_threat_integrity_reset', array( 'CSDT_Threat_Monitor', 'ajax_threat_integrity_reset' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_integrity_scan', array( 'CSDT_Integrity', 'ajax_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_db_malware_scan', array( 'CSDT_DB_Malware', 'ajax_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_integrity_settings_save', array( 'CSDT_Integrity', 'ajax_settings_save' ), array( 'refuse_test_session' => true ) ); // Its own schedule, because the frequency is now the administrator's choice and the // threat-monitor tick is fixed at five minutes, it cannot express 1 minute, hourly or // daily. The continuation hook finishes a part-done pass promptly whatever the interval. self::cron_action( CSDT_Integrity::CRON_HOOK, array( 'CSDT_Integrity', 'cron_run' ) ); self::cron_action( CSDT_Integrity::CRON_HOOK . '_continue', array( 'CSDT_Integrity', 'cron_run' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_integrity_theme_snapshot', array( 'CSDT_Integrity', 'ajax_theme_snapshot' ) ); // Repair and quarantine change files on disk, so a minted test session may not use them. CSDT_Ajax::register( 'wp_ajax_csdt_integrity_repair', array( 'CSDT_Integrity_Repair', 'ajax_repair' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_integrity_quarantine', array( 'CSDT_Integrity_Repair', 'ajax_quarantine' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_integrity_restore', array( 'CSDT_Integrity_Repair', 'ajax_restore' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_integrity_quarantine_delete', array( 'CSDT_Integrity_Repair', 'ajax_delete' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_activity_save', array( 'CSDT_Activity_Log', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_activity_export', array( 'CSDT_Activity_Log', 'ajax_export' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_enum_guard_save', array( 'CSDT_Enum_Guard', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_comment_guard_save', array( 'CSDT_Comment_Guard', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_comment_flood_save', array( 'CSDT_Comment_Flood', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_host_alarms_save', array( 'CSDT_Host_Alarms', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_tls_expiry_save', array( 'CSDT_Tls_Expiry', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_cf_comment_rule_add', array( 'CSDT_Comment_Flood', 'ajax_cf_add' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_cf_comment_rule_remove', array( 'CSDT_Comment_Flood', 'ajax_cf_remove' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_cf_comment_rule_status', array( 'CSDT_Comment_Flood', 'ajax_cf_status' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_weekly_report_save', array( 'CSDT_Security_Report', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_weekly_report_test', array( 'CSDT_Security_Report', 'ajax_test' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_webhook_save', array( 'CSDT_Webhook', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_webhook_test', array( 'CSDT_Webhook', 'ajax_test' ), array( 'refuse_test_session' => true ) ); self::cron_action( CSDT_Security_Report::CRON_HOOK, array( 'CSDT_Security_Report', 'cron_run' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_password_policy_save', array( 'CSDT_Password_Policy', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_geoblock_save', array( 'CSDT_Geo_Block', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_firewall_save', array( 'CSDT_Firewall', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_crawler_verify_save', array( 'CSDT_Crawler_Verify', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_vpatch_save', array( 'CSDT_Virtual_Patch', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_vpatch_refresh', array( 'CSDT_Virtual_Patch', 'ajax_refresh' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_live_traffic_save', array( 'CSDT_Live_Traffic', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_live_traffic_poll', array( 'CSDT_Live_Traffic', 'ajax_poll' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_ip_reputation_save', array( 'CSDT_Ip_Reputation', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_vuln_recheck_lookups_save', array( 'CSDT_Vuln_Recheck', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_vuln_autopatch_save', array( 'CSDT_Vuln_Autopatch', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_vuln_autopatch_update_now', array( 'CSDT_Vuln_Autopatch', 'ajax_update_now' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_ratelimit_save', array( 'CSDT_Request_Limiter', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_ip_reputation_refresh', array( 'CSDT_Ip_Reputation', 'ajax_refresh' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_shield_save', array( 'CSDT_Shield', 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_shield_enumerate', array( 'CSDT_Shield', 'ajax_enumerate' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_shield_test', array( 'CSDT_Shield', 'ajax_test' ) ); // Keeps the cached CIDR list current; a stale or never-fetched list is caught // by the panel itself (it says "no list has been fetched yet") rather than by // this cron silently doing nothing forever if the schedule is ever missed. self::cron_action( CSDT_Ip_Reputation::CRON_HOOK, array( 'CSDT_Ip_Reputation', 'refresh' ) ); self::cron_action( CSDT_Virtual_Patch::CRON_HOOK, array( 'CSDT_Virtual_Patch', 'refresh' ) ); // What applies depends on what is installed, so it is recomputed when that changes. // accepted_args 0: recompute() takes nothing, whatever each action passes. add_action( 'upgrader_process_complete', array( 'CSDT_Virtual_Patch', 'recompute' ), 20, 0 ); add_action( 'activated_plugin', array( 'CSDT_Virtual_Patch', 'recompute' ), 20, 0 ); add_action( 'deactivated_plugin', array( 'CSDT_Virtual_Patch', 'recompute' ), 20, 0 ); add_action( 'switch_theme', array( 'CSDT_Virtual_Patch', 'recompute' ), 20, 0 ); // 404 counting for the firewall's missing-page threshold. template_redirect, because // that is where WordPress has settled that the request is a 404 and nothing has been // sent yet. Priority 99 so a plugin that turns a 404 into something else has already // had its say. add_action( 'template_redirect', array( 'CSDT_Firewall', 'count_404' ), 99 ); // Telegram alert log. Registered unconditionally, not behind an is_admin() or a // settings check: alerts are sent from cron and from front-end requests, which is // exactly where the interesting failures happen. The capture costs one strpos() per // outbound HTTP request. CSDT_Alert_Log::init(); // Every alert the log captures also becomes a row under Recent Security Events. CSDT_Alert_Events::init(); // Activity log. Registered unconditionally and gated at write time: deciding at // registration would mean a setting change mid-request installs nothing, which is // exactly how the 2FA write-veto came to be absent on the one request that mattered. CSDT_Activity_Log::init(); // Account-name disclosure guards. Registered unconditionally and gated at call time, // because every one of them has to decide per request whether the caller is signed in. CSDT_Enum_Guard::init(); // Comment screening: bots to spam, payloads held. Gated at call time, like the // enum guard, because each hook decides per request who is commenting. CSDT_Comment_Guard::init(); CSDT_Comment_Flood::init(); // Comments are stored under the resolved visitor address, not the proxy's. Not // part of the comment guard: it follows the Visitor IP Source setting whether or // not screening is on, and is a no-op while that setting is REMOTE_ADDR. add_filter( 'pre_comment_user_ip', array( __CLASS__, 'comment_author_ip' ) ); // Weekly report, outbound webhook, Site Health tests. Read-only except for the // report's own daily tick; the webhook listens on CSDT_Alert_Log's capture. CSDT_Security_Report::init(); CSDT_Webhook::init(); CSDT_Site_Health::init(); CSDT_CF_Health::init(); // Vulnerable-component policy: core auto-update filters and the update-result hook. CSDT_Vuln_Autopatch::init(); // Breached-password check, minimum length, 2FA by role, and self-service enrolment on // the user's own profile. Gated at call time, like the two guards above. CSDT_Password_Policy::init(); add_action( 'user_register', array( 'CSDT_Threat_Monitor', 'on_user_registered' ) ); add_action( 'set_user_role', array( 'CSDT_Threat_Monitor', 'on_set_user_role' ), 10, 3 ); // The other two ways an account becomes an administrator, neither of which // fires set_user_role: add_role() and a direct write to the capabilities meta. add_action( 'add_user_role', array( 'CSDT_Threat_Monitor', 'on_add_user_role' ), 10, 2 ); add_action( 'updated_user_meta', array( 'CSDT_Threat_Monitor', 'on_capabilities_meta' ), 10, 4 ); add_action( 'added_user_meta', array( 'CSDT_Threat_Monitor', 'on_capabilities_meta' ), 10, 4 ); // A second credential that bypasses the login page and every second factor. add_action( 'wp_create_application_password', array( 'CSDT_Threat_Monitor', 'on_application_password_created' ), 10, 2 ); add_filter( 'cron_schedules', array( 'CSDT_Site_Audit', 'add_cron_schedules' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_plugin_stack_scan', array( 'CSDT_Optimizer', 'ajax_plugin_stack_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_site_audit', array( 'CSDT_Site_Audit', 'ajax_site_audit' ) ); // Re-check one finding without re-running the whole audit. Shares the audit // nonce because it is the same panel and the same capability. CSDT_Ajax::register( 'wp_ajax_csdt_verify_finding', array( 'CSDT_Site_Audit', 'ajax_verify_finding' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_update_risk_scan', array( 'CSDT_Optimizer', 'ajax_update_risk_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_update_risk_assess', array( 'CSDT_Optimizer', 'ajax_update_risk_assess' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_db_intelligence_scan', array( 'CSDT_Optimizer', 'ajax_db_intelligence_scan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_db_intelligence_fix', array( 'CSDT_Optimizer', 'ajax_db_intelligence_fix' ) ); // CSP nonce injection, only active when nonce mode is enabled. // Note: nonces are added to script elements only. Stylesheet link // elements are deliberately NOT nonced because style-src uses 'self' + // 'unsafe-inline' (no nonce-source listed), so a nonce attribute on a // stylesheet is purely decorative, and iOS WKWebView (LinkedIn's // in-app browser) mis-handles it, blocking the theme CSS entirely. if ( ! is_admin() && CSDT_Options::get( 'csdt_csp_nonces_enabled' ) === '1' ) { add_filter( 'script_loader_tag', array( 'CSDT_CSP', 'csp_nonce_script_tag' ), 10, 1 ); // WP 6.3+ inline script attributes filter add_filter( 'wp_inline_script_attributes', array( 'CSDT_CSP', 'csp_nonce_inline_attrs' ), 10, 1 ); // Scripts that bypass wp_enqueue (AdSense, theme inline scripts) are reached through // core's template enhancement buffer. Attaching this callback is what makes core // START that buffer, so the plugin opens none of its own, the previous // open-here/close-on-shutdown pair was rejected by WordPress.org on 18 Aug 2026. // // WordPress 6.9+ only. Below that the filter does not exist and this enhancement is // simply absent: scripts outside wp_enqueue go un-nonced, which costs extra entries // in the CSP report and nothing else, because the policy runs in Report-Only mode. // Shipping a self-managed buffer as a fallback would reintroduce the finding. if ( function_exists( 'wp_start_template_enhancement_output_buffer' ) ) { add_filter( 'wp_template_enhancement_output_buffer', array( 'CSDT_CSP', 'csp_filter_template_nonces' ) ); } } // Schedule SSH monitor (default on), ensure cron is running if enabled. // Uses csdt_every_5min (not csdt_every_1min) to avoid a 256KB file-read // on every page load when DISABLE_WP_CRON is not set. if ( CSDT_Options::get( 'csdt_ssh_monitor_enabled' ) === '1' ) { $ev = wp_get_scheduled_event( 'csdt_ssh_monitor' ); if ( ! $ev ) { wp_schedule_event( time() + 60, 'csdt_every_5min', 'csdt_ssh_monitor' ); } elseif ( isset( $ev->schedule ) && 'csdt_every_5min' !== $ev->schedule ) { wp_clear_scheduled_hook( 'csdt_ssh_monitor' ); wp_schedule_event( time() + 60, 'csdt_every_5min', 'csdt_ssh_monitor' ); } } else { wp_clear_scheduled_hook( 'csdt_ssh_monitor' ); } // Schedule PHP error monitor (default on) if ( CSDT_Options::get( 'csdt_php_error_monitor_enabled' ) === '1' ) { if ( ! wp_next_scheduled( 'csdt_php_error_monitor' ) ) { wp_schedule_event( time() + 300, 'csdt_every_5min', 'csdt_php_error_monitor' ); } } else { wp_clear_scheduled_hook( 'csdt_php_error_monitor' ); } // Schedule threat monitor (default on) // File integrity books its own event at the chosen interval, and reschedules when that // choice changes. Called unconditionally: it is also what CLEARS the event when the // check is switched off. if ( class_exists( 'CSDT_Integrity' ) ) { CSDT_Integrity::sync_schedule(); } // Every five minutes while on, removed while off, never booked on a standby. CSDT_Standby_Rearm::sync_threat_monitor(); // The scheduled audit, from its saved settings, on every load like the syncs below: // until this, only saving the settings booked it, so a promoted standby never ran one. CSDT_Standby_Rearm::sync_scheduled_scan(); // The persistence watch, on the same interval, now that the interval exists. CSDT_Persistence_Watch::sync_schedule(); CSDT_Host_Alarms::sync_schedule(); CSDT_Tls_Expiry::sync_schedule(); CSDT_Php_Advisory::sync_schedule(); CSDT_Upgrade_Verify::sync_schedule(); CSDT_Edge_Waf::sync_schedule(); // The Exposed folders scan: once a day in the background, in batches. It asks // only this site for its own folders. See CSDT_Dir_Probe. self::cron_action( CSDT_Dir_Probe::CRON_HOOK, array( 'CSDT_Dir_Probe', 'cron_run' ) ); self::cron_action( CSDT_Dir_Probe::CRON_HOOK . '_continue', array( 'CSDT_Dir_Probe', 'cron_run' ) ); CSDT_Dir_Probe::sync_schedule(); self::cron_action( 'csdt_devtools_run_vuln_scan', array( 'CSDT_Vuln_Scan', 'cron_vuln_scan' ) ); self::cron_action( 'csdt_devtools_run_deep_scan', array( 'CSDT_Site_Audit', 'cron_deep_scan' ) ); // Email log, only when an administrator has switched it on (off by default). // When on it tracks every wp_mail() call site-wide, whether or not our SMTP is enabled. if ( CSDT_SMTP::email_log_enabled() ) { add_filter( 'wp_mail', array( 'CSDT_SMTP', 'smtp_log_capture' ) ); add_action( 'wp_mail_failed', array( 'CSDT_SMTP', 'smtp_log_on_failure' ) ); // Priority 5 so it runs before phpmailer_configure (priority 10) and sets action_function first. add_action( 'phpmailer_init', array( 'CSDT_SMTP', 'smtp_log_set_callback' ), 5 ); } // SMTP, configure phpmailer and override from address only when fully configured. // Guard: if host is empty we skip configuration entirely so other plugins' emails // continue to work via PHP mail() rather than silently failing. if ( CSDT_Options::get( 'csdt_devtools_smtp_enabled' ) === '1' && '' !== trim( (string) get_option( 'csdt_devtools_smtp_host', '' ) ) ) { add_action( 'phpmailer_init', array( 'CSDT_SMTP', 'phpmailer_configure' ) ); if ( get_option( 'csdt_devtools_smtp_from_email', '' ) ) { add_filter( 'wp_mail_from', array( 'CSDT_SMTP', 'smtp_from_email' ) ); } if ( get_option( 'csdt_devtools_smtp_from_name', '' ) ) { add_filter( 'wp_mail_from_name', array( 'CSDT_SMTP', 'smtp_from_name' ) ); } } // Login security, URL intercept / 2FA flow (early, priority 1 on init). add_action( 'init', array( 'CSDT_Login', 'login_admin_intercept' ), 0 ); add_action( 'init', array( 'CSDT_Login', 'login_serve_custom_slug' ), 1 ); add_action( 'login_init', array( 'CSDT_Login', 'login_redirect_authenticated' ), 0 ); add_action( 'login_init', array( 'CSDT_Login', 'login_block_direct_access' ), 1 ); add_filter( 'auth_cookie_expiration', array( 'CSDT_Login', 'login_session_expiration' ), 10, 3 ); add_action( 'login_init', array( 'CSDT_Login', 'login_2fa_handle' ) ); add_filter( 'authenticate', array( 'CSDT_Login', 'login_2fa_intercept' ), 100, 3 ); add_filter( 'secure_signon_cookie', array( 'CSDT_Login', 'capture_signon_remember' ), 10, 2 ); add_filter( 'login_url', array( 'CSDT_Login', 'login_custom_url' ), 10, 3 ); add_filter( 'logout_url', array( 'CSDT_Login', 'login_custom_logout_url' ), 10, 2 ); add_filter( 'lostpassword_url', array( 'CSDT_Login', 'login_custom_lostpassword_url' ), 10, 2 ); add_filter( 'network_site_url', array( 'CSDT_Login', 'login_custom_network_url' ), 10, 3 ); add_filter( 'site_url', array( 'CSDT_Login', 'login_custom_site_url' ), 10, 4 ); // Brute-force protection, check before authentication (priority 1, before password check). add_filter( 'authenticate', array( 'CSDT_Login', 'login_brute_force_check' ), 1, 3 ); // Force a persistent cookie when a custom session duration is configured. // On wp_login, not login_init: it has to run AFTER the auth cookie exists so it can // replace it, and it has to reach the two-factor path, which sets its own cookie and // never goes through core's sign-on helper. add_action( 'wp_login', array( 'CSDT_Login', 'login_force_remember' ), 10, 2 ); // Honeypot 2FA, redirect failed logins to fake PIN screen, then watched screen. add_action( 'wp_login_failed', array( 'CSDT_Login', 'honeypot_redirect' ), 5 ); add_action( 'login_init', array( 'CSDT_Login', 'honeypot_handle' ), 2 ); // Security monitor, always track failed logins regardless of monitor toggle. // Registered HERE, between honeypot_redirect (priority 5) and CSDT_Login::on_login_failed // (below, same priority), on_login_failed reads the counters this updates, and within one // priority WordPress runs callbacks in registration order. Moved from CSDT_Perf_Monitor to // CSDT_Login on 2026-08-17; the position in this list is the part that must not change. add_action( 'wp_login_failed', array( 'CSDT_Login', 'record_failed_login' ) ); // Telegram alerts for failed logins and REST API auth failures. add_action( 'wp_login_failed', array( 'CSDT_Login', 'on_login_failed' ) ); add_action( 'application_password_failed_authentication', array( 'CSDT_Login', 'on_rest_auth_failed' ) ); // Reject malformed Basic Auth (empty username, oversized, invalid chars) with 400. add_filter( 'rest_authentication_errors', array( 'CSDT_Login', 'rest_reject_empty_basic_auth' ), 1 ); // Return generic 401 so attackers can't enumerate usernames via REST auth errors. add_filter( 'rest_authentication_errors', array( 'CSDT_Login', 'rest_generic_auth_error' ), 99 ); // Alert on ANY write to security-critical options, catches WP-CLI, direct DB edits, etc. add_action( 'updated_option', array( 'CSDT_Login', 'on_option_updated' ), 10, 3 ); // Style the login error panel. add_action( 'login_enqueue_scripts', array( 'CSDT_Login', 'login_error_styles' ) ); // Username enumeration protection, only register if option is enabled (default on). if ( CSDT_Options::get( 'csdt_devtools_enum_protect' ) === '1' ) { add_filter( 'wp_login_errors', array( 'CSDT_Login', 'generic_login_errors' ) ); } // Custom 404 page + hiscore leaderboard. add_action( 'template_redirect', array( 'CSDT_Custom_404', 'maybe_custom_404' ), 1 ); add_action( 'rest_api_init', array( 'CSDT_Custom_404', 'register_hiscore_routes' ) ); // Export to PDF button on single posts, for every visitor. CSDT_PDF_Export::init(); add_action( 'rest_api_init', array( 'CSDT_CSP', 'register_csp_report_route' ) ); add_action( 'rest_api_init', array( 'CSDT_Monitor', 'register_fpm_report_route' ) ); add_action( 'rest_api_init', array( 'CSDT_GitHub_Sync', 'register_rest_routes' ) ); add_action( 'init', array( 'CSDT_GitHub_Sync', 'maybe_install_table' ) ); // One-time normalisation of csdt_ip_blocklist into a single entry shape. // Idempotent and version-gated, so it costs one autoloaded option read // once the version matches. Housekeeping only, enforcement normalises on // read, so correctness does not wait for this to run. add_action( 'init', array( 'CSDT_Auto_Block', 'migrate_blocklist' ) ); // Counter table for hostile-event accounting. Idempotent and version-gated, // so it costs one autoloaded option read once the version matches. add_action( 'init', array( 'CSDT_Storage', 'maybe_install' ) ); // REST save chain diagnostics, Redis-safe, uses PHP static class (in-process memory). // Previous code was broken: it checked REST_REQUEST inside plugins_loaded, but // REST_REQUEST is not defined until WP_REST_Server::serve_request() fires, so the // shutdown handler was never registered. Also used $wpdb->options transient queries // that miss Redis-cached transients entirely. // // Layers tracked: // rest_pre_dispatch β†’ request reached the REST router // rest_pre_insert_post β†’ DB write about to start // rest_after_insert_post β†’ DB write succeeded // rest_pre_serve_request β†’ output buffer check (stray PHP output corrupts JSON) // rest_pre_serve_request β†’ another plugin's filter intercepted the response // shutdown β†’ PHP exited without serving the response // Layer 1: REST router started (rest_pre_dispatch fires before controller dispatch). add_filter( 'rest_pre_dispatch', static function ( $result, $server, $request ) { if ( 'POST' !== $request->get_method() ) { return $result; } if ( ! preg_match( '#^/wp/v2/posts/(\d+)$#', $request->get_route(), $m ) ) { return $result; } CSDT_REST_Diag::$post_id = (int) $m[1]; CSDT_REST_Diag::$dispatched = true; CSDT_REST_Diag::$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : $request->get_route(); return $result; }, 1, 3 ); // Layer 2: DB write starting. add_filter( 'rest_pre_insert_post', static function ( $prepared, $_req ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- hook callback; $prepared is the used value; $_req is the WP_REST_Request required by arity CSDT_REST_Diag::$pre_inserted = true; if ( ! CSDT_REST_Diag::$post_id && isset( $prepared->ID ) ) { CSDT_REST_Diag::$post_id = (int) $prepared->ID; } return $prepared; }, 1, 2 ); // Layer 3: DB write succeeded. add_action( 'rest_after_insert_post', static function ( \WP_Post $_post ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- hook callback; WP_Post arg required by action arity CSDT_REST_Diag::$inserted = true; }, PHP_INT_MAX ); // Layer 4: Output buffer check at priority 1 (before WP encodes the response). // Any content already in the buffer here is stray PHP output (warnings, notices) // that would be prepended to the JSON body and break the response. add_filter( 'rest_pre_serve_request', static function ( $served, $_result, $_request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- hook callback; $result/$request required by filter arity if ( ! CSDT_REST_Diag::$dispatched ) { return $served; } $ob = ob_get_contents(); if ( false !== $ob && '' !== trim( $ob ) ) { CSDT_REST_Diag::$ob_stray = substr( $ob, 0, 600 ); } // If $served is already true here, a lower-priority filter already intercepted // and served a custom response, that other plugin is the culprit. if ( $served ) { CSDT_REST_Diag::$intercepted = true; } return $served; }, 1, 3 ); // Layer 5: Final check just before WP actually sends the JSON. // Validate the encoded response and mark served = true so the shutdown function // knows the request completed normally. add_filter( 'rest_pre_serve_request', static function ( $served, $result, $_request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- hook callback; $request required by filter arity if ( ! CSDT_REST_Diag::$dispatched ) { return $served; } CSDT_REST_Diag::$served = true; if ( ! $served && $result instanceof \WP_REST_Response ) { $encoded = wp_json_encode( $result->get_data() ); if ( false === $encoded ) { CSDT_REST_Diag::$json_err = 'wp_json_encode returned false (non-serialisable value in response data)'; } } return $served; }, PHP_INT_MAX, 3 ); // Shutdown handler registered at rest_api_init, this action fires inside // WP_REST_Server::serve_request() so REST_REQUEST is already defined and true. // Previous code registered this at plugins_loaded where REST_REQUEST does not // yet exist, meaning it never ran for any REST request. add_action( 'rest_api_init', static function () { register_shutdown_function( static function () { if ( ! class_exists( 'CSDT_Telegram' ) || ! CSDT_Telegram::is_configured() ) { return; } $err = error_get_last(); $err_detail = $err ? sprintf( "\nLast PHP error: [%d] %s\n in %s:%d", $err['type'], $err['message'], $err['file'], $err['line'] ) : ''; // Stray PHP output buffered before the response (warnings/notices with display_errors=on). if ( CSDT_REST_Diag::$dispatched && '' !== CSDT_REST_Diag::$ob_stray ) { CSDT_Alert_Events::send( CSDT_Alert_Events::GENERAL, sprintf( "REST post save: stray PHP output detected before response\nPost: #%d\nURI: %s\nBuffer (first 600 chars):\n%s%s", CSDT_REST_Diag::$post_id, CSDT_REST_Diag::$uri, CSDT_REST_Diag::$ob_stray, $err_detail ), 'CloudScale Cyber Devtools', 'critical' ); return; } // Another plugin's rest_pre_serve_request filter intercepted the response. if ( CSDT_REST_Diag::$dispatched && CSDT_REST_Diag::$intercepted && ! CSDT_REST_Diag::$served ) { CSDT_Alert_Events::send( CSDT_Alert_Events::GENERAL, sprintf( "REST post save: another plugin intercepted rest_pre_serve_request\nPost: #%d\nURI: %s\nThe response was hijacked before our PHP_INT_MAX hook, a plugin served a custom (possibly corrupt) response.%s", CSDT_REST_Diag::$post_id, CSDT_REST_Diag::$uri, $err_detail ), 'CloudScale Cyber Devtools', 'critical' ); return; } // wp_json_encode failed, response data contained non-serialisable values. if ( CSDT_REST_Diag::$dispatched && '' !== CSDT_REST_Diag::$json_err ) { CSDT_Alert_Events::send( CSDT_Alert_Events::GENERAL, sprintf( "REST post save: JSON encoding failed\nPost: #%d\nURI: %s\nReason: %s%s", CSDT_REST_Diag::$post_id, CSDT_REST_Diag::$uri, CSDT_REST_Diag::$json_err, $err_detail ), 'CloudScale Cyber Devtools', 'critical' ); return; } // PHP exited before the response was served, map to the last completed stage. if ( CSDT_REST_Diag::$dispatched && ! CSDT_REST_Diag::$served ) { if ( CSDT_REST_Diag::$inserted ) { $stage = 'between rest_after_insert_post and rest_pre_serve_request'; } elseif ( CSDT_REST_Diag::$pre_inserted ) { $stage = 'between rest_pre_insert_post and rest_after_insert_post (DB write in progress)'; } else { $stage = 'between rest_pre_dispatch and rest_pre_insert_post'; } CSDT_Alert_Events::send( CSDT_Alert_Events::GENERAL, sprintf( "REST post save INCOMPLETE: PHP exited without serving response\nPost: #%d\nURI: %s\nFailed at stage: %s%s", CSDT_REST_Diag::$post_id, CSDT_REST_Diag::$uri, $stage, $err_detail ), 'CloudScale Cyber Devtools', 'critical' ); } } ); }, 1 ); // Reconcile the index after every post save so that deleting a block or // clearing a block's GitHub URL removes its row from the linked-blocks table. add_action( 'save_post', array( 'CSDT_GitHub_Sync', 'on_save_post' ), 20, 3 ); add_action( 'before_delete_post', array( 'CSDT_GitHub_Sync', 'on_post_deleted' ) ); add_action( 'wp_trash_post', array( 'CSDT_GitHub_Sync', 'on_post_deleted' ) ); self::cron_action( CSDT_GitHub_Sync::CRON_HOOK, array( 'CSDT_GitHub_Sync', 'cron_sweep' ) ); if ( ! wp_next_scheduled( CSDT_GitHub_Sync::CRON_HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', CSDT_GitHub_Sync::CRON_HOOK ); } CSDT_Ajax::register( 'wp_ajax_csdt_devtools_save_404_settings', array( 'CSDT_Custom_404', 'ajax_save_404_settings' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_perf_debug_toggle', array( 'CSDT_Perf_Monitor', 'ajax_perf_debug_toggle' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_perf_critical_notify', array( 'CSDT_Perf_Monitor', 'ajax_perf_critical_notify' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_devtools_perf_error_report', array( 'CSDT_Perf_Monitor', 'ajax_perf_error_report' ) ); // Code block paste guard: reports paste-time fence/bracket recovery failures. CSDT_Ajax::register( 'wp_ajax_csdt_devtools_paste_recovery_failed', array( __CLASS__, 'ajax_paste_recovery_failed' ) ); // Performance monitor, only register data-collection hooks when the monitor is enabled. // This prevents per-request data accumulation on every request when disabled. if ( CSDT_Options::get( 'csdt_devtools_perf_monitor_enabled' ) !== '0' ) { add_filter( 'pre_http_request', array( 'CSDT_Perf_Monitor', 'perf_http_before' ), 10, 3 ); add_action( 'http_api_debug', array( 'CSDT_Perf_Monitor', 'perf_http_after' ), 10, 5 ); // Debug logging from the panel: make sure a destination is recorded, and nothing // else. It used to also switch PHP's own log_errors on and repoint error_log at // that file, which WordPress.org rejected for changing settings the whole request // shares, and which most hosts ignored anyway. self::log() now writes to this path // itself, so the destination is honoured without touching PHP's configuration. // // error_reporting() has never been touched here and still is not: the panel's own // set_error_handler() (registered below) collects warnings, notices and deprecations // for the diagnostic feed without altering the global reporting level. if ( get_option( 'csdt_devtools_perf_debug_logging', false ) && csdt_devtools_is_diagnostic_context() ) { if ( '' === (string) get_option( 'csdt_debug_log_path', '' ) ) { update_option( 'csdt_debug_log_path', self::get_log_dir() . '/debug.log', false ); } } // Register error handler late (priority 9999 on plugins_loaded) so we sit // on top of any handler registered by other plugins (e.g. Query Monitor). add_action( 'plugins_loaded', function () { CSDT_Perf_Monitor::$perf_prev_error_handler = set_error_handler( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- custom error capture for diagnostic purposes array( 'CSDT_Perf_Monitor', 'perf_error_handler' ), E_WARNING | E_NOTICE | E_DEPRECATED | E_USER_WARNING | E_USER_NOTICE | E_USER_DEPRECATED ); CSDT_Monitor::register_shutdown_handler(); // Deliberately NOT set_exception_handler(). An uncaught exception // already reaches the shutdown handler above as E_ERROR ("Uncaught // RuntimeException: ..."), so it is alerted today. Installing an // exception handler to gain a stack trace makes error_get_last() // return NULL instead, which silently disables both that alert and // WordPress's own fatal-error protection (the "critical error" page // and recovery-mode email). Verified, not assumed. A trace is not // worth turning two working safety nets off. }, 9999 ); // Client-side failure reporter, printed inline in so it cannot // itself be the thing that failed to load. See the method docblock. // Priority 0: the reporter must be the first script in the head to see every later asset load. add_action( 'admin_enqueue_scripts', array( 'CSDT_Monitor', 'print_js_error_reporter' ), 0 ); // Performance monitor, panel rendering (admin pages). add_action( 'admin_enqueue_scripts', array( 'CSDT_Perf_Monitor', 'perf_enqueue' ) ); // Inject JSON data at priority 15, before wp_print_footer_scripts (priority 20) so // cs-perf-monitor.js reads window.csdtDevtoolsPerfData when its IIFE runs. add_action( 'admin_footer', array( 'CSDT_Perf_Monitor', 'perf_inject_data' ), 15 ); add_action( 'admin_footer', array( 'CSDT_Perf_Monitor', 'perf_output_panel' ), 9999 ); // Performance monitor, panel rendering (frontend, admin users only). add_action( 'wp_enqueue_scripts', array( 'CSDT_Perf_Monitor', 'perf_frontend_enqueue' ) ); add_action( 'wp_footer', array( 'CSDT_Perf_Monitor', 'perf_inject_data' ), 15 ); add_action( 'wp_footer', array( 'CSDT_Perf_Monitor', 'perf_output_panel' ), 9999 ); // Capture the active template filename for the page-context strip. add_filter( 'template_include', array( 'CSDT_Perf_Monitor', 'perf_capture_template' ), 9999 ); // Hook timing tracker, fires on every action/filter. // Guard behind a logged-in-user signal: anonymous visitor requests skip the // ~1000-call overhead for a panel they will never see. LOGGED_IN_COOKIE is // available before authentication completes, giving us an early read on // whether any user (and potentially an admin) is present. if ( is_admin() || CSDT_Request::has_cookie( LOGGED_IN_COOKIE ) ) { add_action( 'all', array( 'CSDT_Perf_Monitor', 'perf_hook_tracker' ) ); // Transient + template hierarchy observer (single all-hook for both). add_action( 'all', array( 'CSDT_Perf_Monitor', 'perf_misc_tracker' ) ); } // WordPress 6.8 renamed setted_transient to set_transient and deprecated the old // name, which WP_DEBUG reports on every page load; 6.2 to 6.7 only fire the old one. $wp_68 = version_compare( get_bloginfo( 'version' ), '6.8', '>=' ); add_action( $wp_68 ? 'set_transient' : 'setted_transient', array( 'CSDT_Perf_Monitor', 'perf_transient_set' ) ); add_action( $wp_68 ? 'set_site_transient' : 'setted_site_transient', array( 'CSDT_Perf_Monitor', 'perf_transient_set' ) ); add_action( 'deleted_transient', array( 'CSDT_Perf_Monitor', 'perf_transient_delete' ) ); add_action( 'deleted_site_transient', array( 'CSDT_Perf_Monitor', 'perf_transient_delete' ) ); // Scripts & styles, collect at footer time (after everything is enqueued). add_action( 'admin_footer', array( 'CSDT_Perf_Monitor', 'perf_capture_assets' ), 1 ); // Late, after every enqueue has run: names any csdt-* script WordPress will // refuse to print because a declared dependency is not registered on this // screen. WP does that silently, which is how Active Verification shipped dead // on the Protect tab. See CSDT_Monitor::report_dropped_scripts(). add_action( 'admin_print_footer_scripts', array( 'CSDT_Monitor', 'report_dropped_scripts' ), 99 ); add_action( 'wp_footer', array( 'CSDT_Perf_Monitor', 'perf_capture_assets' ), 1 ); // Request lifecycle milestones for the waterfall timeline. // Registered at PHP_INT_MAX so we capture the time after all other // callbacks on that hook have finished running. foreach ( array( 'plugins_loaded' => 'Plugins loaded', 'init' => 'WP init', 'admin_init' => 'Admin init', 'wp_loaded' => 'WP loaded', 'wp' => 'Query setup', 'template_redirect' => 'Template', ) as $_ms_hook => $_ms_label ) { add_action( $_ms_hook, static function () use ( $_ms_label ) { CSDT_Perf_Monitor::perf_record_milestone( $_ms_label ); }, PHP_INT_MAX ); } } } private static function maybe_migrate_autoload(): void { global $wpdb; // ── v1: ensure hot-path config options ARE autoloaded. // (Credentials removed from this list, see v2 below.) $done_key_v1 = 'csdt_autoload_migrated_v1'; if ( ! get_option( $done_key_v1 ) ) { $names_yes = array( 'csdt_uptime_enabled', 'csdt_uptime_worker_url', 'csdt_ssh_monitor_enabled', 'csdt_ssh_monitor_threshold', 'csdt_php_error_monitor_enabled', 'csdt_php_error_monitor_threshold', 'csdt_threat_monitor_enabled', 'csdt_threat_file_integrity_enabled', 'csdt_threat_new_admin_enabled', 'csdt_threat_probe_enabled', 'csdt_threat_probe_threshold', 'csdt_devtools_ai_provider', 'csdt_devtools_security_model', 'csdt_devtools_deep_scan_model', 'csdt_devtools_security_prompt', ); // Option names never contain a comma, so the list is ONE %s value for FIND_IN_SET() // and the statement is a literal; no placeholder list is assembled into it. $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off autoload migration $wpdb->prepare( "UPDATE %i SET autoload = 'yes' WHERE FIND_IN_SET( option_name, %s ) AND autoload != 'yes'", $wpdb->options, implode( ',', $names_yes ) ) ); add_option( $done_key_v1, '1', '', 'yes' ); } // ── v2: force credentials OFF autoload. // Cyber review: these values are long-lived secrets. Keeping them out of // wp_load_alloptions() means a future arbitrary-read primitive can't // dump them in a single query. $done_key_v2 = 'csdt_autoload_migrated_v2'; if ( ! get_option( $done_key_v2 ) ) { $names_no = array( 'csdt_devtools_anthropic_key', 'csdt_devtools_gemini_key', 'csdt_devtools_cf_api_token', 'csdt_devtools_smtp_pass', 'csdt_db_prefix_rollback', 'csdt_devtools_openai_key', 'csdt_uptime_token', 'csdt_opcache_token', 'csdt_fpm_token', ); // Option names never contain a comma, so the list is ONE %s value for FIND_IN_SET() // and the statement is a literal; no placeholder list is assembled into it. $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off autoload migration $wpdb->prepare( "UPDATE %i SET autoload = 'no' WHERE FIND_IN_SET( option_name, %s ) AND autoload != 'no'", $wpdb->options, implode( ',', $names_no ) ) ); add_option( $done_key_v2, '1', '', 'no' ); } // ── v3: catch credential options the v2 list missed. // Cyber review identified three credentials that pre-existing installs // may still have stored with autoload=yes from earlier plugin code. $done_key_v3 = 'csdt_autoload_migrated_v3'; if ( ! get_option( $done_key_v3 ) ) { $names_no = array( CSDT_Telegram::OPTION_TOKEN, 'csdt_test_session_secret', // test-session API HMAC secret 'csdt_test_session_path_token', // path-segment secret hiding the endpoint ); // Option names never contain a comma, so the list is ONE %s value for FIND_IN_SET() // and the statement is a literal; no placeholder list is assembled into it. $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off autoload migration $wpdb->prepare( "UPDATE %i SET autoload = 'no' WHERE FIND_IN_SET( option_name, %s ) AND autoload != 'no'", $wpdb->options, implode( ',', $names_no ) ) ); add_option( $done_key_v3, '1', '', 'no' ); } } /** * Registers plugin settings with sanitise callbacks. * * @since 1.0.0 * @return void */ public static function register_settings() { register_setting( 'csdt_devtools_code_settings', 'csdt_devtools_code_default_theme', array( 'type' => 'string', 'sanitize_callback' => function ( $val ) { return in_array( $val, array( 'dark', 'light' ), true ) ? $val : 'dark'; }, 'default' => 'dark', ) ); $valid_themes = array_keys( self::get_theme_registry() ); register_setting( 'csdt_devtools_code_settings', 'csdt_devtools_code_theme_pair', array( 'type' => 'string', 'sanitize_callback' => function ( $val ) use ( $valid_themes ) { return in_array( $val, $valid_themes, true ) ? $val : 'atom-one'; }, 'default' => 'atom-one', ) ); register_setting( 'csdt_devtools_code_settings', 'csdt_devtools_perf_monitor_enabled', array( 'type' => 'string', 'sanitize_callback' => function ( $val ) { return '1' === $val ? '1' : '0'; }, 'default' => '0', ) ); register_setting( 'csdt_devtools_code_settings', 'csdt_pdf_export_enabled', array( 'type' => 'string', 'sanitize_callback' => static function ( $v ) { return '0' === $v ? '0' : '1'; }, 'default' => '1', ) ); register_setting( 'csdt_devtools_code_settings', 'csdt_emoji_polyfill_off', array( 'type' => 'string', 'sanitize_callback' => static function ( $v ) { return '0' === $v ? '0' : '1'; }, 'default' => '1', ) ); register_setting( 'csdt_devtools_code_settings', 'csdt_show_powered_by', array( 'type' => 'string', 'sanitize_callback' => static function ( $v ) { return '1' === $v ? '1' : '0'; }, 'default' => '0', ) ); // Login security settings register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_login_hide_enabled', array( 'type' => 'string', 'sanitize_callback' => function ( $v ) { return '1' === $v ? '1' : '0'; }, 'default' => '0', ) ); register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_login_slug', array( 'type' => 'string', 'sanitize_callback' => function ( $v ) { $slug = sanitize_title( $v ); // Disallow WP reserved slugs $reserved = array( 'wp-login', 'wp-admin', 'login', 'admin', 'dashboard' ); return in_array( $slug, $reserved, true ) ? '' : $slug; }, 'default' => '', ) ); register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_2fa_method', array( 'type' => 'string', 'sanitize_callback' => function ( $v ) { return in_array( $v, array( 'off', 'email', 'totp' ), true ) ? $v : 'off'; }, 'default' => 'off', ) ); register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_2fa_force_admins', array( 'type' => 'string', 'sanitize_callback' => function ( $v ) { return '1' === $v ? '1' : '0'; }, 'default' => '0', ) ); register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_2fa_grace_logins', array( 'type' => 'string', 'sanitize_callback' => static function ( $v ) { $n = (int) $v; return ( $n >= 0 && $n <= 10 ) ? (string) $n : '0'; }, 'default' => '0', ) ); register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_session_duration', array( 'type' => 'string', 'sanitize_callback' => static function ( $v ) { $valid = array( 'default', '1', '7', '14', '30', '90', '365' ); return in_array( $v, $valid, true ) ? $v : 'default'; }, 'default' => 'default', ) ); register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_brute_force_enabled', array( 'type' => 'string', 'sanitize_callback' => static function ( $v ) { return $v === '1' ? '1' : '0'; }, 'default' => '1', ) ); register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_brute_force_attempts', array( 'type' => 'string', 'sanitize_callback' => static function ( $v ) { $n = (int) $v; return ( $n >= 1 && $n <= 100 ) ? (string) $n : '5'; }, 'default' => '5', ) ); register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_brute_force_lockout', array( 'type' => 'string', 'sanitize_callback' => static function ( $v ) { $n = (int) $v; return ( $n >= 1 && $n <= 1440 ) ? (string) $n : '5'; }, 'default' => '5', ) ); // SMTP settings register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_enabled', array( 'type' => 'string', 'sanitize_callback' => static function ( $v ) { return $v === '1' ? '1' : '0'; }, 'default' => '0', ) ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_host', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_port', array( 'type' => 'integer', 'sanitize_callback' => static function ( $v ) { $v = absint( $v ); return ( $v >= 1 && $v <= 65535 ) ? $v : 587; }, 'default' => 587, ) ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_encryption', array( 'type' => 'string', 'sanitize_callback' => static function ( $v ) { return in_array( $v, array( 'tls', 'ssl', 'none' ), true ) ? $v : 'tls'; }, 'default' => 'tls', ) ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_auth', array( 'type' => 'string', 'sanitize_callback' => static function ( $v ) { return $v === '1' ? '1' : '0'; }, 'default' => '1', ) ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_user', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_pass', array( 'type' => 'string', 'sanitize_callback' => static function ( $v ) { return $v; }, 'default' => '', ) ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_from_email', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_email', 'default' => '', ) ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_from_name', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'csdt_devtools_thumbs_settings', 'csdt_devtools_openai_key', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); } /** * Redirects legacy ?page=cloudscale-code-sql / ?page=cyber-devtools URLs, and tools.php?page= links from before the top-level menu, to admin.php?page=. * * MUST NOT be hooked on admin_init, which is what it was for its first two years and why it * never once worked. wp-admin/admin.php requires wp-admin/menu.php at line 163, and that dies * with "Sorry, you are not allowed to access this page." (403) for any ?page= slug no menu * registered, seventeen lines BEFORE do_action( 'admin_init' ) at line 180. An unregistered * slug is precisely the case this function exists to handle, so it was dead code for exactly * its own purpose: every old bookmark got a 403 that reads like a permissions problem. * * It now runs on init (which fires during wp-load, before admin.php gets that far) and again * on admin_page_access_denied, the action core fires immediately before that wp_die. * * @since 1.8.56 * @return void */ public static function redirect_legacy_slug() { // Only admin screen loads. ?page= is a normal front-end query var (and AJAX/REST callers // must never be bounced to a wp-admin URL), so running everywhere would break more than // it fixed. if ( ! is_admin() || wp_doing_ajax() || wp_is_json_request() ) { return; } // $plugin_page is core's own sanitised copy of the requested admin page (wp-admin/admin.php // sets it before admin_init); the raw query string is not read here. $page = isset( $GLOBALS['plugin_page'] ) ? sanitize_key( (string) $GLOBALS['plugin_page'] ) : ''; $on_tools = isset( $GLOBALS['pagenow'] ) && 'tools.php' === $GLOBALS['pagenow']; if ( in_array( $page, array( 'cloudscale-code-sql', 'cyber-devtools' ), true ) || ( $on_tools && self::TOOLS_SLUG === $page ) ) { // Keep the rest of the bookmark (tab=, etc.) from the request line. $query = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_QUERY ) : ''; wp_parse_str( $query, $args ); $args = array_map( 'sanitize_text_field', array_filter( (array) $args, 'is_string' ) ); $args['page'] = self::TOOLS_SLUG; wp_safe_redirect( add_query_arg( array_map( 'rawurlencode', $args ), admin_url( 'admin.php' ) ) ); exit; } } /** * Adds the plugin's top-level admin menu entry (Cyber & Devtools, shield icon). * * @since 1.6.0 * @return void */ public static function add_tools_page() { // One entry, top level, and no Tools submenu: two sidebar entries opening one screen is // clutter. Position 81 sits it right after CloudScale SEO AI (80). Old tools.php?page= // links and bookmarks are redirected here by redirect_legacy_slug(). add_menu_page( 'CloudScale Cyber and Devtools', 'Cyber & Devtools', 'manage_options', self::TOOLS_SLUG, array( __CLASS__, 'render_tools_page' ), 'dashicons-shield', 81 ); } /** * The tab the administrator asked for, or 'home'. * * The one place the `tab` query argument is read. Tab links are plain bookmarkable URLs * on this plugin's own Tools screen; the value chooses which panel to render and changes * nothing, so it carries no nonce, exactly like core's own `?page=` and `?tab=` routing. */ public static function requested_tab(): string { return self::normalise_tab( CSDT_Request::routing_key( 'tab' ) ?? 'home' ); } /** * Maps a requested tab slug onto the tab that now renders it. * * The Headers tab was folded into Protect (tab=login): response headers refuse * classes of request, which is what that tab is for, and a single-panel tab was * competing for top-level attention with the dashboard. * * Normalising here rather than leaving a redirect stub keeps every existing * `tab=headers` link working, dashboard chips, alert links, the published help * docs and seven Playwright specs all address the panels that way, and a stale * `tab=` fails silently: it lands on the fallback tab with no error anywhere. * Because both the enqueue pass and the render pass call this, the assets and * the markup cannot disagree about which tab is being shown. * * @param string $tab Requested tab slug. * @return string Tab slug to render. */ private static function normalise_tab( string $tab ): string { // `tab=headers` is a retired tab whose URL is still in bookmarks, in old alert emails and // in this plugin's own fix links. It has to land wherever those panels ARE, which is why // this is one line in one place: it pointed at `login` while the header and CSP panels // lived there, and moved to `security` with them on 2026-08-19. A stale tab= produces no // error of any kind, the page simply renders the default tab, so an alias left behind is // silent, and the reader is on the wrong page looking for a control that is not there. return 'headers' === $tab ? 'security' : $tab; } /** * Renders the combined Code Migrator and SQL Command tools page. * * @since 1.6.0 * @return void */ public static function render_tools_page() { $active_tab = self::requested_tab(); $base_url = admin_url( 'admin.php?page=' . self::TOOLS_SLUG ); ?> > */ private static array $explain_kses = array( 'a' => array( 'href' => true, 'target' => true, 'rel' => true, ), 'strong' => array(), 'em' => array(), 'code' => array(), 'br' => array(), 'ul' => array(), 'ol' => array(), 'li' => array(), 'p' => array(), 'h4' => array(), ); /** * Renders an "Explain…" button + inline modal. * * Each item in $items may have: * 'name' => string, section heading * 'rec' => string, badge label (Recommended | Note | Optional) * 'desc' => string, plain-text description (escaped with esc_html) * 'html' => string, HTML description rendered via wp_kses (overrides 'desc') * * @param string $id Unique slug used to build element IDs. * @param string $title Modal title. * @param array $items Array of item arrays. */ public static function render_explain_btn( string $id, string $title, array $items, string $intro = '', bool $light = false ): void { $btn_id = 'cs-explain-btn-' . $id; $modal_id = 'cs-explain-modal-' . $id; $btn_style = $light ? 'background:#eff6ff!important;border:1.5px solid #3b82f6!important;border-radius:5px!important;color:#1d4ed8!important;font-size:12px!important;font-weight:700!important;padding:5px 14px!important;cursor:pointer!important;margin-left:auto!important;flex-shrink:0!important;display:block!important;box-shadow:0 1px 3px rgba(59,130,246,0.15)!important;text-shadow:none!important;text-transform:none!important;letter-spacing:normal!important;line-height:1.4!important;' : 'background:rgba(255,255,255,0.15)!important;border:1px solid rgba(255,255,255,0.5)!important;border-radius:5px!important;color:#fff!important;font-size:12px!important;font-weight:600!important;padding:5px 14px!important;cursor:pointer!important;margin-left:auto!important;flex-shrink:0!important;display:block!important;box-shadow:none!important;text-shadow:none!important;text-transform:none!important;letter-spacing:normal!important;line-height:1.4!important;backdrop-filter:blur(4px)!important'; ?>
🎨 CODE BLOCK SETTINGS 'Color Theme', 'rec' => 'Recommended', 'desc' => 'Choose a light/dark colour-scheme pair for syntax-highlighted code blocks. Which half of the pair renders is set by Default Mode below, or per block from the block inspector.', ), array( 'name' => 'Default Mode', 'rec' => 'Optional', 'desc' => 'Sets whether all code blocks render light or dark by default. Applies to every code block unless a block overrides it in the inspector.', ), ) ); ?>

βœ“
__( 'What needs attention across the whole site, worst first, each linking straight to the control that fixes it. Nowhere else looks at everything at once. Tip: work down the setup steps in order, since the later ones only work once the earlier ones are done.', 'cloudscale-cyber-devtools' ), 'site-audit' => __( 'Checks your configuration, database and plugin health, then explains every finding in plain terms. Most sites are broken into through a known misconfiguration rather than a clever exploit. Tip: re-run it after any plugin update, which is when settings quietly change.', 'cloudscale-cyber-devtools' ), 'login' => __( 'Two-factor authentication, a login page attackers cannot find, and automatic lockout after repeated failures. The login form is the most attacked part of any WordPress site. Tip: register a passkey BEFORE enforcing two-factor, so a lost phone cannot lock you out.', 'cloudscale-cyber-devtools' ), 'security' => __( 'AI reads your site for weaknesses a fixed checklist would miss, and ranks them by what to fix first. Rules find known problems; this finds combinations. Tip: set up AI on the AI Settings tab first, since without it this tab returns rule-based findings only.', 'cloudscale-cyber-devtools' ), 'firewall' => __( 'Refuses hostile requests before WordPress loads, so a vulnerable plugin is never reached. A request that never arrives cannot be exploited. Tip: leave it in Log only for a week and read what it would have blocked before you switch it on.', 'cloudscale-cyber-devtools' ), 'activity' => __( 'Who did what, and when. After something breaks, the useful question is what changed just before, and this is the only record that answers it. Tip: filter to setting changes to spot an edit nobody meant to make.', 'cloudscale-cyber-devtools' ), 'thumbnails' => __( 'Featured images for posts that have none, and the image sizes your theme actually uses. Posts without artwork share badly; unused sizes waste disk. Tip: take a backup before pruning, because a deleted size is regenerated rather than recovered.', 'cloudscale-cyber-devtools' ), 'optimizer' => __( 'Finds what actually makes the site slow: heavy plugins, missing caches and database bloat, measured rather than guessed. Speed is the one thing every visitor notices. Tip: fix caching before blaming plugins, since it is usually the larger win.', 'cloudscale-cyber-devtools' ), 'migrate' => __( 'Code blocks that highlight 37 languages and draw Mermaid diagrams from text, plus a migrator that converts old code blocks in bulk, showing you the result before anything is written. Tip: preview a single post first and check it renders as you expect.', 'cloudscale-cyber-devtools' ), 'debug' => __( 'Logs, monitors and server health, for when the site behaves oddly and you need evidence rather than a guess. Tip: start with Error Monitoring, since a PHP fatal usually explains the symptom you came here about.', 'cloudscale-cyber-devtools' ), 'mail' => __( 'Sends your alerts and your site\'s email reliably, so warnings reach you and contact forms arrive. An alert that never gets delivered is no protection at all. Tip: send a test message after any change here.', 'cloudscale-cyber-devtools' ), 'ai' => __( 'One place for the AI provider, key and model that every AI feature here uses: the deep dive, Site Audit and the optimizer. Tip: after saving, use Test Key, and check the status line above says AI is set up.', 'cloudscale-cyber-devtools' ), 'credentials' => __( 'Every key and token this plugin holds, and where each one is used. A leaked credential is only dangerous until it is replaced. Tip: rotate anything you have pasted into a chat, a ticket or a screenshot.', 'cloudscale-cyber-devtools' ), ); // Aliases render the same panel, so they get the same introduction. if ( in_array( $tab, self::DIAGNOSTICS_TABS, true ) ) { $tab = 'debug'; } if ( in_array( $tab, self::IMAGE_TABS, true ) ) { $tab = self::IMAGE_TABS[0]; } if ( empty( $intros[ $tab ] ) ) { return; } if ( $inline ) { CSDT_Tab_Intro::render_inline( $intros[ $tab ] ); return; } if ( 'home' === $tab ) { CSDT_Tab_Intro::render_slot(); return; } // Drawn as a card aligned with the panels below it, the tip under its own label. CSDT_Tab_Intro::render( $intros[ $tab ] ); } /** * Whether the current session was minted by the test-session endpoint. * * Such a session holds `manage_options` by design, it has to, or it could not drive the * settings screens it exists to test, so it can open every admin panel this plugin renders. * It must not be shown a credential or offered a control that rotates one: it was obtained * with no password and no second factor, so anything it can read is readable by anyone * holding a CI token, and anything it can rotate is a way to lock real CI out. * * Answered here rather than delegated, because this plugin must keep working with the * companion absent. It asks the companion when that class is present and new enough, since * the companion owns the role list; otherwise it checks the two role slugs itself, which * still matters, WordPress roles outlive the plugin that registered them, so a deactivated * companion leaves the accounts behind. * * Public because CSDT_Sec_Headers asks it too, to keep test-driven scans out of the audit * history. One answer to this question, not one per caller. * * @since 1.10.581 * @return bool */ public static function viewer_is_test_session(): bool { if ( class_exists( 'CSDT_Test_Accounts' ) && method_exists( 'CSDT_Test_Accounts', 'is_real_admin' ) ) { return ! CSDT_Test_Accounts::is_real_admin(); } $user = wp_get_current_user(); if ( ! $user || ! $user->exists() ) { return false; } return (bool) array_intersect( array( 'csdt_test_settings', 'csdt_test_admin' ), (array) $user->roles ); } public static function add_crossorigin_to_external_scripts( string $tag, string $handle, string $src ): string { if ( ! preg_match( '#^https?://#i', $src ) ) { return $tag; } $site_host = wp_parse_url( home_url(), PHP_URL_HOST ); $src_host = wp_parse_url( $src, PHP_URL_HOST ); if ( $site_host && $src_host && $site_host === $src_host ) { return $tag; } if ( strpos( $tag, 'crossorigin' ) !== false ) { return $tag; } return str_replace( ' src=', ' crossorigin="anonymous" src=', $tag ); } /** * Strips duplicate AdSense page-level-ads pushes from the finished response. * * WHY THIS IS A FILTER AND NOT AN OUTPUT BUFFER * -------------------------------------------- * This used to open its own output buffer with a callback, closed on `shutdown`, which * WordPress.org rejected in the 18 Aug 2026 review: a buffer must be closed inside the * function that opened it, and they name core's 6.9 template enhancement filter as the * sanctioned way to modify a whole response. Attaching a callback makes core own it. * * The is_admin() guard the old version needed is gone with it: core runs this filter only * for front-end HTML responses, never admin, REST or feeds. * * Still a regular expression rather than WP_HTML_Tag_Processor, deliberately, the * duplicate lives in the JavaScript TEXT inside a script element, and the tag processor * reads tags and attributes, not element content. * * Never open a buffer or emit output from here: core treats either as a fatal. * * @since 1.10.691 * @param string $html The full response HTML, supplied by core. * @return string */ public static function ads_dedup_filter_html( $html ): string { return self::ads_dedup_ob_html( (string) $html ); } public static function ads_dedup_ob_html( string $html ): string { // Matches push({ ... enable_page_level_ads ... }) allowing one level of nested {}. $push_re = '/\.push\s*\(\s*\{(?:[^{}]|\{[^{}]*\})*\benable_page_level_ads\b(?:[^{}]|\{[^{}]*\})*\}\s*\)\s*;?/is'; // adsbygoogle.js loaded with ?client= auto-enables page-level ads internally; // any explicit enable_page_level_ads push on the same page is a duplicate β†’ strip all. if ( preg_match( '/adsbygoogle\.js[^"\'<>\s]*[?&]client=/i', $html ) ) { return preg_replace( $push_re, '', $html ) ?? $html; } // Old-style (no ?client= URL): keep only the first push, drop duplicates. $found = false; return preg_replace_callback( $push_re, static function ( array $m ) use ( &$found ): string { if ( $found ) { return ''; } $found = true; return $m[0]; }, $html ) ?? $html; } public static function enqueue_ads_dedup_script(): void { // Runs at wp_enqueue_scripts priority 0 and prints in the head (in_footer false), so it // is the first script WordPress emits, ahead of any enqueued ad loader. The guard used to // be a 30-line string printed on wp_head with wp_print_inline_script_tag(); WordPress.org // reads that as inline JavaScript, which it was. A 1 KB cached file costs one request on // the first page view and nothing after. wp_enqueue_script( 'csdt-ads-dedup', plugins_url( 'assets/cs-ads-dedup.js', __FILE__ ), array(), self::VERSION, false ); } } CSDT_DevTools::init();