> */ public static function get_theme_registry(): array { return [ 'atom-one' => [ 'label' => 'Atom One', 'dark_css' => 'atom-one-dark', 'light_css' => 'atom-one-light', 'dark_bg' => '#282c34', 'dark_toolbar' => '#21252b', 'light_bg' => '#fafafa', 'light_toolbar'=> '#e8eaed', ], 'github' => [ 'label' => 'GitHub', 'dark_css' => 'github-dark', 'light_css' => 'github', 'dark_bg' => '#24292e', 'dark_toolbar' => '#1f2428', 'light_bg' => '#fff', 'light_toolbar'=> '#f6f8fa', ], 'monokai' => [ 'label' => 'Monokai', 'dark_css' => 'monokai', 'light_css' => 'atom-one-light', 'dark_bg' => '#272822', 'dark_toolbar' => '#1e1f1c', 'light_bg' => '#fafafa', 'light_toolbar'=> '#e8eaed', ], 'nord' => [ 'label' => 'Nord', 'dark_css' => 'nord', 'light_css' => 'atom-one-light', 'dark_bg' => '#2e3440', 'dark_toolbar' => '#272c36', 'light_bg' => '#fafafa', 'light_toolbar'=> '#e8eaed', ], 'dracula' => [ 'label' => 'Dracula', 'dark_css' => 'dracula', 'light_css' => 'atom-one-light', 'dark_bg' => '#282a36', 'dark_toolbar' => '#21222c', 'light_bg' => '#fafafa', 'light_toolbar'=> '#e8eaed', ], 'tokyo-night' => [ 'label' => 'Tokyo Night', 'dark_css' => 'tokyo-night-dark', 'light_css' => 'tokyo-night-light', 'dark_bg' => '#1a1b26', 'dark_toolbar' => '#16161e', 'light_bg' => '#d5d6db', 'light_toolbar'=> '#c8c9ce', ], 'vs2015' => [ 'label' => 'VS 2015 / VS Code', 'dark_css' => 'vs2015', 'light_css' => 'vs', 'dark_bg' => '#1e1e1e', 'dark_toolbar' => '#181818', 'light_bg' => '#fff', 'light_toolbar'=> '#f3f3f3', ], 'stackoverflow' => [ 'label' => 'Stack Overflow', 'dark_css' => 'stackoverflow-dark', 'light_css' => 'stackoverflow-light', 'dark_bg' => '#1c1b1b', 'dark_toolbar' => '#151414', 'light_bg' => '#f6f6f6', 'light_toolbar'=> '#e8e8e8', ], 'night-owl' => [ 'label' => 'Night Owl', 'dark_css' => 'night-owl', 'light_css' => 'atom-one-light', 'dark_bg' => '#011627', 'dark_toolbar' => '#001122', 'light_bg' => '#fafafa', 'light_toolbar'=> '#e8eaed', ], 'gruvbox' => [ 'label' => 'Gruvbox', 'dark_css' => 'base16/gruvbox-dark-hard', 'light_css' => 'base16/gruvbox-light-hard', 'dark_bg' => '#1d2021', 'dark_toolbar' => '#171819', 'light_bg' => '#f9f5d7', 'light_toolbar'=> '#ece8c8', ], 'solarized' => [ 'label' => 'Solarized', 'dark_css' => 'base16/solarized-dark', 'light_css' => 'base16/solarized-light', 'dark_bg' => '#002b36', 'dark_toolbar' => '#002530', 'light_bg' => '#fdf6e3', 'light_toolbar'=> '#eee8d5', ], 'panda' => [ 'label' => 'Panda', 'dark_css' => 'panda-syntax-dark', 'light_css' => 'panda-syntax-light', 'dark_bg' => '#292a2b', 'dark_toolbar' => '#222324', 'light_bg' => '#e6e6e6', 'light_toolbar'=> '#d9d9d9', ], 'tomorrow' => [ 'label' => 'Tomorrow Night', 'dark_css' => 'tomorrow-night-bright', 'light_css' => 'atom-one-light', 'dark_bg' => '#000', 'dark_toolbar' => '#0a0a0a', 'light_bg' => '#fafafa', 'light_toolbar'=> '#e8eaed', ], 'shades-of-purple' => [ 'label' => 'Shades of Purple', 'dark_css' => 'shades-of-purple', 'light_css' => 'atom-one-light', 'dark_bg' => '#2d2b55', 'dark_toolbar' => '#252347', 'light_bg' => '#fafafa', 'light_toolbar'=> '#e8eaed', ], ]; } private static $instance_count = 0; private static $assets_enqueued = false; // Performance monitor — static storage. /** @var array HTTP calls captured during this request. */ private static $perf_http_calls = []; /** @var float|null Microtime when last HTTP request started. */ private static $perf_http_timer = null; /** @var array PHP errors captured during this request. */ private static $perf_php_errors = []; /** @var array|null Active plugin prefix → slug map cache. */ private static $perf_plugin_map = null; /** @var callable|null Previous PHP error handler to chain into. */ private static $perf_prev_error_handler = null; /** @var string Template filename captured via template_include filter. */ private static $perf_template = ''; /** @var array Hook fire stats: [ hook => ['count'=>int,'total_ms'=>float,'max_ms'=>float] ] */ private static $perf_hooks = []; /** @var float|null Timestamp of last hook fire (ms). */ private static $perf_hook_last_ms = null; /** @var string|null Name of last hook fired. */ private static $perf_hook_last_name = null; /** @var array Transient stats: [ key => [ gets, hits, sets, deletes ] ] */ private static $perf_transients = []; /** @var array Template hierarchy candidates captured via *_template_hierarchy filters. */ private static $perf_template_hierarchy = []; /** @var array Request lifecycle milestones: [ ['label'=>string, 'ms'=>float] ] */ private static $perf_milestones = []; /** @var array|null Pending email log entry for the in-flight wp_mail() call. */ private static $smtp_log_pending = null; /** * Registers all plugin hooks. * * @since 1.0.0 * @return void */ public static function init() { self::maybe_migrate_prefix(); self::maybe_migrate_smtp_prefix(); self::maybe_migrate_usermeta_prefix(); add_filter( 'xmlrpc_enabled', '__return_false' ); // One-click security hardening — option-driven filters applied at every boot if ( get_option( 'csdt_devtools_disable_app_passwords', '0' ) === '1' ) { if ( get_option( 'csdt_test_accounts_enabled', '0' ) === '1' ) { // Test-account mode: block per-user (not site-wide) so test accounts still authenticate add_filter( 'wp_is_application_passwords_available_for_user', [ __CLASS__, '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' ) { add_action( 'application_password_did_authenticate', [ __CLASS__, 'test_account_after_auth' ], 10, 2 ); } if ( get_option( 'csdt_devtools_hide_wp_version', '0' ) === '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', [ __CLASS__, 'strip_asset_ver' ], 9999 ); add_filter( 'script_loader_src', [ __CLASS__, 'strip_asset_ver' ], 9999 ); } add_action( 'init', [ __CLASS__, 'load_textdomain' ] ); add_action( 'init', [ __CLASS__, 'register_block' ] ); add_action( 'init', [ __CLASS__, 'register_shortcode' ] ); add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'enqueue_convert_script' ] ); add_action( 'admin_menu', [ __CLASS__, 'add_tools_page' ] ); add_action( 'wp_dashboard_setup', [ __CLASS__, 'register_dashboard_widget' ] ); add_action( 'admin_init', [ __CLASS__, 'register_settings' ] ); add_action( 'admin_init', [ __CLASS__, 'redirect_legacy_slug' ] ); add_action( 'init', [ __CLASS__, 'redirect_legacy_help_url' ], 1 ); add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_admin_assets' ] ); // Migration AJAX add_action( 'wp_ajax_csdt_devtools_migrate_scan', [ __CLASS__, 'ajax_scan' ] ); add_action( 'wp_ajax_csdt_devtools_migrate_preview', [ __CLASS__, 'ajax_preview' ] ); add_action( 'wp_ajax_csdt_devtools_migrate_single', [ __CLASS__, 'ajax_migrate_single' ] ); add_action( 'wp_ajax_csdt_devtools_migrate_all', [ __CLASS__, 'ajax_migrate_all' ] ); // SQL AJAX add_action( 'wp_ajax_csdt_devtools_sql_run', [ __CLASS__, 'ajax_sql_run' ] ); // Settings AJAX add_action( 'wp_ajax_csdt_devtools_save_theme_setting', [ __CLASS__, 'ajax_save_theme_setting' ] ); // Login security AJAX add_action( 'wp_ajax_csdt_devtools_login_save', [ __CLASS__, 'ajax_login_save' ] ); add_action( 'wp_ajax_csdt_devtools_bf_log_fetch', [ __CLASS__, 'ajax_bf_log_fetch' ] ); add_action( 'wp_ajax_csdt_devtools_totp_setup_start', [ __CLASS__, 'ajax_totp_setup_start' ] ); add_action( 'wp_ajax_csdt_devtools_totp_setup_verify', [ __CLASS__, 'ajax_totp_setup_verify' ] ); add_action( 'wp_ajax_csdt_devtools_2fa_disable', [ __CLASS__, 'ajax_2fa_disable' ] ); add_action( 'wp_ajax_csdt_devtools_email_2fa_enable', [ __CLASS__, 'ajax_email_2fa_enable' ] ); add_action( 'admin_init', [ __CLASS__, 'email_2fa_confirm_check' ] ); add_action( 'after_password_reset', [ __CLASS__, 'on_password_reset' ], 10, 1 ); add_action( 'profile_update', [ __CLASS__, 'on_profile_update' ], 10, 2 ); CSDT_DevTools_Passkey::register_hooks(); // Thumbnails / Social Preview AJAX add_action( 'wp_ajax_csdt_devtools_social_check_url', [ __CLASS__, 'ajax_social_check_url' ] ); add_action( 'wp_ajax_csdt_devtools_social_scan_posts', [ __CLASS__, 'ajax_social_scan_posts' ] ); add_action( 'wp_ajax_csdt_devtools_social_scan_media', [ __CLASS__, 'ajax_social_scan_media' ] ); add_action( 'wp_ajax_csdt_devtools_social_fix_image', [ __CLASS__, 'ajax_social_fix_image' ] ); add_action( 'wp_ajax_csdt_devtools_social_generate_formats', [ __CLASS__, 'ajax_social_generate_formats' ] ); add_action( 'wp_ajax_csdt_devtools_social_platform_save', [ __CLASS__, 'ajax_social_platform_save' ] ); add_action( 'wp_ajax_csdt_devtools_social_fix_all_batch', [ __CLASS__, 'ajax_social_fix_all_batch' ] ); add_action( 'wp_ajax_csdt_devtools_social_diagnose_formats', [ __CLASS__, 'ajax_social_diagnose_formats' ] ); add_action( 'save_post_post', [ __CLASS__, 'on_post_saved' ], 100, 3 ); add_action( 'admin_notices', [ __CLASS__, 'social_format_admin_notice' ] ); // Serve platform-specific og:image based on crawler User-Agent. add_action( 'wp_head', [ __CLASS__, 'output_crawler_og_image' ], 1 ); add_action( 'wp_ajax_csdt_devtools_social_cf_test', [ __CLASS__, 'ajax_social_cf_test' ] ); add_action( 'wp_ajax_csdt_devtools_cf_purge', [ __CLASS__, 'ajax_cf_purge' ] ); add_action( 'wp_ajax_csdt_devtools_cf_save', [ __CLASS__, 'ajax_cf_save' ] ); // SMTP AJAX add_action( 'wp_ajax_csdt_devtools_smtp_save', [ __CLASS__, 'ajax_smtp_save' ] ); add_action( 'wp_ajax_csdt_devtools_smtp_test', [ __CLASS__, 'ajax_smtp_test' ] ); add_action( 'wp_ajax_csdt_devtools_smtp_log_clear', [ __CLASS__, 'ajax_smtp_log_clear' ] ); add_action( 'wp_ajax_csdt_devtools_smtp_log_fetch', [ __CLASS__, 'ajax_smtp_log_fetch' ] ); add_action( 'wp_ajax_csdt_devtools_vuln_scan', [ __CLASS__, 'ajax_vuln_scan' ] ); add_action( 'wp_ajax_csdt_devtools_deep_scan', [ __CLASS__, 'ajax_deep_scan' ] ); add_action( 'wp_ajax_csdt_devtools_scan_status', [ __CLASS__, 'ajax_scan_status' ] ); add_action( 'wp_ajax_csdt_devtools_cancel_scan', [ __CLASS__, 'ajax_cancel_scan' ] ); add_action( 'wp_ajax_csdt_devtools_vuln_save_key', [ __CLASS__, 'ajax_vuln_save_key' ] ); add_action( 'wp_ajax_csdt_devtools_security_test_key', [ __CLASS__, 'ajax_security_test_key' ] ); add_action( 'wp_ajax_csdt_devtools_server_logs_status', [ __CLASS__, 'ajax_server_logs_status' ] ); add_action( 'wp_ajax_csdt_devtools_server_logs_fetch', [ __CLASS__, 'ajax_server_logs_fetch' ] ); add_action( 'wp_ajax_csdt_devtools_logs_setup_php', [ __CLASS__, 'ajax_logs_setup_php' ] ); add_action( 'wp_ajax_csdt_devtools_logs_custom_save', [ __CLASS__, 'ajax_logs_custom_save' ] ); add_action( 'wp_ajax_csdt_devtools_scan_history', [ __CLASS__, 'ajax_scan_history' ] ); add_action( 'wp_ajax_csdt_devtools_save_schedule', [ __CLASS__, 'ajax_save_schedule' ] ); add_action( 'wp_ajax_csdt_devtools_quick_fix', [ __CLASS__, 'ajax_apply_quick_fix' ] ); add_action( 'wp_ajax_csdt_devtools_csp_save', [ __CLASS__, 'ajax_csp_save' ] ); add_action( 'wp_ajax_csdt_devtools_csp_rollback', [ __CLASS__, 'ajax_csp_rollback' ] ); add_action( 'send_headers', [ __CLASS__, 'output_security_headers' ] ); add_action( 'wp_ajax_csdt_test_account_create', [ __CLASS__, 'ajax_create_test_account' ] ); add_action( 'wp_ajax_csdt_test_account_revoke', [ __CLASS__, 'ajax_revoke_test_account' ] ); add_action( 'wp_ajax_csdt_test_account_settings_save', [ __CLASS__, 'ajax_save_test_account_settings' ] ); add_action( 'csdt_cleanup_test_accounts', [ __CLASS__, 'cleanup_expired_test_accounts' ] ); add_action( 'csdt_scheduled_scan', [ __CLASS__, 'run_scheduled_scan' ] ); add_filter( 'cron_schedules', [ __CLASS__, 'add_cron_schedules' ] ); add_action( 'csdt_devtools_run_vuln_scan', [ __CLASS__, 'cron_vuln_scan' ] ); add_action( 'csdt_devtools_run_deep_scan', [ __CLASS__, 'cron_deep_scan' ] ); // Email log — always active so every wp_mail() call is tracked site-wide, // regardless of whether our SMTP is enabled. add_filter( 'wp_mail', [ __CLASS__, 'smtp_log_capture' ] ); add_action( 'wp_mail_failed', [ __CLASS__, 'smtp_log_on_failure' ] ); // Priority 5 so it runs before phpmailer_configure (priority 10) and sets action_function first. add_action( 'phpmailer_init', [ __CLASS__, '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 ( get_option( 'csdt_devtools_smtp_enabled', '0' ) === '1' && '' !== trim( (string) get_option( 'csdt_devtools_smtp_host', '' ) ) ) { add_action( 'phpmailer_init', [ __CLASS__, 'phpmailer_configure' ] ); if ( get_option( 'csdt_devtools_smtp_from_email', '' ) ) { add_filter( 'wp_mail_from', [ __CLASS__, 'smtp_from_email' ] ); } if ( get_option( 'csdt_devtools_smtp_from_name', '' ) ) { add_filter( 'wp_mail_from_name', [ __CLASS__, 'smtp_from_name' ] ); } } // Login security — URL intercept / 2FA flow (early, priority 1 on init). add_action( 'init', [ __CLASS__, 'login_serve_custom_slug' ], 1 ); add_action( 'login_init', [ __CLASS__, 'login_redirect_authenticated' ], 0 ); add_action( 'login_init', [ __CLASS__, 'login_block_direct_access' ], 1 ); add_filter( 'auth_cookie_expiration', [ __CLASS__, 'login_session_expiration' ], 10, 3 ); add_action( 'login_init', [ __CLASS__, 'login_2fa_handle' ] ); add_filter( 'authenticate', [ __CLASS__, 'login_2fa_intercept' ], 100, 3 ); add_filter( 'login_url', [ __CLASS__, 'login_custom_url' ], 10, 3 ); add_filter( 'logout_url', [ __CLASS__, 'login_custom_logout_url' ], 10, 2 ); add_filter( 'lostpassword_url', [ __CLASS__, 'login_custom_lostpassword_url' ], 10, 2 ); add_filter( 'network_site_url', [ __CLASS__, 'login_custom_network_url' ], 10, 3 ); add_filter( 'site_url', [ __CLASS__, 'login_custom_site_url' ], 10, 4 ); // Brute-force protection — check before authentication (priority 1, before password check). add_filter( 'authenticate', [ __CLASS__, 'login_brute_force_check' ], 1, 3 ); // Force persistent cookie when a custom session duration is configured. // Must be login_init (fires before the POST is processed) not login_form_login // (which is a display hook that never fires on a successful login POST). add_action( 'login_init', [ __CLASS__, 'login_force_remember' ], 5 ); // Security monitor — always track failed logins regardless of monitor toggle. add_action( 'wp_login_failed', [ __CLASS__, 'perf_track_failed_login' ] ); // Custom 404 page + hiscore leaderboard. add_action( 'template_redirect', [ __CLASS__, 'maybe_custom_404' ], 1 ); add_action( 'rest_api_init', [ __CLASS__, 'register_hiscore_routes' ] ); add_action( 'wp_ajax_csdt_devtools_save_404_settings', [ __CLASS__, 'ajax_save_404_settings' ] ); // Performance monitor — EXPLAIN endpoint. add_action( 'wp_ajax_csdt_devtools_perf_explain', [ __CLASS__, 'ajax_perf_explain' ] ); add_action( 'wp_ajax_csdt_devtools_perf_debug_toggle', [ __CLASS__, 'ajax_perf_debug_toggle' ] ); // Performance monitor — only register data-collection hooks when the monitor is enabled. // This prevents SAVEQUERIES-scale memory accumulation on every request when disabled. if ( get_option( 'csdt_devtools_perf_monitor_enabled', '1' ) !== '0' ) { add_filter( 'pre_http_request', [ __CLASS__, 'perf_http_before' ], 10, 3 ); add_action( 'http_api_debug', [ __CLASS__, 'perf_http_after' ], 10, 5 ); // If the user enabled debug logging via the panel, activate PHP error logging // using ini_set — this works regardless of WP_DEBUG in wp-config.php and // survives Docker container rebuilds because the setting lives in the DB. if ( get_option( 'csdt_devtools_perf_debug_logging', false ) ) { // phpcs:ignore WordPress.PHP.IniSet.Risky @ini_set( 'log_errors', '1' ); // phpcs:ignore WordPress.PHP.IniSet.Risky @ini_set( 'error_log', WP_CONTENT_DIR . '/debug.log' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting error_reporting( E_ALL ); } // 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). // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler add_action( 'plugins_loaded', function () { self::$perf_prev_error_handler = set_error_handler( [ __CLASS__, 'perf_error_handler' ], E_WARNING | E_NOTICE | E_DEPRECATED | E_USER_WARNING | E_USER_NOTICE | E_USER_DEPRECATED ); }, 9999 ); // Performance monitor — panel rendering (admin pages). add_action( 'admin_enqueue_scripts', [ __CLASS__, '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', [ __CLASS__, 'perf_inject_data' ], 15 ); add_action( 'admin_footer', [ __CLASS__, 'perf_output_panel' ], 9999 ); // Performance monitor — panel rendering (frontend, admin users only). add_action( 'wp_enqueue_scripts', [ __CLASS__, 'perf_frontend_enqueue' ] ); add_action( 'wp_footer', [ __CLASS__, 'perf_inject_data' ], 15 ); add_action( 'wp_footer', [ __CLASS__, 'perf_output_panel' ], 9999 ); // Capture the active template filename for the page-context strip. add_filter( 'template_include', [ __CLASS__, 'perf_capture_template' ], 9999 ); // Hook timing tracker — fires on every action/filter. add_action( 'all', [ __CLASS__, 'perf_hook_tracker' ] ); // Transient + template hierarchy observer (single all-hook for both). add_action( 'all', [ __CLASS__, 'perf_misc_tracker' ] ); add_action( 'setted_transient', [ __CLASS__, 'perf_transient_set' ] ); add_action( 'setted_site_transient', [ __CLASS__, 'perf_transient_set' ] ); add_action( 'deleted_transient', [ __CLASS__, 'perf_transient_delete' ] ); add_action( 'deleted_site_transient', [ __CLASS__, 'perf_transient_delete' ] ); // Scripts & styles — collect at footer time (after everything is enqueued). add_action( 'admin_footer', [ __CLASS__, 'perf_capture_assets' ], 1 ); add_action( 'wp_footer', [ __CLASS__, '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 ( [ '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 ) { self::perf_record_milestone( $_ms_label ); }, PHP_INT_MAX ); } } } /* ================================================================== 0. TEXT DOMAIN ================================================================== */ /** * Loads the plugin text domain for translations. * * @since 1.0.0 * @return void */ public static function load_textdomain(): void { load_plugin_textdomain( 'cloudscale-devtools', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); } /* ================================================================== 1. BLOCK REGISTRATION ================================================================== */ /** * Registers the block type and all its scripts and stylesheets. * * @since 1.0.0 * @return void */ public static function register_block() { $cdn = self::HLJS_CDN . self::HLJS_VERSION; wp_register_script( 'hljs-core', $cdn . '/highlight.min.js', [], self::HLJS_VERSION, true ); // Register both theme stylesheets from the selected pair $pair_slug = get_option( 'csdt_devtools_code_theme_pair', 'atom-one' ); $registry = self::get_theme_registry(); $pair = isset( $registry[ $pair_slug ] ) ? $registry[ $pair_slug ] : $registry['atom-one']; wp_register_style( 'hljs-theme-dark', $cdn . '/styles/' . $pair['dark_css'] . '.min.css', [], self::HLJS_VERSION ); wp_register_style( 'hljs-theme-light', $cdn . '/styles/' . $pair['light_css'] . '.min.css', [], self::HLJS_VERSION ); wp_register_style( 'csdt-code-block-frontend', plugins_url( 'assets/cs-code-block.css', __FILE__ ), [ 'hljs-theme-dark', 'hljs-theme-light' ], self::VERSION ); wp_register_script( 'csdt-code-block-frontend', plugins_url( 'assets/cs-code-block.js', __FILE__ ), [ 'hljs-core' ], self::VERSION, true ); wp_register_style( 'csdt-code-block-editor', plugins_url( 'assets/cs-code-block-editor.css', __FILE__ ), [], self::VERSION ); wp_register_script( 'cloudscale-code-block-editor-script', plugins_url( 'blocks/code/editor.js', __FILE__ ), [ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n', 'wp-data', 'wp-hooks' ], self::VERSION, true ); register_block_type( __DIR__ . '/blocks/code', [ 'render_callback' => [ __CLASS__, 'render_block' ], 'editor_script' => 'cloudscale-code-block-editor-script', ] ); } /* ================================================================== 1b. CONVERT SCRIPT ================================================================== */ /** * Enqueues the block editor auto-convert script and attaches the toast inline style. * * @since 1.5.0 * @return void */ public static function enqueue_convert_script() { wp_enqueue_script( 'csdt-code-block-convert', plugins_url( 'assets/cs-convert.js', __FILE__ ), [ 'wp-blocks', 'wp-data' ], self::VERSION, true ); wp_add_inline_style( 'csdt-code-block-editor', self::get_convert_toast_css() ); } /** * Returns the CSS string for the block editor convert-all toast notification. * * @since 1.7.17 * @return string */ private static function get_convert_toast_css(): string { return '#cs-convert-all-toast{' . 'position:fixed;bottom:24px;right:24px;z-index:999999;' . 'background:linear-gradient(135deg,#1e3a5f 0%,#0d9488 100%);' . 'color:#fff;padding:16px 20px;border-radius:10px;' . 'box-shadow:0 8px 32px rgba(0,0,0,0.3);' . 'display:flex;align-items:center;gap:16px;' . 'font-size:14px;font-weight:500;' . 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' . 'animation:cs-toast-in 0.3s ease-out;' . '}' . '#cs-convert-all-toast button{' . 'background:#fff;color:#1e3a5f;font-weight:700;border-radius:6px;' . 'padding:10px 24px;font-size:14px;border:none;white-space:nowrap;' . 'cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.15);font-family:inherit;' . '}' . '#cs-convert-all-toast button:hover{background:#f0fdf4;}' . '@keyframes cs-toast-in{' . 'from{opacity:0;transform:translateY(20px);}' . 'to{opacity:1;transform:translateY(0);}' . '}'; } /* ================================================================== 2. RENDER (shared by block + shortcode) ================================================================== */ /** * Renders a code block on the frontend. * * @since 1.0.0 * @param array $attributes Block attributes. * @param string $block_content Existing block content (unused). * @return string HTML output. */ public static function render_block( $attributes, $block_content = '' ) { self::maybe_enqueue_frontend(); self::$instance_count++; $id = 'cs-code-' . self::$instance_count; $code = isset( $attributes['content'] ) ? $attributes['content'] : ''; $lang = isset( $attributes['language'] ) ? $attributes['language'] : ''; $title = isset( $attributes['title'] ) ? $attributes['title'] : ''; $theme = isset( $attributes['theme'] ) ? $attributes['theme'] : ''; return self::build_html( $id, $code, $lang, $title, $theme ); } /** * Builds the full HTML markup for a code block. * * @since 1.0.0 * @param string $id Unique HTML element ID. * @param string $code Code content to display. * @param string $lang Language identifier for highlight.js, or empty for auto-detect. * @param string $title Optional filename or title label. * @param string $theme Per-block colour-theme override slug, or empty for site default. * @return string HTML markup. */ private static function build_html( $id, $code, $lang, $title, $theme ) { $lang_class = $lang ? 'language-' . esc_attr( $lang ) : ''; $cloudscale_link = ' Powered by CloudScale'; $title_html = ''; if ( $title ) { $title_html = '
' . esc_html( $title ) . '
'; } ob_start(); ?>
>
$default_theme, 'themePair' => $pair_slug, 'darkBg' => $pair['dark_bg'], 'darkToolbar' => $pair['dark_toolbar'], 'lightBg' => $pair['light_bg'], 'lightToolbar' => $pair['light_toolbar'], ] ); } /* ================================================================== 3. SHORTCODE [csdt_devtools_code] ================================================================== */ /** * Registers the [csdt_devtools_code] shortcode. * * @since 1.0.0 * @return void */ public static function register_shortcode() { add_shortcode( 'csdt_devtools_code', [ __CLASS__, 'render_shortcode' ] ); } /** * Renders the [csdt_devtools_code] shortcode. * * @since 1.0.0 * @param array $atts Shortcode attributes. * @param string|null $content Shortcode content. * @return string HTML output. */ public static function render_shortcode( $atts, $content = null ) { $atts = shortcode_atts( [ 'lang' => '', 'theme' => '', 'title' => '', ], $atts, 'csdt_devtools_code' ); $code = self::decode_shortcode_content( $content ); return self::render_block( [ 'content' => $code, 'language' => $atts['lang'], 'title' => $atts['title'], 'theme' => $atts['theme'], ] ); } /** * Decodes WordPress-mangled HTML entities and line breaks from shortcode content. * * @since 1.0.0 * @param string|null $content Raw shortcode content. * @return string Plain-text code with entities decoded. */ private static function decode_shortcode_content( $content ) { $content = preg_replace( '#^

|

$#i', '', trim( $content ) ); $content = str_replace( [ '
', '
', '
', '“', '”', '‘', '’', ' ', '&' ], [ "\n", "\n", "\n", '"', '"', "'", "'", ' ', '&' ], $content ); $content = html_entity_decode( $content, ENT_QUOTES, 'UTF-8' ); return trim( $content ); } /* ================================================================== 4. SETTINGS ================================================================== */ /** * 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', [ 'type' => 'string', 'sanitize_callback' => function ( $val ) { return in_array( $val, [ 'dark', 'light' ] ) ? $val : 'dark'; }, 'default' => 'dark', ] ); $valid_themes = array_keys( self::get_theme_registry() ); register_setting( 'csdt_devtools_code_settings', 'csdt_devtools_code_theme_pair', [ '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', [ 'type' => 'string', 'sanitize_callback' => function ( $val ) { return '0' === $val ? '0' : '1'; }, 'default' => '1', ] ); // Login security settings register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_login_hide_enabled', [ 'type' => 'string', 'sanitize_callback' => function ( $v ) { return '1' === $v ? '1' : '0'; }, 'default' => '0', ] ); register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_login_slug', [ 'type' => 'string', 'sanitize_callback' => function ( $v ) { $slug = sanitize_title( $v ); // Disallow WP reserved slugs $reserved = [ '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', [ 'type' => 'string', 'sanitize_callback' => function ( $v ) { return in_array( $v, [ 'off', 'email', 'totp' ], true ) ? $v : 'off'; }, 'default' => 'off', ] ); register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_2fa_force_admins', [ 'type' => 'string', 'sanitize_callback' => function ( $v ) { return '1' === $v ? '1' : '0'; }, 'default' => '0', ] ); register_setting( 'csdt_devtools_login_settings', 'csdt_devtools_2fa_grace_logins', [ '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', [ 'type' => 'string', 'sanitize_callback' => static function ( $v ) { $valid = [ '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', [ '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', [ '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', [ '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', [ 'type' => 'string', 'sanitize_callback' => static function ( $v ) { return $v === '1' ? '1' : '0'; }, 'default' => '0', ] ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_host', [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ] ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_port', [ 'type' => 'integer', 'sanitize_callback' => static function ( $v ) { $v = absint( $v ); return $v > 0 ? $v : 587; }, 'default' => 587, ] ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_encryption', [ 'type' => 'string', 'sanitize_callback' => static function ( $v ) { return in_array( $v, [ 'tls', 'ssl', 'none' ], true ) ? $v : 'tls'; }, 'default' => 'tls', ] ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_auth', [ 'type' => 'string', 'sanitize_callback' => static function ( $v ) { return $v === '1' ? '1' : '0'; }, 'default' => '1', ] ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_user', [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ] ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_pass', [ 'type' => 'string', 'sanitize_callback' => static function ( $v ) { return $v; }, 'default' => '', ] ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_from_email', [ 'type' => 'string', 'sanitize_callback' => 'sanitize_email', 'default' => '', ] ); register_setting( 'csdt_devtools_smtp_settings', 'csdt_devtools_smtp_from_name', [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ] ); } /* ================================================================== 5. COMBINED TOOLS PAGE (Code Block Migrator + SQL Command) ================================================================== */ /** * Adds the combined Tools page to the WordPress admin menu. * * @since 1.6.0 * @return void */ /** * Redirects legacy ?page=cloudscale-code-sql URLs to the new slug. * * @since 1.8.56 * @return void */ /** * Redirects the old help page URL to the current one. * * @since 1.8.56 * @return void */ public static function redirect_legacy_help_url() { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : ''; if ( strpos( $uri, 'code-block-help' ) !== false ) { wp_redirect( home_url( '/wordpress-plugin-help/cloudscale-devtools-help/' ), 301 ); exit; } } public static function redirect_legacy_slug() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['page'] ) && $_GET['page'] === 'cloudscale-code-sql' ) { $args = $_GET; $args['page'] = self::TOOLS_SLUG; wp_safe_redirect( add_query_arg( $args, admin_url( 'tools.php' ) ) ); exit; } } public static function add_tools_page() { add_management_page( 'CloudScale Cyber and Devtools', '🌩️ Cyber and Devtools', 'manage_options', self::TOOLS_SLUG, [ __CLASS__, 'render_tools_page' ] ); } /** * Conditionally enqueues admin assets on the plugin tools page only. * * @since 1.6.0 * @param string $hook Current admin page hook suffix. * @return void */ public static function enqueue_admin_assets( $hook ) { if ( $hook !== 'tools_page_' . self::TOOLS_SLUG ) { return; } // Tabs CSS wp_enqueue_style( 'csdt-admin-tabs', plugins_url( 'assets/cs-admin-tabs.css', __FILE__ ), [], self::VERSION ); // Explain modal description styling — scoped to .cs-explain-desc. wp_add_inline_style( 'csdt-admin-tabs', self::get_explain_modal_css() ); // Migrate CSS + JS wp_enqueue_style( 'csdt-code-migrate', plugins_url( 'assets/cs-code-migrate.css', __FILE__ ), [], self::VERSION ); wp_enqueue_script( 'csdt-code-migrate', plugins_url( 'assets/cs-code-migrate.js', __FILE__ ), [], self::VERSION, true ); wp_localize_script( 'csdt-code-migrate', 'csdtDevtoolsMigrate', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::MIGRATE_NONCE ), ] ); // Settings save JS wp_enqueue_script( 'csdt-admin-settings', plugins_url( 'assets/cs-admin-settings.js', __FILE__ ), [], self::VERSION, true ); wp_localize_script( 'csdt-admin-settings', 'csdtDevtoolsAdminSettings', [ 'nonce' => wp_create_nonce( 'csdt_devtools_code_settings_inline' ), ] ); // SQL editor JS wp_enqueue_script( 'csdt-sql-editor', plugins_url( 'assets/cs-sql-editor.js', __FILE__ ), [], self::VERSION, true ); wp_localize_script( 'csdt-sql-editor', 'csdtDevtoolsSqlEditor', [ 'nonce' => wp_create_nonce( 'csdt_devtools_sql_nonce' ), ] ); // Login security JS (only loaded on the login tab) $active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'home'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( $active_tab === 'login' ) { wp_enqueue_script( 'csdt-qrcode', plugins_url( 'assets/qrcode.min.js', __FILE__ ), [], self::VERSION, true ); wp_enqueue_script( 'csdt-login', plugins_url( 'assets/cs-login.js', __FILE__ ), [ 'csdt-qrcode' ], self::VERSION, true ); wp_localize_script( 'csdt-login', 'csdtDevtoolsLogin', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'csdt_devtools_login_nonce' ), 'currentUser' => get_current_user_id(), 'mailTabUrl' => admin_url( 'tools.php?page=' . self::TOOLS_SLUG . '&tab=mail' ), ] ); wp_enqueue_script( 'csdt-passkey', plugins_url( 'assets/cs-passkey.js', __FILE__ ), [ 'csdt-login' ], self::VERSION, true ); wp_enqueue_script( 'csdt-test-accounts', plugins_url( 'assets/cs-test-accounts.js', __FILE__ ), [ 'csdt-login' ], self::VERSION, true ); wp_localize_script( 'csdt-test-accounts', 'csdtTestAccounts', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'csdt_devtools_login_nonce' ), 'accounts' => self::get_active_test_accounts(), ] ); } if ( $active_tab === 'mail' ) { wp_enqueue_script( 'csdt-smtp', plugins_url( 'assets/cs-smtp.js', __FILE__ ), [], self::VERSION, true ); wp_localize_script( 'csdt-smtp', 'csdtDevtoolsSmtp', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::SMTP_NONCE ), 'testTo' => wp_get_current_user()->user_email, ] ); } if ( $active_tab === '404' ) { wp_enqueue_script( 'csdt-404-admin', plugins_url( 'assets/cs-404-admin.js', __FILE__ ), [], self::VERSION, true ); wp_localize_script( 'csdt-404-admin', 'csdtDevtools404', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'csdt_devtools_404_settings' ), 'custom_404' => get_option( self::CUSTOM_404_OPTION, 1 ) ? 1 : 0, 'scheme' => get_option( self::SCHEME_404_OPTION, 'ocean' ), 'previewUrl' => home_url( '/this-page-does-not-exist' ), ] ); } if ( $active_tab === 'security' ) { wp_enqueue_script( 'csdt-vuln-scan', plugins_url( 'assets/cs-vuln-scan.js', __FILE__ ), [], self::VERSION, true ); $saved_model = get_option( 'csdt_devtools_security_model', '_auto' ); $saved_deep_model = get_option( 'csdt_devtools_deep_scan_model', '_auto_deep' ); $saved_prompt = get_option( 'csdt_devtools_security_prompt', '' ); $saved_provider = get_option( 'csdt_devtools_ai_provider', 'anthropic' ); $api_key = get_option( 'csdt_devtools_anthropic_key', '' ); $gemini_key = get_option( 'csdt_devtools_gemini_key', '' ); $masked_key = $api_key ? '••••••••' . substr( $api_key, -4 ) : ''; $masked_gemini = $gemini_key ? '••••••••' . substr( $gemini_key, -4 ) : ''; $has_key = $saved_provider === 'gemini' ? ! empty( $gemini_key ) : ! empty( $api_key ); wp_localize_script( 'csdt-vuln-scan', 'csdtVulnScan', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'csdt_devtools_security_nonce' ), 'hasKey' => $has_key, 'savedProvider' => $saved_provider, 'maskedKey' => $masked_key, 'maskedGemini' => $masked_gemini, 'savedModel' => $saved_model, 'savedDeepModel' => $saved_deep_model, 'savedPrompt' => $saved_prompt, 'defaultPrompt' => self::default_security_prompt(), 'scanHistory' => get_option( 'csdt_scan_history', [] ), ] ); } if ( $active_tab === 'thumbnails' ) { $thumb_js = plugin_dir_path( __FILE__ ) . 'assets/cs-thumbnails.js'; wp_enqueue_script( 'csdt-thumbnails', plugins_url( 'assets/cs-thumbnails.js', __FILE__ ), [], self::VERSION, true ); wp_localize_script( 'csdt-thumbnails', 'csdtDevtoolsThumbs', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'csdt_devtools_thumbnails' ), 'siteUrl' => home_url( '/' ), ] ); // Thumbnails-tab-specific CSS — injected as inline style to avoid an // extra HTTP request and keep the render method free of $src ) { $path = $src['path']; if ( ! file_exists( $path ) ) { $statuses[ $key ] = [ 'status' => 'not_found' ]; } elseif ( ! is_readable( $path ) ) { $statuses[ $key ] = [ 'status' => 'permission_denied' ]; } elseif ( filesize( $path ) === 0 ) { $statuses[ $key ] = [ 'status' => 'empty' ]; } else { $statuses[ $key ] = [ 'status' => 'ok' ]; } } wp_send_json_success( $statuses ); } public static function ajax_server_logs_fetch(): void { check_ajax_referer( 'csdt_devtools_server_logs', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $source_key = isset( $_POST['source'] ) ? sanitize_key( $_POST['source'] ) : ''; $lines_req = min( 1000, max( 50, (int) ( $_POST['lines'] ?? 300 ) ) ); $sources = self::get_log_sources(); if ( ! isset( $sources[ $source_key ] ) ) { wp_send_json_error( [ 'message' => 'Unknown source.' ] ); return; } $path = $sources[ $source_key ]['path']; if ( ! file_exists( $path ) ) { wp_send_json_success( [ 'status' => 'not_found', 'lines' => [], 'count' => 0, 'path' => $path ] ); return; } if ( ! is_readable( $path ) ) { wp_send_json_success( [ 'status' => 'permission_denied', 'lines' => [], 'count' => 0, 'path' => $path ] ); return; } // Read last N lines efficiently without loading the entire file // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $handle = @fopen( $path, 'rb' ); if ( ! $handle ) { wp_send_json_success( [ 'status' => 'error', 'lines' => [], 'count' => 0, 'path' => $path ] ); return; } fseek( $handle, 0, SEEK_END ); $file_size = ftell( $handle ); $chunk_size = 65536; // 64 KB chunks, read from end $buffer = ''; $pos = $file_size; $line_count = 0; while ( $pos > 0 && $line_count < $lines_req + 1 ) { $read = min( $chunk_size, $pos ); $pos -= $read; fseek( $handle, $pos ); $buffer = fread( $handle, $read ) . $buffer; $line_count = substr_count( $buffer, "\n" ); } fclose( $handle ); $all_lines = explode( "\n", $buffer ); // Remove trailing empty line if ( end( $all_lines ) === '' ) { array_pop( $all_lines ); } $lines = array_slice( $all_lines, -$lines_req ); wp_send_json_success( [ 'status' => count( $lines ) > 0 ? 'ok' : 'empty', 'lines' => $lines, 'count' => count( $lines ), 'path' => $path, ] ); } private static function render_migrate_panel() { ?>
🔄 CODE BLOCK MIGRATOR 'Scan Posts', 'rec' => 'Informational', 'html' => 'Scans all posts and pages for legacy WordPress wp:code and wp:preformatted blocks that can be upgraded to CloudScale Code Blocks with full syntax highlighting.' ], [ 'name' => 'Preview', 'rec' => 'Recommended', 'html' => 'Shows a side-by-side before/after diff for each post before committing any changes, so you can review exactly what will be converted.' ], [ 'name' => 'Migrate', 'rec' => 'Optional', 'html' => 'Converts detected legacy blocks to CloudScale format. Each post is saved with the converted markup.

Take a backup first — this cannot be undone without one.' ], ] ); ?>

' . esc_html__( 'Scan Posts', 'cloudscale-devtools' ) . '' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- format string is hardcoded, only %s is user-visible and escaped above ?>

prefix; ?>
🗄️ SQL Query  ·  ⚠ 'Read-only', 'rec' => 'Informational', 'html' => 'Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries are permitted. Write operations (INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE) are blocked to prevent accidental data loss.' ], [ 'name' => 'Table Prefix', 'rec' => 'Informational', 'html' => 'Your WordPress table prefix is shown in the header. Use it in your queries, e.g. SELECT * FROM wp_posts LIMIT 10 or SELECT * FROM wp_options WHERE option_name = \'siteurl\'.' ], [ 'name' => 'Quick Queries', 'rec' => 'Recommended', 'html' => 'Use the preset queries below for common diagnostics without needing to write SQL from scratch. Press Enter or Ctrl+Enter to run a query, Shift+Enter to insert a newline.' ], ] ); ?>
📊 'Table output', 'rec' => 'Informational', 'desc' => 'Query results are shown in a scrollable table with column headers. HTTP URLs in cells are highlighted for easy identification.' ], [ 'name' => 'Row count / timing', 'rec' => 'Informational', 'desc' => 'The header shows the number of rows returned and the query execution time in milliseconds.' ], ] ); ?>
'Health & Diagnostics', 'rec' => 'Recommended', 'html' => 'MySQL version, table sizes, connection limits, and WordPress table row counts at a glance. Good first check when diagnosing slow sites.' ], [ 'name' => 'Content Summary', 'rec' => 'Informational', 'html' => 'Counts posts by type and status, revisions, auto-drafts, spam comments, and users for a quick content audit. Useful before a site migration.' ], [ 'name' => 'Cleanup Candidates', 'rec' => 'Optional', 'html' => 'Identifies orphaned postmeta rows, expired transients, and bloated wp_options autoloaded rows that may be slowing down your database.' ], [ 'name' => 'Security Checks', 'rec' => 'Optional', 'html' => 'Looks for http:// (non-HTTPS) URLs or stale IP addresses in wp_options and post GUIDs — common indicators of old content or unfinished HTTP→HTTPS migrations.' ], ] ); ?>

🏥

📈

🧹

🔍

🔒 HIDE LOGIN URL 'Enable Hide Login', 'rec' => 'Recommended', 'html' => 'Moves your login page to a secret URL. Direct requests to /wp-login.php return a 404, stopping bots and credential-stuffing scripts from even finding the login form.' ], [ 'name' => 'Custom Login Path', 'rec' => 'Recommended', 'html' => 'The URL slug that serves your login page, e.g. /my-secret-login. Use letters, numbers, and hyphens only.

Save the full login URL somewhere safe — you will need it to log in after enabling this feature.' ], ] ); ?>
__( 'WordPress default (2 days / 14 days with Remember Me)', 'cloudscale-devtools' ), '1' => __( '1 day', 'cloudscale-devtools' ), '7' => __( '7 days', 'cloudscale-devtools' ), '14' => __( '14 days', 'cloudscale-devtools' ), '30' => __( '30 days', 'cloudscale-devtools' ), '90' => __( '90 days', 'cloudscale-devtools' ), '365' => __( '1 year', 'cloudscale-devtools' ), ]; ?>
⏱ SESSION DURATION 'Session Lifetime', 'rec' => 'Recommended', 'html' => 'Sets how long the WordPress auth cookie stays valid before the user must log in again.

' ], [ 'name' => 'Remember Me & timing', 'rec' => 'Note', 'html' => 'When a custom duration is set, the Remember Me checkbox is overridden — all new sessions get the same lifetime regardless.

Changing this setting only affects new logins. Users already logged in keep their current session cookie until it expires or they log out.' ], ] ); ?>
🔒 BRUTE-FORCE PROTECTION 'How it works', 'rec' => 'Info', 'html' => 'After N consecutive failed login attempts for the same username, the account is locked for the configured duration. The lock is per-username, not per-IP — it also stops distributed attacks spread across multiple IPs. The counter resets automatically after the lockout period expires.' ], [ 'name' => 'Failed attempts', 'rec' => 'Recommended', 'html' => 'To unlock an account immediately, delete the transient key csdt_devtools_lockout_{username} from the database.' ], [ 'name' => 'Lockout period', 'rec' => 'Recommended', 'html' => 'Default is 5 minutes. The lock lifts automatically — no admin action needed.

' ], ] ); ?>
📊
👤 YOUR 2FA SETUP user_login ); ?> 'Authenticator App (TOTP)', 'rec' => 'Recommended', 'html' => 'Generates a 6-digit code every 30 seconds using a TOTP app. Works offline and is the most secure 2FA method.

' ], [ 'name' => 'Email Code', 'rec' => 'Optional', 'html' => 'Sends a one-time code to your account email on each login. Simpler to set up but depends on email deliverability — if your site\'s outgoing email is unreliable, use an authenticator app instead.' ], [ 'name' => 'Passkey', 'rec' => 'Recommended', 'html' => 'Uses Face ID, Touch ID, Windows Hello, or a hardware security key (YubiKey, etc.) as your second factor. Register a passkey in the Passkeys panel, then select this method here.' ], ] ); ?>
📧
📬
📱
🔑 TWO-FACTOR AUTHENTICATION 'Off', 'rec' => 'Not Recommended', 'html' => 'Disables 2FA site-wide. Passwords alone are vulnerable to phishing and brute-force attacks — not recommended for any public site.' ], [ 'name' => 'Email Code', 'rec' => 'Optional', 'html' => 'Requires users to enter a code sent to their email after each password login. Works out of the box with no app required — but depends on your site\'s outgoing email working reliably.' ], [ 'name' => 'Authenticator App (TOTP)', 'rec' => 'Recommended', 'html' => 'Each user configures their own TOTP app (Google Authenticator, Authy, 1Password). Most secure option — works offline, no email dependency.' ], [ 'name' => 'Force 2FA for Admins', 'rec' => 'Recommended', 'html' => 'Blocks administrator-role users from accessing the dashboard until they have set up 2FA. Strongly recommended on any multi-user site.' ], [ 'name' => 'Grace Logins', 'rec' => 'Advanced', 'html' => 'Allows a user to log in up to N times before 2FA is enforced. The counter is per-user and never resets automatically. Default is 0 (2FA required from the first login).

Tip for automated test accounts: set to 1. Tools like Playwright cannot complete a real 2FA challenge — one grace login lets a test account authenticate for setup steps without disabling 2FA site-wide.' ], ] ); ?>
🔑 PASSKEYS (WEBAUTHN) 'What is a passkey?', 'rec' => 'Informational', 'html' => 'A passkey is a cryptographic credential stored on your device. It replaces passwords with biometrics (Face ID, Touch ID, Windows Hello) or hardware keys (YubiKey, etc.). No secret is ever sent over the network — the private key never leaves your device.' ], [ 'name' => 'Registering a passkey', 'rec' => 'Recommended', 'html' => 'Click + Add Passkey, give it a name (e.g. iPhone 16 or MacBook Touch ID), then follow your device\'s biometric prompt.

Register multiple passkeys for different devices so you always have a backup.' ], [ 'name' => 'Test', 'rec' => 'Optional', 'html' => 'Verifies a passkey is working correctly without logging out. Use this after registering a new passkey to confirm the credential round-trips successfully.' ], [ 'name' => 'Remove', 'rec' => 'Optional', 'html' => 'Deletes the passkey from your account. You can re-register it at any time — the device credential itself is not affected.' ], ] ); ?>
🧪
>

suppress_errors( true ); $start = microtime( true ); // prepare() cannot be applied to a free-form admin SQL tool — the entire // query is the user's input, leaving no placeholders to bind. Safety is // provided by is_safe_query() (read-only keywords + no semicolons), // manage_options capability gate, and nonce verification above. // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results = $wpdb->get_results( $sql, ARRAY_A ); $elapsed = round( ( microtime( true ) - $start ) * 1000, 2 ); $error = $wpdb->last_error; $wpdb->suppress_errors( false ); if ( $error ) { wp_send_json_error( $error ); } wp_send_json_success( [ 'rows' => $results, 'count' => count( $results ), 'elapsed' => $elapsed, ] ); } /* ================================================================== 6a. Settings AJAX save ================================================================== */ /** * AJAX handler: saves the colour theme and default mode settings. * * @since 1.6.0 * @return void Sends JSON response and exits. */ public static function ajax_save_theme_setting(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Forbidden' ); } if ( ! check_ajax_referer( 'csdt_devtools_code_settings_inline', 'nonce', false ) ) { wp_send_json_error( 'Bad nonce' ); } $theme = isset( $_POST['theme'] ) ? sanitize_text_field( wp_unslash( $_POST['theme'] ) ) : 'dark'; if ( ! in_array( $theme, [ 'dark', 'light' ], true ) ) { $theme = 'dark'; } update_option( 'csdt_devtools_code_default_theme', $theme ); $valid_pairs = array_keys( self::get_theme_registry() ); $pair = isset( $_POST['theme_pair'] ) ? sanitize_text_field( wp_unslash( $_POST['theme_pair'] ) ) : 'atom-one'; if ( ! in_array( $pair, $valid_pairs, true ) ) { $pair = 'atom-one'; } update_option( 'csdt_devtools_code_theme_pair', $pair ); $perf_enabled = isset( $_POST['csdt_devtools_perf_monitor_enabled'] ) && '1' === $_POST['csdt_devtools_perf_monitor_enabled'] ? '1' : '0'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized update_option( 'csdt_devtools_perf_monitor_enabled', $perf_enabled ); wp_send_json_success( [ 'theme' => $theme, 'theme_pair' => $pair, 'perf_enabled' => $perf_enabled ] ); } /* ================================================================== 7. MIGRATION TOOL ================================================================== */ /* ================================================================== 7a. Migration: Block conversion logic ================================================================== */ /** * Returns the regex pattern that matches legacy wp:code blocks. * * @since 1.5.0 * @return string PCRE pattern string. */ private static function get_code_pattern() { return '#\s*' . ']*class="[^"]*wp-block-code[^"]*"[^>]*>\s*' . ']*)>(.*?)\s*' . '\s*' . '#s'; } /** * Returns the regex pattern that matches legacy wp:preformatted blocks. * * @since 1.5.0 * @return string PCRE pattern string. */ private static function get_preformatted_pattern() { return '#\s*' . ']*class="[^"]*wp-block-preformatted[^"]*"[^>]*>(.*?)\s*' . '#s'; } /** * Converts a matched legacy wp:code block into a CloudScale block comment. * * @since 1.5.0 * @param array $matches preg_replace_callback match array. * @return string New block comment markup. */ private static function convert_code_block( $matches ) { $block_json = $matches[2] ?? ''; $code_attrs = $matches[3] ?? ''; $code_content = $matches[4] ?? ''; $code = html_entity_decode( $code_content, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $code = rtrim( $code, "\n" ); $lang = ''; if ( ! empty( $block_json ) ) { $json = json_decode( $block_json, true ); if ( isset( $json['language'] ) ) { $lang = $json['language']; } } if ( empty( $lang ) && preg_match( '/lang=["\']([^"\']+)["\']/', $code_attrs, $lm ) ) { $lang = $lm[1]; } if ( empty( $lang ) && preg_match( '/class=["\'][^"\']*language-([a-zA-Z0-9+#._-]+)/', $code_attrs, $lm ) ) { $lang = $lm[1]; } return self::build_migrate_block( $code, $lang ); } /** * Converts a matched legacy wp:preformatted block into a CloudScale block comment. * * @since 1.5.0 * @param array $matches preg_replace_callback match array. * @return string New block comment markup. */ private static function convert_preformatted_block( $matches ) { $code_content = $matches[2] ?? ''; $code = str_ireplace( [ '
', '
', '
' ], "\n", $code_content ); $code = strip_tags( $code ); $code = html_entity_decode( $code, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $code = rtrim( $code, "\n" ); return self::build_migrate_block( $code, '' ); } /** * Builds a CloudScale block comment from code content and an optional language slug. * * @since 1.5.0 * @param string $code Code content. * @param string $lang Language identifier, or empty string for auto-detect. * @return string Block comment markup. */ private static function build_migrate_block( $code, $lang ) { $attrs = [ 'content' => $code ]; if ( ! empty( $lang ) ) { $attrs['language'] = $lang; } $attrs_json = wp_json_encode( $attrs ); return ''; } /** * Counts the total number of legacy code blocks in post content. * * @since 1.5.0 * @param string $content Post content. * @return int Number of legacy code blocks found. */ private static function count_migrate_blocks( $content ) { $count = preg_match_all( self::get_code_pattern(), $content, $m ); $count += preg_match_all( self::get_preformatted_pattern(), $content, $m ); return $count; } /** * Converts all legacy code and preformatted blocks in post content to CloudScale blocks. * * @since 1.5.0 * @param string $content Post content. * @return string Post content with legacy blocks replaced. */ private static function convert_content( $content ) { $content = preg_replace_callback( self::get_code_pattern(), [ __CLASS__, 'convert_code_block' ], $content ); $content = preg_replace_callback( self::get_preformatted_pattern(), [ __CLASS__, 'convert_preformatted_block' ], $content ); return $content; } /** * Truncates a string to a maximum byte length, appending an ellipsis when cut. * * @since 1.5.0 * @param string $str String to truncate. * @param int $max Maximum byte length. * @return string Truncated string. */ private static function truncate_block( $str, $max ) { if ( strlen( $str ) <= $max ) { return $str; } return substr( $str, 0, $max ) . "\n... [truncated]"; } /** * Builds a before/after preview array for all legacy blocks in post content. * * @since 1.5.0 * @param string $content Post content. * @return array> Preview data for each block found. */ private static function get_migration_preview( $content ) { $blocks = []; preg_match_all( self::get_code_pattern(), $content, $matches, PREG_SET_ORDER ); foreach ( $matches as $match ) { $original = $match[0]; $converted = self::convert_code_block( $match ); $lang = ''; if ( preg_match( '/"language":"([^"]+)"/', $converted, $lm ) ) { $lang = $lm[1]; } $code_preview = html_entity_decode( $match[4], ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $first_line = strtok( $code_preview, "\n" ); if ( strlen( $first_line ) > 80 ) { $first_line = substr( $first_line, 0, 80 ) . '...'; } $blocks[] = [ 'index' => count( $blocks ) + 1, 'type' => 'wp:code', 'language' => $lang ?: '(auto detect)', 'first_line' => $first_line, 'original' => htmlspecialchars( self::truncate_block( $original, 500 ) ), 'converted' => htmlspecialchars( self::truncate_block( $converted, 500 ) ), ]; } preg_match_all( self::get_preformatted_pattern(), $content, $matches, PREG_SET_ORDER ); foreach ( $matches as $match ) { $original = $match[0]; $converted = self::convert_preformatted_block( $match ); $code_raw = str_ireplace( [ '
', '
', '
' ], "\n", $match[2] ); $code_raw = strip_tags( $code_raw ); $code_raw = html_entity_decode( $code_raw, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $first_line = strtok( $code_raw, "\n" ); if ( strlen( $first_line ) > 80 ) { $first_line = substr( $first_line, 0, 80 ) . '...'; } $blocks[] = [ 'index' => count( $blocks ) + 1, 'type' => 'wp:preformatted', 'language' => '(auto detect)', 'first_line' => $first_line, 'original' => htmlspecialchars( self::truncate_block( $original, 500 ) ), 'converted' => htmlspecialchars( self::truncate_block( $converted, 500 ) ), ]; } return $blocks; } /* ================================================================== 7b. Migration: AJAX handlers ================================================================== */ /** * AJAX handler: scans all posts for legacy code blocks and returns a list. * * @since 1.5.0 * @return void Sends JSON response and exits. */ public static function ajax_scan() { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Forbidden', 403 ); } if ( ! check_ajax_referer( self::MIGRATE_NONCE, 'nonce', false ) ) { wp_send_json_error( 'Bad nonce', 403 ); } global $wpdb; // Static query — no user data; $wpdb->posts is a trusted WP core property. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared $posts = $wpdb->get_results( "SELECT ID, post_title, post_status, post_date, post_content FROM {$wpdb->posts} WHERE post_type IN ('post', 'page') AND post_status != 'trash' AND ( post_content LIKE '%%' OR post_content LIKE '%%' OR post_content LIKE '% ⚡ CloudScale
Code & Security
' . $inner_html . '

You\'re receiving this because you have an account on this site.
If you didn\'t request this, you can safely ignore it.

'; } /** * HTML email body for the 2FA one-time login code. * * @param string $display_name User's display name. * @param string $site Site name (already decoded). * @param string $otp 6-digit code. * @return string Full HTML email. */ private static function email_html_otp( string $display_name, string $site, string $otp ): string { $inner = '

Hi ' . esc_html( $display_name ) . ',

Your one-time login code for ' . esc_html( $site ) . ':

' . esc_html( $otp ) . '

⏰ This code expires in 10 minutes.


⚠️ If you did not attempt to log in, please change your password immediately.

'; return self::email_html_wrap( $inner ); } /** * HTML email body for the email 2FA verification link. * * @param string $display_name User's display name. * @param string $site Site name (already decoded). * @param string $verify_url Full verification URL. * @return string Full HTML email. */ private static function email_html_verify( string $display_name, string $site, string $verify_url ): string { $inner = '

Hi ' . esc_html( $display_name ) . ',

You requested to enable Email Two-Factor Authentication on ' . esc_html( $site ) . '. Click the button below to verify your email address and activate 2FA.

✔️ Verify Email & Activate 2FA

⏰ This link expires in 1 hour.

Or copy this URL: ' . esc_html( $verify_url ) . '

'; return self::email_html_wrap( $inner ); } /** * Handles the email verification callback link. * Runs on admin_init — activates email 2FA when a valid token is present. * * @since 1.9.4 * @return void */ public static function email_2fa_confirm_check(): void { if ( ! isset( $_GET['csdt_devtools_email_verify'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } if ( ! is_user_logged_in() ) { return; } $token = sanitize_text_field( wp_unslash( $_GET['csdt_devtools_email_verify'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $transient = self::EMAIL_VERIFY_TRANSIENT . $token; $data = get_transient( $transient ); if ( ! $data || empty( $data['user_id'] ) ) { // Expired or invalid — redirect back without activating. wp_safe_redirect( admin_url( 'tools.php?page=' . self::TOOLS_SLUG . '&tab=login&email_verify_expired=1' ) ); exit; } $user_id = (int) $data['user_id']; // Verify the token belongs to the currently logged-in user. if ( $user_id !== get_current_user_id() ) { wp_safe_redirect( admin_url( 'tools.php?page=' . self::TOOLS_SLUG . '&tab=login' ) ); exit; } // If already activated on a previous click (e.g. email client prefetch), just redirect to success. if ( ! empty( $data['activated'] ) ) { wp_safe_redirect( admin_url( 'tools.php?page=' . self::TOOLS_SLUG . '&tab=login&email_2fa_activated=1' ) ); exit; } // Activate email 2FA. update_user_meta( $user_id, 'csdt_devtools_2fa_email_enabled', '1' ); delete_user_meta( $user_id, 'csdt_devtools_email_verify_pending' ); // Mark the transient as used (keep it alive for 10 min so re-clicks show success, not "expired"). set_transient( $transient, [ 'user_id' => $user_id, 'activated' => true ], 600 ); // Security state changed — destroy all other open sessions for this user. wp_destroy_other_sessions(); wp_safe_redirect( admin_url( 'tools.php?page=' . self::TOOLS_SLUG . '&tab=login&email_2fa_activated=1' ) ); exit; } /** * Destroys all other sessions when a user resets their password. * * @since 1.9.4 * @param \WP_User $user The user whose password was reset. * @return void */ public static function on_password_reset( \WP_User $user ): void { // Destroy all sessions so the newly-reset password must be used everywhere. WP_Session_Tokens::get_instance( $user->ID )->destroy_all(); } /** * Destroys all other sessions when a user's email or password changes. * * @since 1.9.4 * @param int $user_id Updated user ID. * @param \WP_User $old_userdata User data before the update. * @return void */ public static function on_profile_update( int $user_id, \WP_User $old_userdata ): void { $new_user = get_userdata( $user_id ); if ( ! $new_user ) { return; } $email_changed = $old_userdata->user_email !== $new_user->user_email; $password_changed = $old_userdata->user_pass !== $new_user->user_pass; if ( $email_changed || $password_changed ) { // If the currently-logged-in user changed their own account, keep their // current session alive; destroy all others. For admin-changed accounts // (different user_id) destroy every session outright. if ( get_current_user_id() === $user_id ) { wp_destroy_other_sessions(); } else { WP_Session_Tokens::get_instance( $user_id )->destroy_all(); } } } /* ================================================================== SMTP — MAIL / SMTP CONFIGURATION ================================================================== */ /** * Renders the Mail / SMTP settings panel. * * @since 1.9.4 * @return void */ private static function render_smtp_panel(): void { $enabled = get_option( 'csdt_devtools_smtp_enabled', '0' ) === '1'; $host = get_option( 'csdt_devtools_smtp_host', '' ); $port = get_option( 'csdt_devtools_smtp_port', 587 ); $encryption = get_option( 'csdt_devtools_smtp_encryption', 'tls' ); $auth = get_option( 'csdt_devtools_smtp_auth', '1' ) === '1'; $user = get_option( 'csdt_devtools_smtp_user', '' ); $has_pass = '' !== get_option( 'csdt_devtools_smtp_pass', '' ); $from_email = get_option( 'csdt_devtools_smtp_from_email', '' ); $from_name = get_option( 'csdt_devtools_smtp_from_name', '' ); ?>
📧 SMTP CONFIGURATION 'Enable SMTP', 'rec' => 'Recommended', 'desc' => 'Routes all WordPress emails through your own SMTP server instead of the server\'s PHP mail() function. This dramatically improves deliverability and lets you use Gmail, Outlook, or any hosted mail service.', ], [ 'name' => 'App Passwords', 'rec' => 'Note', 'html' => 'Gmail and most modern providers require an App Password — a separate password generated specifically for third-party apps — rather than your regular account password. This is required when two-factor authentication (2FA) is enabled on the account.' . '

' . 'Generate an App Password from your provider\'s security settings and paste it into the Password field below:' . '

' . 'Gmailsupport.google.com/accounts/answer/185833
' . 'Outlook / Microsoft 365support.microsoft.com — App passwords
' . 'Yahoo Mailhelp.yahoo.com — Generate app passwords
' . 'Zoho Mailzoho.com/mail/help — Two-factor authentication', ], [ 'name' => 'Send Test Email', 'rec' => 'Note', 'desc' => 'Sends a test message to your admin email using your current saved settings. If it fails, check that your host, port, and encryption match your provider\'s requirements (port 587 + TLS is the safest default), and that you\'re using an App Password where required.', ], ] ); ?>
•••••••• 
✉️ FROM ADDRESS 'From Name & Email', 'rec' => 'Recommended', 'desc' => 'Sets the sender name and email address that recipients see in their inbox. Leave blank to keep WordPress defaults (usually the site name and admin email).' ], [ 'name' => 'SMTP Authorisation', 'rec' => 'Note', 'desc' => 'The From Email must be authorised to send via your SMTP account. Using an address your SMTP provider doesn\'t recognise will cause emails to bounce or land in spam.' ], ] ); ?>
📋 EMAIL ACTIVITY LOG
' . esc_html__( 'No emails logged yet. Emails are recorded here as soon as WordPress sends them.', 'cloudscale-devtools' ) . '

'; return; } ?>
$entry ) : $bg = $i % 2 === 0 ? '#fff' : '#fafafa'; $status = $entry['status'] ?? 'unknown'; if ( $status === 'sent' ) { $badge = '✓ Sent'; } elseif ( $status === 'failed' ) { $err = ! empty( $entry['error'] ) ? ' — ' . esc_html( $entry['error'] ) : ''; $badge = '✗ Failed' . esc_html( $err ) . ''; } else { $badge = '— Unknown'; } $via = $entry['via'] ?? 'phpmail'; $via_label = $via === 'smtp' ? 'SMTP' : 'PHP mail'; ?>
65535 ) { $port = 587; } // Validate: if enabling SMTP, require a host and (if auth on) credentials. if ( $enabled === '1' ) { $errors = []; if ( $host === '' ) { $errors[] = __( 'SMTP Host is required when SMTP is enabled.', 'cloudscale-devtools' ); } if ( $auth === '1' && $user === '' ) { $errors[] = __( 'Username is required when SMTP Authentication is enabled.', 'cloudscale-devtools' ); } $existing_pass = get_option( 'csdt_devtools_smtp_pass', '' ); if ( $auth === '1' && $new_pass === '' && $existing_pass === '' ) { $errors[] = __( 'Password is required when SMTP Authentication is enabled.', 'cloudscale-devtools' ); } if ( ! empty( $errors ) ) { wp_send_json_error( implode( ' ', $errors ) ); } } update_option( 'csdt_devtools_smtp_enabled', $enabled ); update_option( 'csdt_devtools_smtp_host', $host ); update_option( 'csdt_devtools_smtp_port', $port ); update_option( 'csdt_devtools_smtp_encryption', $encryption ); update_option( 'csdt_devtools_smtp_auth', $auth ); update_option( 'csdt_devtools_smtp_user', $user ); update_option( 'csdt_devtools_smtp_from_email', $from_email ); update_option( 'csdt_devtools_smtp_from_name', $from_name ); // Only update password if the user explicitly provided one. if ( $new_pass !== '' ) { update_option( 'csdt_devtools_smtp_pass', $new_pass ); } wp_send_json_success(); } /** * AJAX: sends a test email using current SMTP settings. * * @since 1.9.4 * @return void */ public static function ajax_smtp_test(): void { check_ajax_referer( self::SMTP_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'type' => 'auth' ], 403 ); } $enabled = get_option( 'csdt_devtools_smtp_enabled', '0' ); $host = trim( (string) get_option( 'csdt_devtools_smtp_host', '' ) ); $port = (int) get_option( 'csdt_devtools_smtp_port', 587 ); $encryption = (string) get_option( 'csdt_devtools_smtp_encryption', 'tls' ); $auth = get_option( 'csdt_devtools_smtp_auth', '1' ) === '1'; $user = trim( (string) get_option( 'csdt_devtools_smtp_user', '' ) ); $pass = (string) get_option( 'csdt_devtools_smtp_pass', '' ); // ── Pre-flight checks ───────────────────────────────────────────── $issues = []; if ( $enabled !== '1' ) { $issues[] = 'SMTP is not enabled — toggle it on and save first.'; } if ( $host === '' ) { $issues[] = 'SMTP Host is empty — enter your server hostname (e.g. smtp.gmail.com).'; } if ( $port <= 0 || $port > 65535 ) { $issues[] = 'Port is invalid — use 587 (TLS), 465 (SSL), or 25 (none).'; } if ( $auth && $user === '' ) { $issues[] = 'Authentication is on but Username is empty.'; } if ( $auth && $pass === '' ) { $issues[] = 'Authentication is on but no Password is saved.'; } if ( ! empty( $issues ) ) { wp_send_json_error( [ 'type' => 'preflight', 'issues' => $issues ] ); } // ── Use PHPMailer directly so we capture real SMTP debug output ─── require_once ABSPATH . WPINC . '/PHPMailer/PHPMailer.php'; require_once ABSPATH . WPINC . '/PHPMailer/SMTP.php'; require_once ABSPATH . WPINC . '/PHPMailer/Exception.php'; $debug_log = []; $to = wp_get_current_user()->user_email; $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); try { $mail = new PHPMailer\PHPMailer\PHPMailer( true ); $mail->isSMTP(); $mail->Host = $host; $mail->Port = $port; $mail->SMTPSecure = $encryption === 'none' ? '' : $encryption; $mail->SMTPAuth = $auth; $mail->Username = $user; $mail->Password = $pass; $mail->SMTPDebug = 2; $mail->Debugoutput = static function ( string $str ) use ( &$debug_log ): void { $clean = trim( $str ); if ( $clean !== '' ) { $debug_log[] = $clean; } }; $from_email = get_option( 'csdt_devtools_smtp_from_email', '' ) ?: get_bloginfo( 'admin_email' ); $from_name = get_option( 'csdt_devtools_smtp_from_name', '' ) ?: $site; $mail->setFrom( $from_email, $from_name ); $mail->addAddress( $to ); $mail->isHTML( true ); $mail->CharSet = 'UTF-8'; $mail->Subject = sprintf( '[%s] CloudScale Cyber and Devtools — SMTP Test', $site ); $mail->Body = '

This is a test email from CloudScale Cyber and Devtools.

' . '

Your SMTP configuration is working correctly.

'; $mail->send(); wp_send_json_success( [ 'to' => $to ] ); } catch ( PHPMailer\PHPMailer\Exception $e ) { // Surface the PHPMailer error plus the last few relevant SMTP conversation lines. $filtered = array_values( array_filter( $debug_log, static function ( string $line ): bool { // Skip lines that are just raw email body content. return ! preg_match( '/^(Date:|From:|To:|Subject:|MIME|Content-|Message-ID:|X-Mailer:|--[a-zA-Z0-9]+|)/i', $line ); } ) ); wp_send_json_error( [ 'type' => 'smtp', 'message' => $e->getMessage(), 'debug' => array_slice( $filtered, -12 ), ] ); } } /** * Configures PHPMailer to use SMTP with saved settings. * Hooked onto phpmailer_init when SMTP is enabled. * * @since 1.9.4 * @param \PHPMailer\PHPMailer\PHPMailer $phpmailer PHPMailer instance (passed by reference). * @return void */ public static function phpmailer_configure( $phpmailer ): void { $phpmailer->isSMTP(); $phpmailer->Host = (string) get_option( 'csdt_devtools_smtp_host', '' ); $port = (int) get_option( 'csdt_devtools_smtp_port', 587 ); $phpmailer->Port = $port > 0 ? $port : 587; $encryption = (string) get_option( 'csdt_devtools_smtp_encryption', 'tls' ); $encryption = in_array( $encryption, [ 'tls', 'ssl', 'none' ], true ) ? $encryption : 'tls'; $phpmailer->SMTPSecure = $encryption === 'none' ? '' : $encryption; // Default auth to ON — empty/missing option means "never explicitly turned off". $auth_val = get_option( 'csdt_devtools_smtp_auth', '1' ); $phpmailer->SMTPAuth = $auth_val !== '0'; $phpmailer->Username = (string) get_option( 'csdt_devtools_smtp_user', '' ); $phpmailer->Password = (string) get_option( 'csdt_devtools_smtp_pass', '' ); $phpmailer->SMTPDebug = 0; } /** * Filter: overrides wp_mail_from with configured from email. * * @since 1.9.4 * @param string $email Default from email. * @return string */ public static function smtp_from_email( string $email ): string { $configured = get_option( 'csdt_devtools_smtp_from_email', '' ); return $configured ?: $email; } /** * Filter: overrides wp_mail_from_name with configured from name. * * @since 1.9.4 * @param string $name Default from name. * @return string */ public static function smtp_from_name( string $name ): string { $configured = get_option( 'csdt_devtools_smtp_from_name', '' ); return $configured ?: $name; } /* ================================================================== EMAIL LOG ================================================================== */ const EMAIL_LOG_OPTION = 'csdt_devtools_email_log'; const EMAIL_LOG_MAX = 100; /** * wp_mail filter — captures outgoing email details before send. * * @since 1.9.4 * @param array $args wp_mail arguments. * @return array Unchanged. */ public static function smtp_log_capture( array $args ): array { $to = $args['to'] ?? ''; if ( is_array( $to ) ) { $to = implode( ', ', $to ); } self::$smtp_log_pending = [ 'ts' => time(), 'to' => (string) $to, 'subject' => (string) ( $args['subject'] ?? '' ), 'status' => 'pending', 'error' => '', 'via' => ( get_option( 'csdt_devtools_smtp_enabled', '0' ) === '1' && '' !== trim( (string) get_option( 'csdt_devtools_smtp_host', '' ) ) ) ? 'smtp' : 'phpmail', ]; return $args; } /** * phpmailer_init (priority 5) — sets the PHPMailer action_function callback * so we receive a reliable success/failure signal after every send attempt. * * @since 1.9.4 * @param \PHPMailer\PHPMailer\PHPMailer $phpmailer PHPMailer instance. * @return void */ public static function smtp_log_set_callback( $phpmailer ): void { $phpmailer->action_function = [ __CLASS__, 'smtp_log_on_send' ]; } /** * PHPMailer action_function callback — fires after every send attempt. * * @since 1.9.4 * @param bool $is_sent Whether the send succeeded. * @param array $to Recipient addresses. * @param array $cc CC addresses (unused). * @param array $bcc BCC addresses (unused). * @param string $subject Subject line (unused — already captured). * @param string $body Message body (unused). * @param string $from Sender address (unused). * @return void */ public static function smtp_log_on_send( bool $is_sent, array $to, array $cc, array $bcc, string $subject, string $body, string $from ): void { if ( self::$smtp_log_pending === null ) { return; } $entry = self::$smtp_log_pending; $entry['status'] = $is_sent ? 'sent' : 'failed'; self::smtp_log_write( $entry ); self::$smtp_log_pending = null; } /** * wp_mail_failed action — fires when wp_mail() returns false (PHPMailer threw). * * @since 1.9.4 * @param \WP_Error $error WP_Error with PHPMailer error message. * @return void */ public static function smtp_log_on_failure( \WP_Error $error ): void { $entry = self::$smtp_log_pending ?? [ 'ts' => time(), 'to' => '', 'subject' => '(unknown)', 'status' => 'pending', 'error' => '', 'via' => 'unknown', ]; $entry['status'] = 'failed'; $entry['error'] = $error->get_error_message(); self::smtp_log_write( $entry ); self::$smtp_log_pending = null; } /** * Prepends a log entry to the stored email log (newest-first, capped at 100). * * @since 1.9.4 * @param array $entry Log entry array. * @return void */ private static function smtp_log_write( array $entry ): void { $log = get_option( self::EMAIL_LOG_OPTION, [] ); if ( ! is_array( $log ) ) { $log = []; } array_unshift( $log, $entry ); update_option( self::EMAIL_LOG_OPTION, array_slice( $log, 0, self::EMAIL_LOG_MAX ), false ); } /** * AJAX: clears the email log. * * @since 1.9.4 * @return void */ public static function ajax_smtp_log_clear(): void { check_ajax_referer( self::SMTP_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } delete_option( self::EMAIL_LOG_OPTION ); wp_send_json_success(); } /** * AJAX: returns the email log as JSON for client-side refresh. * * @since 1.9.4 * @return void */ public static function ajax_smtp_log_fetch(): void { check_ajax_referer( self::SMTP_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $log = get_option( self::EMAIL_LOG_OPTION, [] ); if ( ! is_array( $log ) ) { $log = []; } wp_send_json_success( $log ); } // ── Prefix migration (cs_ → csdt_devtools_) ─────────────────────────────── /** * One-time migration: renames options and user meta from the old cs_ prefix * to csdt_devtools_. Runs on every load but exits immediately after the first * successful run (guarded by a flag option). */ private static function maybe_migrate_prefix(): void { if ( get_option( 'csdt_devtools_prefix_migrated' ) ) { return; } // ── Options ────────────────────────────────────────────────────────── $option_map = [ 'cs_hide_login' => 'csdt_devtools_hide_login', 'cs_login_slug' => 'csdt_devtools_login_slug', 'cs_2fa_method' => 'csdt_devtools_2fa_method', 'cs_2fa_force_admins' => 'csdt_devtools_2fa_force_admins', 'cs_code_default_theme' => 'csdt_devtools_code_default_theme', 'cs_code_theme_pair' => 'csdt_devtools_code_theme_pair', 'cs_perf_monitor_enabled' => 'csdt_devtools_perf_monitor_enabled', 'cs_perf_debug_logging' => 'csdt_devtools_perf_debug_logging', ]; foreach ( $option_map as $old => $new ) { $val = get_option( $old ); if ( $val !== false ) { update_option( $new, $val ); delete_option( $old ); } } // ── User meta (all users) ───────────────────────────────────────────── global $wpdb; $meta_map = [ 'cs_passkeys' => 'csdt_devtools_passkeys', 'cs_totp_enabled' => 'csdt_devtools_totp_enabled', 'cs_totp_secret' => 'csdt_devtools_totp_secret', 'cs_totp_secret_pending' => 'csdt_devtools_totp_secret_pending', 'cs_2fa_email_enabled' => 'csdt_devtools_2fa_email_enabled', 'cs_email_verify_pending' => 'csdt_devtools_email_verify_pending', ]; foreach ( $meta_map as $old => $new ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $wpdb->usermeta, [ 'meta_key' => $new ], [ 'meta_key' => $old ] ); } update_option( 'csdt_devtools_prefix_migrated', '1' ); } /** * One-time migration: renames SMTP options from the cs_devtools_ prefix * (missed by the first migration) to csdt_devtools_. */ private static function maybe_migrate_smtp_prefix(): void { if ( get_option( 'csdt_devtools_smtp_prefix_migrated' ) ) { return; } $smtp_map = [ 'cs_devtools_smtp_enabled' => 'csdt_devtools_smtp_enabled', 'cs_devtools_smtp_host' => 'csdt_devtools_smtp_host', 'cs_devtools_smtp_port' => 'csdt_devtools_smtp_port', 'cs_devtools_smtp_encryption' => 'csdt_devtools_smtp_encryption', 'cs_devtools_smtp_auth' => 'csdt_devtools_smtp_auth', 'cs_devtools_smtp_user' => 'csdt_devtools_smtp_user', 'cs_devtools_smtp_pass' => 'csdt_devtools_smtp_pass', 'cs_devtools_smtp_from_email' => 'csdt_devtools_smtp_from_email', 'cs_devtools_smtp_from_name' => 'csdt_devtools_smtp_from_name', ]; foreach ( $smtp_map as $old => $new ) { $val = get_option( $old ); if ( $val !== false ) { update_option( $new, $val ); delete_option( $old ); } } update_option( 'csdt_devtools_smtp_prefix_migrated', '1' ); } /** * One-time migration: renames TOTP/2FA user meta from cs_devtools_ prefix * (missed by the first migration which used incorrect short keys) to csdt_devtools_. */ private static function maybe_migrate_usermeta_prefix(): void { if ( get_option( 'csdt_devtools_usermeta_prefix_migrated' ) ) { return; } global $wpdb; $meta_map = [ 'cs_devtools_totp_enabled' => 'csdt_devtools_totp_enabled', 'cs_devtools_totp_secret' => 'csdt_devtools_totp_secret', 'cs_devtools_totp_secret_pending' => 'csdt_devtools_totp_secret_pending', 'cs_devtools_2fa_email_enabled' => 'csdt_devtools_2fa_email_enabled', 'cs_devtools_email_verify_pending' => 'csdt_devtools_email_verify_pending', 'cs_devtools_passkeys' => 'csdt_devtools_passkeys', ]; foreach ( $meta_map as $old => $new ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $wpdb->usermeta, [ 'meta_key' => $new ], [ 'meta_key' => $old ] ); } update_option( 'csdt_devtools_usermeta_prefix_migrated', '1' ); } /* ================================================================== Custom 404 page with games ================================================================== */ /** Returns the 12 built-in colour scheme definitions for the 404 page. */ public static function get_404_schemes(): array { return [ 'ocean' => [ 'name' => 'Ocean', 'bg1' => '#cce9fb', 'bg2' => '#a8d8f0', 'acc' => '#f57c00', 'da' => '#e65100', 'text' => '#0d2a4a', 'card' => 'rgba(255,255,255,0.45)', 'dm' => false ], 'midnight' => [ 'name' => 'Midnight', 'bg1' => '#0f172a', 'bg2' => '#1e293b', 'acc' => '#60a5fa', 'da' => '#3b82f6', 'text' => '#e2e8f0', 'card' => 'rgba(15,23,42,0.65)', 'dm' => true ], 'forest' => [ 'name' => 'Forest', 'bg1' => '#d1fae5', 'bg2' => '#a7f3d0', 'acc' => '#059669', 'da' => '#047857', 'text' => '#064e3b', 'card' => 'rgba(255,255,255,0.45)', 'dm' => false ], 'sunset' => [ 'name' => 'Sunset', 'bg1' => '#fff1e6', 'bg2' => '#fde68a', 'acc' => '#ea580c', 'da' => '#c2410c', 'text' => '#7c2d12', 'card' => 'rgba(255,255,255,0.45)', 'dm' => false ], 'slate' => [ 'name' => 'Slate', 'bg1' => '#e2e8f0', 'bg2' => '#cbd5e1', 'acc' => '#7c3aed', 'da' => '#6d28d9', 'text' => '#1e293b', 'card' => 'rgba(255,255,255,0.45)', 'dm' => false ], 'rose' => [ 'name' => 'Rose', 'bg1' => '#fff1f2', 'bg2' => '#fecdd3', 'acc' => '#e11d48', 'da' => '#be123c', 'text' => '#881337', 'card' => 'rgba(255,255,255,0.45)', 'dm' => false ], 'emerald' => [ 'name' => 'Emerald', 'bg1' => '#ecfdf5', 'bg2' => '#d1fae5', 'acc' => '#d97706', 'da' => '#b45309', 'text' => '#064e3b', 'card' => 'rgba(255,255,255,0.45)', 'dm' => false ], 'violet' => [ 'name' => 'Violet', 'bg1' => '#1e1b4b', 'bg2' => '#312e81', 'acc' => '#a78bfa', 'da' => '#7c3aed', 'text' => '#ede9fe', 'card' => 'rgba(49,46,129,0.5)', 'dm' => true ], 'charcoal' => [ 'name' => 'Charcoal', 'bg1' => '#1c1c1e', 'bg2' => '#2c2c2e', 'acc' => '#f57c00', 'da' => '#e65100', 'text' => '#e5e5ea', 'card' => 'rgba(44,44,46,0.6)', 'dm' => true ], 'arctic' => [ 'name' => 'Arctic', 'bg1' => '#f0fdfa', 'bg2' => '#ccfbf1', 'acc' => '#0d9488', 'da' => '#0f766e', 'text' => '#134e4a', 'card' => 'rgba(255,255,255,0.45)', 'dm' => false ], 'copper' => [ 'name' => 'Copper', 'bg1' => '#fdf6ec', 'bg2' => '#fde8c8', 'acc' => '#b45309', 'da' => '#92400e', 'text' => '#451a03', 'card' => 'rgba(255,255,255,0.45)', 'dm' => false ], 'cosmic' => [ 'name' => 'Cosmic', 'bg1' => '#0a0015', 'bg2' => '#1a0033', 'acc' => '#e879f9', 'da' => '#d946ef', 'text' => '#fae8ff', 'card' => 'rgba(26,0,51,0.5)', 'dm' => true ], ]; } /** Builds inline CSS overrides for the chosen colour scheme (empty string for default). */ public static function get_404_scheme_css( string $key ): string { $schemes = self::get_404_schemes(); if ( ! isset( $schemes[ $key ] ) || 'ocean' === $key ) { return ''; } $s = $schemes[ $key ]; $bg1 = esc_attr( $s['bg1'] ); $bg2 = esc_attr( $s['bg2'] ); $acc = esc_attr( $s['acc'] ); $da = esc_attr( $s['da'] ); $text = esc_attr( $s['text'] ); $card = esc_attr( $s['card'] ); $css = "body{background:linear-gradient(160deg,{$bg1} 0%,{$bg2} 100%);color:{$text};}"; $css .= ".cs404-heading{background:linear-gradient(135deg,{$acc},{$da});-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}"; $css .= ".cs404-btn,.cs404-home-btn{background:linear-gradient(135deg,{$acc},{$da});box-shadow:0 4px 24px {$acc}44;}"; $css .= ".cs404-btn:hover,.cs404-home-btn:hover{box-shadow:0 6px 28px {$acc}66;}"; $css .= ".cs404-tab.active{background:linear-gradient(135deg,{$acc},{$da});box-shadow:0 2px 12px {$acc}44;}"; $css .= "#cs404-game{background:{$card};border-color:rgba(128,128,128,0.2);}"; $css .= ".cs404-lb-score{color:{$acc};}"; $css .= ".cs404-lb-row-gold{background:{$acc}18;}"; if ( $s['dm'] ) { $css .= ".cs404-desc,.cs404-site-name,.cs404-tagline{color:{$text};}"; $css .= ".cs404-tab{background:rgba(255,255,255,0.08);color:{$text};border-color:rgba(255,255,255,0.1);}"; $css .= ".cs404-tab:hover{background:rgba(255,255,255,0.14);}"; $css .= ".cs404-miner-btn{background:rgba(255,255,255,0.1);color:{$text};border-color:rgba(255,255,255,0.15);}"; $css .= "#cs404-lb-panel{background:rgba(255,255,255,0.05);border-color:rgba(255,255,255,0.1);}"; $css .= ".cs404-lb-header{background:rgba(255,255,255,0.07);color:{$text};}"; $css .= ".cs404-lb-name{color:{$text};}.cs404-lb-empty{color:{$text};}"; $css .= ".cs404-lb-row{border-bottom-color:rgba(255,255,255,0.07);}"; } return $css; } /** * Intercepts WordPress 404 responses and outputs the custom games page. * * Hooked on `template_redirect` at priority 1. */ public static function maybe_custom_404(): void { if ( ! is_404() ) { return; } $is_preview = isset( $_GET['csdt_devtools_preview_scheme'] ) && current_user_can( 'manage_options' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! $is_preview && ! get_option( self::CUSTOM_404_OPTION, 1 ) ) { return; } status_header( 404 ); nocache_headers(); header( 'Content-Type: text/html; charset=utf-8' ); $site_name = get_bloginfo( 'name' ); $site_tagline = get_bloginfo( 'description' ); $home_url = home_url( '/' ); $logo_html = ''; if ( has_custom_logo() ) { $logo_html = get_custom_logo(); } elseif ( $icon_url = get_site_icon_url( 64 ) ) { $logo_html = ''; } $css_path = plugin_dir_path( __FILE__ ) . 'assets/cs-custom-404.css'; $js_path = plugin_dir_path( __FILE__ ) . 'assets/cs-custom-404.js'; $css_url = plugins_url( 'assets/cs-custom-404.css', __FILE__ ) . '?ver=' . self::VERSION . '.' . filemtime( $css_path ); $js_url = plugins_url( 'assets/cs-custom-404.js', __FILE__ ) . '?ver=' . self::VERSION . '.' . filemtime( $js_path ); $preview_key = isset( $_GET['csdt_devtools_preview_scheme'] ) ? sanitize_key( wp_unslash( $_GET['csdt_devtools_preview_scheme'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only palette preview $all_schemes = self::get_404_schemes(); $active_scheme = ( $preview_key && isset( $all_schemes[ $preview_key ] ) ) ? $preview_key : get_option( self::SCHEME_404_OPTION, 'ocean' ); $scheme_css = self::get_404_scheme_css( $active_scheme ); ?> <?php echo esc_html__( 'Page Not Found', 'cloudscale-devtools' ); ?> — <?php echo esc_html( $site_name ); ?>

404

← Home
🏆 Runner — Top 10

No scores yet — be the first!

var CS_PCR_API=' . wp_json_encode( rest_url( self::HISCORE_NS ) ) . ';var CS_PCR_SCORE_NONCE=' . wp_json_encode( wp_create_nonce( self::SCORE_NONCE_ACTION ) ) . ';'; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- standalone exit-page ?> '; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- standalone 404 exit-page, no wp_head/wp_footer ?> runner|jetpack|racer|miner|asteroids|snake|mrdo)', [ [ 'methods' => 'GET', 'callback' => [ __CLASS__, 'rest_get_hiscore' ], // Public leaderboard read — no authentication required. 'permission_callback' => static fn() => true, 'args' => [ 'game' => [ 'required' => true, 'type' => 'string' ] ], ], [ 'methods' => 'POST', 'callback' => [ __CLASS__, 'rest_set_hiscore' ], // Public score submission — open to guests by design (404 mini-games). // CSRF protection is enforced via nonce verification in the callback. 'permission_callback' => static fn() => true, 'args' => [ 'game' => [ 'required' => true, 'type' => 'string' ], 'score' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, 'maximum' => 999999 ], 'name' => [ 'required' => true, 'type' => 'string', 'maxLength' => 30 ], ], ], ] ); } /** Returns the top-10 leaderboard for one game. */ public static function rest_get_hiscore( WP_REST_Request $request ): WP_REST_Response { $game = sanitize_key( $request->get_param( 'game' ) ); $raw = get_option( 'csdt_devtools_leaderboard_' . $game, '' ); $lb = $raw ? json_decode( $raw, true ) : []; if ( ! is_array( $lb ) ) { $lb = []; } return rest_ensure_response( [ 'leaderboard' => $lb ] ); } /** Inserts a score into the top-10 leaderboard for one game. */ public static function rest_set_hiscore( WP_REST_Request $request ) { $nonce = $request->get_header( 'x_wp_score_nonce' ); if ( ! $nonce || ! wp_verify_nonce( sanitize_text_field( $nonce ), self::SCORE_NONCE_ACTION ) ) { return new WP_Error( 'forbidden', __( 'Invalid nonce.', 'cloudscale-devtools' ), [ 'status' => 403 ] ); } $game = sanitize_key( $request->get_param( 'game' ) ); $score = (int) $request->get_param( 'score' ); $name = sanitize_text_field( $request->get_param( 'name' ) ); $score_caps = [ 'runner' => 999999, 'jetpack' => 999999, 'racer' => 999999, 'miner' => 2000, 'asteroids' => 999999, 'snake' => 9990, 'mrdo' => 99990 ]; if ( isset( $score_caps[ $game ] ) && $score > $score_caps[ $game ] ) { return new WP_Error( 'score_invalid', __( 'Score exceeds maximum for this game.', 'cloudscale-devtools' ), [ 'status' => 422 ] ); } // Rate limit: max 5 submissions per IP per game per 10 minutes. $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $ip_key = 'csdt_devtools_rl_' . md5( $ip . $game ); $count = (int) get_transient( $ip_key ); if ( $count >= 5 ) { return new WP_Error( 'rate_limited', __( 'Too many score submissions. Try again later.', 'cloudscale-devtools' ), [ 'status' => 429 ] ); } set_transient( $ip_key, $count + 1, 600 ); $raw = get_option( 'csdt_devtools_leaderboard_' . $game, '' ); $lb = $raw ? json_decode( $raw, true ) : []; if ( ! is_array( $lb ) ) { $lb = []; } foreach ( $lb as $entry ) { if ( (int) $entry['score'] === $score && $entry['name'] === $name ) { return rest_ensure_response( [ 'ok' => false, 'leaderboard' => $lb ] ); } } $lowest = isset( $lb[9] ) ? (int) $lb[9]['score'] : 0; if ( count( $lb ) >= 10 && $score <= $lowest ) { return rest_ensure_response( [ 'ok' => false, 'leaderboard' => $lb ] ); } $lb[] = [ 'score' => $score, 'name' => $name ]; usort( $lb, fn( $a, $b ) => (int) $b['score'] - (int) $a['score'] ); $lb = array_slice( $lb, 0, 10 ); update_option( 'csdt_devtools_leaderboard_' . $game, wp_json_encode( $lb ), false ); return rest_ensure_response( [ 'ok' => true, 'leaderboard' => $lb ] ); } /** AJAX handler: saves the 404 enable toggle and colour scheme. */ public static function ajax_save_404_settings(): void { check_ajax_referer( 'csdt_devtools_404_settings', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Forbidden.', 'cloudscale-devtools' ) ); } $custom_404 = isset( $_POST['custom_404'] ) ? ( absint( wp_unslash( $_POST['custom_404'] ) ) ? 1 : 0 ) : 0; update_option( self::CUSTOM_404_OPTION, $custom_404 ); if ( isset( $_POST['scheme'] ) ) { $schemes = self::get_404_schemes(); $scheme_key = sanitize_key( wp_unslash( $_POST['scheme'] ) ); if ( isset( $schemes[ $scheme_key ] ) ) { update_option( self::SCHEME_404_OPTION, $scheme_key ); } } wp_send_json_success( [ 'custom_404' => $custom_404, 'scheme' => get_option( self::SCHEME_404_OPTION, 'ocean' ) ] ); } /** Renders the 404 Games settings panel. */ private static function render_404_panel(): void { $current_scheme = get_option( self::SCHEME_404_OPTION, 'ocean' ); $enabled = (bool) get_option( self::CUSTOM_404_OPTION, 1 ); ?>
🎮 404 GAMES PAGE 'Enable', 'rec' => 'Toggle', 'desc' => 'When enabled, replaces the default WordPress 404 page with a fun interactive page featuring 7 mini-games: Runner, Jetpack, Racer, Miner, Asteroids, Snake, and Mr. Do!. No theme dependency — works even if the active theme is broken.' ], [ 'name' => 'Colour Scheme', 'rec' => 'Optional', 'desc' => 'Choose from 12 built-in colour palettes. Changes take effect immediately. Use Preview to see the result before saving.' ], ] ); ?>
$s ) : ?>
[ 'label' => 'Facebook', 'w' => 1200, 'h' => 630, 'target_kb' => 400, 'max_kb' => 8000 ], 'twitter' => [ 'label' => 'X / Twitter', 'w' => 1200, 'h' => 628, 'target_kb' => 400, 'max_kb' => 5000 ], 'whatsapp' => [ 'label' => 'WhatsApp', 'w' => 1200, 'h' => 630, 'target_kb' => 200, 'max_kb' => 300 ], 'linkedin' => [ 'label' => 'LinkedIn', 'w' => 1200, 'h' => 627, 'target_kb' => 400, 'max_kb' => 5000 ], 'instagram' => [ 'label' => 'Instagram', 'w' => 1080, 'h' => 1080, 'target_kb' => 400, 'max_kb' => 8000 ], ]; private const SOCIAL_UAS = [ 'WhatsApp' => 'WhatsApp/2.23.24.82 A', 'Facebook' => 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)', 'Facebot' => 'Facebot', 'LinkedInBot' => 'LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient +http://www.linkedin.com)', 'Twitterbot' => 'Twitterbot/1.0', ]; // ─── Panel render ──────────────────────────────────────────────────── private static function render_thumbnails_panel(): void { $cf_zone = get_option( 'csdt_devtools_cf_zone_id', '' ); $cf_token = get_option( 'csdt_devtools_cf_api_token', '' ); $cf_token_masked = $cf_token ? str_repeat( '•', 12 ) . substr( $cf_token, -4 ) : ''; ?>
🔍 URL SOCIAL PREVIEW CHECKER 'HTTPS', 'rec' => 'Required', 'html' => 'Social crawlers refuse to load preview images served over http://. The URL must use https://.' ], [ 'name' => 'HTTP Response', 'rec' => 'Required', 'html' => 'The page must return HTTP 200 for the crawler\'s User-Agent. A 403 or bot-block will prevent any preview from loading.' ], [ 'name' => 'Response Time', 'rec' => 'Recommended', 'html' => 'Crawlers time out after ~3 seconds. Pages that take longer to respond will show no preview — check for slow plugins or uncached pages.' ], [ 'name' => 'og:title', 'rec' => 'Required', 'html' => 'The title shown in the social card. Without og:title, platforms fall back to the page <title> tag or show nothing.' ], [ 'name' => 'og:description', 'rec' => 'Recommended', 'html' => 'The summary text shown under the title. Recommended for all platforms; Twitter/X truncates to ~200 chars.' ], [ 'name' => 'og:image', 'rec' => 'Required', 'html' => 'The preview image. Must be an absolute https:// URL. Recommended size: 1200×630 px, max 8 MB. Facebook enforces a minimum of 200×200 px.' ], [ 'name' => 'og:image dimensions', 'rec' => 'Recommended', 'html' => 'og:image:width and og:image:height tell crawlers the size without downloading the image. Speeds up card rendering and avoids layout shifts.' ], [ 'name' => 'robots.txt', 'rec' => 'Info', 'html' => 'Checks that robots.txt does not block Googlebot, Twitterbot, facebookexternalhit, or other social crawlers from accessing the page.' ], [ 'name' => 'Crawler UA test', 'rec' => 'Info', 'html' => 'Re-fetches the page using each platform\'s real crawler User-Agent string to confirm the page is not blocked by a WAF, Cloudflare rule, or bot-protection plugin.' ], ] ); ?>

☁️ CLOUDFLARE SETUP & DIAGNOSTICS

1

2

(http.user_agent contains "WhatsApp") or (http.user_agent contains "facebookexternalhit") or (http.user_agent contains "Facebot") or (http.user_agent contains "LinkedInBot") or (http.user_agent contains "Twitterbot")

3

4

📋 POST SOCIAL PREVIEW SCAN 'Facebook', 'rec' => 'Min 200×200', 'html' => 'Checks the WordPress featured image file directly — no live HTTP fetch. Recommended 1200×630 px, max 8 MB. Optimised versions are auto-generated to /wp-content/uploads/social-formats/ when you publish or update a post.' ], [ 'name' => 'X / Twitter', 'rec' => 'Min 280×150', 'html' => 'summary_large_image card format. Recommended 1200×628 px, max 5 MB. Auto-generated at the correct crop on every post save with a new featured image.' ], [ 'name' => 'WhatsApp', 'rec' => 'Max 300 KB', 'html' => 'Strict 300 KB hard limit — images over this are silently hidden with no error message. The plugin automatically compresses the image at lower JPEG quality until it fits, so your WhatsApp preview will always appear.' ], [ 'name' => 'LinkedIn', 'rec' => 'Min 200×110', 'html' => 'Recommended 1200×627 px, max 5 MB. Auto-generated with the correct crop. Portrait-oriented or very small images often display poorly in LinkedIn feed cards.' ], [ 'name' => 'Instagram', 'rec' => '1080×1080 sq', 'html' => 'Square 1:1 format for direct feed post uploads. Min 320×320, recommended 1080×1080, max 8 MB.

Note: Instagram does not scrape OG tags for link previews — this format is for direct uploads only.' ], [ 'name' => 'Auto-generate', 'rec' => 'Automatic', 'html' => 'Every time you publish or update a post with a new featured image, the plugin automatically generates correctly sized and compressed images for each enabled platform. Nothing changes if the featured image hasn\'t changed.' ], [ 'name' => 'Fix', 'rec' => 'Manual action', 'html' => 'Manually triggers generation for a single post. Use this to regenerate after changing platform settings, or for posts that existed before auto-generation was enabled.' ], [ 'name' => 'Fix all', 'rec' => 'Manual action', 'html' => 'Runs Fix for every post in the current scan results (up to 50). Useful for quickly fixing the posts you just scanned.' ], [ 'name' => 'Fix All Posts on Site', 'rec' => 'Bulk action', 'html' => 'Processes every published post on the entire site in batches of 10, generating platform formats for each. Shows live progress (e.g. Fixing 45 / 320). Posts without a featured image are skipped automatically.' ], [ 'name' => 'Re-check', 'rec' => 'Diagnostic', 'html' => 'Runs the full live URL diagnostic (OG tags, robots.txt, crawler UA test) on this specific post URL and scrolls to the URL checker results above.' ], ] ); ?>

🎨 SOCIAL FORMAT SETTINGS 'Facebook 1200×630', 'rec' => 'Optimum ~400 KB', 'html' => 'Optimum: 1200×630 px at under 400 KB JPEG. Hard limit: 8 MB. Minimum: 200×200 px. Landscape 1.91:1 ratio — the plugin auto-crops to this exact frame so Facebook always shows your image, not a random one from the page.' ], [ 'name' => 'X / Twitter 1200×628', 'rec' => 'Optimum ~400 KB', 'html' => 'Optimum: 1200×628 px at under 400 KB JPEG. Hard limit: 5 MB. Minimum for large card: 280×150 px. Slightly shorter than Facebook — a separate dedicated crop prevents the subject being letterboxed or clipped.' ], [ 'name' => 'WhatsApp 1200×630', 'rec' => 'Optimum ~200 KB', 'html' => 'Optimum: 1200×630 px at under 200 KB JPEG. Hard limit: 300 KB — images over this are silently dropped with no error message. The plugin targets 200 KB for a safe margin, retrying at lower quality until the file fits.' ], [ 'name' => 'LinkedIn 1200×627', 'rec' => 'Optimum ~400 KB', 'html' => 'Optimum: 1200×627 px at under 400 KB JPEG. Hard limit: 5 MB. Minimum: 200×110 px. Landscape cards perform best — portrait images are cropped awkwardly or shown very small in the LinkedIn feed.' ], [ 'name' => 'Instagram 1080×1080', 'rec' => 'Optimum ~400 KB', 'html' => 'Optimum: 1080×1080 px square at under 400 KB JPEG. Hard limit: 8 MB. Minimum: 320×320 px. Square 1:1 crop for direct feed uploads — Instagram does not scrape OG tags for link preview cards.' ], [ 'name' => 'Auto-generation', 'rec' => 'How it works', 'html' => 'Every time you publish or update a post with a new featured image, the plugin automatically generates all enabled platform formats at the optimum size and quality — not just within the hard limit. Unchanged images are skipped. Originals are never modified.' ], [ 'name' => 'Save Settings', 'rec' => 'Required once', 'html' => 'Saves which platforms are enabled. Only checked platforms are generated on post save. Changes take effect on the next publish or update.' ], ] ); ?>

$p ) : $checked = in_array( $key, $enabled_platforms, true ); $opt_kb = $p['target_kb']; $size_note = $opt_kb >= 1000 ? 'optimum ~' . ( $opt_kb / 1000 ) . ' MB' : 'optimum ~' . $opt_kb . ' KB'; ?>
'No URL provided.' ] ); } if ( ! self::is_safe_external_url( $url ) ) { wp_send_json_error( [ 'message' => 'URL must be a publicly accessible address.' ] ); } wp_send_json_success( self::social_diagnose_url( $url ) ); } // ─── AJAX: scan last 10 posts ──────────────────────────────────────── public static function ajax_social_scan_posts(): void { check_ajax_referer( self::THUMB_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $posts = get_posts( [ 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 50, 'orderby' => 'date', 'order' => 'DESC', 'meta_query' => [ [ 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ] ], ] ); $results = []; foreach ( $posts as $post ) { $url = get_permalink( $post ); $diag = self::social_diagnose_url( $url ); $thumb_id = get_post_thumbnail_id( $post->ID ); $attach_id = $thumb_id ? (int) $thumb_id : null; $can_fix = false; if ( $attach_id ) { $file = get_attached_file( $attach_id ); $can_fix = $file && file_exists( $file ); } $results[] = [ 'id' => $post->ID, 'title' => get_the_title( $post ), 'url' => $url, 'totals' => $diag['totals'], 'og_image' => $diag['og_image'] ?? '', 'img_kb' => $diag['img_kb'] ?? null, 'img_w' => $diag['img_w'] ?? null, 'img_h' => $diag['img_h'] ?? null, 'attach_id' => $attach_id, 'can_fix' => $can_fix, ]; } wp_send_json_success( $results ); } // ─── Per-platform compatibility check ──────────────────────────────── private static function check_platform_compat( int $width, int $height, float $kb, bool $https ): array { $r = []; // Facebook — optimum ~400 KB, hard limit 8 MB, min 200×200, ideal 1200×630 if ( ! $https ) { $r['facebook'] = [ 'status' => 'fail', 'msg' => 'Image must be HTTPS' ]; } elseif ( $width < 200 || $height < 200 ) { $r['facebook'] = [ 'status' => 'fail', 'msg' => 'Too small — minimum 200×200 px' ]; } elseif ( $kb > 8000 ) { $r['facebook'] = [ 'status' => 'fail', 'msg' => 'Too large — hard limit 8 MB' ]; } elseif ( $width < 1200 || $height < 630 ) { $r['facebook'] = [ 'status' => 'warn', 'msg' => 'Below optimum 1200×630 — Fix will crop and resize to the ideal size' ]; } elseif ( $kb > 400 ) { $r['facebook'] = [ 'status' => 'warn', 'msg' => "{$kb} KB — above optimum ~400 KB. Fix will compress to the ideal size" ]; } else { $r['facebook'] = [ 'status' => 'pass', 'msg' => 'Ready — optimum size and quality' ]; } // X / Twitter — optimum ~400 KB, hard limit 5 MB, min 280×150, ideal 1200×628 if ( ! $https ) { $r['twitter'] = [ 'status' => 'fail', 'msg' => 'Image must be HTTPS' ]; } elseif ( $width < 280 || $height < 150 ) { $r['twitter'] = [ 'status' => 'fail', 'msg' => 'Too small — minimum 280×150 px for large card' ]; } elseif ( $kb > 5000 ) { $r['twitter'] = [ 'status' => 'fail', 'msg' => 'Too large — hard limit 5 MB' ]; } elseif ( $width < 1200 || $height < 628 ) { $r['twitter'] = [ 'status' => 'warn', 'msg' => 'Below optimum 1200×628 — Fix will crop and resize to the ideal size' ]; } elseif ( $kb > 400 ) { $r['twitter'] = [ 'status' => 'warn', 'msg' => "{$kb} KB — above optimum ~400 KB. Fix will compress to the ideal size" ]; } else { $r['twitter'] = [ 'status' => 'pass', 'msg' => 'Ready — optimum size and quality' ]; } // WhatsApp — optimum ~200 KB, hard limit 300 KB (images over this are silently hidden) if ( ! $https ) { $r['whatsapp'] = [ 'status' => 'fail', 'msg' => 'Image must be HTTPS' ]; } elseif ( $kb > 300 ) { $r['whatsapp'] = [ 'status' => 'fail', 'msg' => "{$kb} KB — over 300 KB hard limit. Preview will be silently hidden. Fix will compress below the limit" ]; } elseif ( $width < 1200 || $height < 630 ) { $r['whatsapp'] = [ 'status' => 'warn', 'msg' => 'Below optimum 1200×630 — Fix will crop, resize, and compress to under 200 KB' ]; } elseif ( $kb > 200 ) { $r['whatsapp'] = [ 'status' => 'warn', 'msg' => "{$kb} KB — above optimum ~200 KB (close to 300 KB hard limit). Fix will compress to the safe target" ]; } else { $r['whatsapp'] = [ 'status' => 'pass', 'msg' => 'Ready — within optimum 200 KB target' ]; } // LinkedIn — optimum ~400 KB, hard limit 5 MB, min 200×110, ideal 1200×627 if ( ! $https ) { $r['linkedin'] = [ 'status' => 'fail', 'msg' => 'Image must be HTTPS' ]; } elseif ( $width < 200 || $height < 110 ) { $r['linkedin'] = [ 'status' => 'fail', 'msg' => 'Too small — minimum 200×110 px' ]; } elseif ( $kb > 5000 ) { $r['linkedin'] = [ 'status' => 'fail', 'msg' => 'Too large — hard limit 5 MB' ]; } elseif ( $width < 1200 || $height < 627 ) { $r['linkedin'] = [ 'status' => 'warn', 'msg' => 'Below optimum 1200×627 — Fix will crop and resize to the ideal size' ]; } elseif ( $kb > 400 ) { $r['linkedin'] = [ 'status' => 'warn', 'msg' => "{$kb} KB — above optimum ~400 KB. Fix will compress to the ideal size" ]; } else { $r['linkedin'] = [ 'status' => 'pass', 'msg' => 'Ready — optimum size and quality' ]; } // Instagram — optimum ~400 KB, hard limit 8 MB, min 320×320, ideal 1080×1080 square if ( $width < 320 || $height < 320 ) { $r['instagram'] = [ 'status' => 'fail', 'msg' => 'Too small — minimum 320×320 px' ]; } elseif ( $kb > 8000 ) { $r['instagram'] = [ 'status' => 'fail', 'msg' => 'Too large — hard limit 8 MB' ]; } elseif ( $width < 1080 || $height < 1080 ) { $r['instagram'] = [ 'status' => 'warn', 'msg' => 'Below optimum 1080×1080 square — Fix will crop to a square and resize to the ideal size' ]; } elseif ( $kb > 400 ) { $r['instagram'] = [ 'status' => 'warn', 'msg' => "{$kb} KB — above optimum ~400 KB. Fix will compress to the ideal size" ]; } else { $r['instagram'] = [ 'status' => 'pass', 'msg' => 'Ready — optimum size and quality' ]; } return $r; } // ─── AJAX: scan featured images for last 50 posts ───────────────────── public static function ajax_social_scan_media(): void { check_ajax_referer( self::THUMB_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $mode = isset( $_POST['mode'] ) && $_POST['mode'] === 'top' ? 'top' : 'recent'; // "Top posts" — order by view count meta if available, else comment count. $view_meta_keys = [ 'post_views_count', 'views', '_post_views', 'wpb_post_views_count', 'jetpack-views' ]; $view_meta_found = false; if ( $mode === 'top' ) { foreach ( $view_meta_keys as $mk ) { $sample = get_posts( [ 'post_type' => 'post', 'posts_per_page' => 1, 'meta_key' => $mk, 'orderby' => 'meta_value_num', 'order' => 'DESC', 'fields' => 'ids' ] ); if ( ! empty( $sample ) ) { $view_meta_found = $mk; break; } } } $query_args = [ 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 50, 'fields' => 'ids', ]; if ( $mode === 'top' ) { if ( $view_meta_found ) { $query_args['meta_key'] = $view_meta_found; $query_args['orderby'] = 'meta_value_num'; $query_args['order'] = 'DESC'; } else { // Fall back to comment count as a proxy for popularity. $query_args['orderby'] = 'comment_count'; $query_args['order'] = 'DESC'; } } else { $query_args['orderby'] = 'date'; $query_args['order'] = 'DESC'; } $posts = get_posts( $query_args ); $results = []; foreach ( $posts as $post_id ) { $post_url = get_permalink( $post_id ); $thumb_id = get_post_thumbnail_id( $post_id ); if ( ! $thumb_id ) { // No featured image — all platforms fail. $all_fail = []; foreach ( array_keys( self::SOCIAL_PLATFORMS ) as $key ) { $all_fail[ $key ] = [ 'status' => 'fail', 'msg' => 'No featured image set' ]; } $results[] = [ 'post_id' => $post_id, 'title' => get_the_title( $post_id ), 'post_url' => $post_url, 'img_url' => '', 'attach_id' => null, 'width' => 0, 'height' => 0, 'size_kb' => null, 'status' => 'fail', 'no_image' => true, 'platforms' => $all_fail, 'can_fix' => false, ]; continue; } $attach_id = (int) $thumb_id; $img_url = wp_get_attachment_url( $attach_id ); $meta = wp_get_attachment_metadata( $attach_id ); $width = (int) ( $meta['width'] ?? 0 ); $height = (int) ( $meta['height'] ?? 0 ); $kb = null; $can_fix = false; $https = $img_url && str_starts_with( $img_url, 'https://' ); $file = get_attached_file( $attach_id ); if ( $file && file_exists( $file ) ) { $bytes = (int) filesize( $file ); $kb = round( $bytes / 1024, 1 ); $ext = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ); $can_fix = in_array( $ext, [ 'jpg', 'jpeg', 'png', 'webp' ], true ); } $platforms = self::check_platform_compat( $width, $height, (float) ( $kb ?? 0 ), $https ); // Derive overall status from worst platform result. $status = 'pass'; foreach ( $platforms as $pc ) { if ( $pc['status'] === 'fail' ) { $status = 'fail'; break; } if ( $pc['status'] === 'warn' ) { $status = 'warn'; } } $results[] = [ 'post_id' => $post_id, 'title' => get_the_title( $post_id ), 'post_url' => $post_url, 'img_url' => $img_url, 'attach_id' => $attach_id, 'width' => $width, 'height' => $height, 'size_kb' => $kb, 'status' => $status, 'no_image' => false, 'platforms' => $platforms, 'can_fix' => $can_fix, ]; } $counts = array_count_values( array_column( $results, 'status' ) ); $sort_note = ''; if ( $mode === 'top' ) { $sort_note = $view_meta_found ? sprintf( __( 'sorted by view count (%s)', 'cloudscale-devtools' ), $view_meta_found ) : __( 'sorted by comment count (no view-count plugin detected)', 'cloudscale-devtools' ); } wp_send_json_success( [ 'total_scanned' => count( $results ), 'pass' => $counts['pass'] ?? 0, 'warn' => $counts['warn'] ?? 0, 'fail' => $counts['fail'] ?? 0, 'mode' => $mode, 'sort_note' => $sort_note, 'posts' => $results, ] ); } // ─── AJAX: recompress an oversized image ───────────────────────────── public static function ajax_social_fix_image(): void { check_ajax_referer( self::THUMB_NONCE, 'nonce' ); if ( ! current_user_can( 'upload_files' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $attachment_id = absint( $_POST['attachment_id'] ?? 0 ); if ( ! $attachment_id ) { wp_send_json_error( [ 'message' => 'No attachment ID.' ] ); } $result = self::social_recompress_image( $attachment_id ); if ( is_wp_error( $result ) ) { wp_send_json_error( [ 'message' => $result->get_error_message() ] ); } wp_send_json_success( $result ); } // ─── AJAX: save platform settings ──────────────────────────────────── public static function ajax_social_platform_save(): void { check_ajax_referer( self::THUMB_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $raw = isset( $_POST['platforms'] ) ? (array) $_POST['platforms'] : []; $allowed = array_keys( self::SOCIAL_PLATFORMS ); $filtered = array_values( array_intersect( $raw, $allowed ) ); update_option( 'csdt_devtools_social_platforms', $filtered ); wp_send_json_success( [ 'saved' => $filtered ] ); } // ─── Shared: generate per-platform social format images ───────────── private static function generate_social_formats_for_post( int $post_id ): ?array { $thumb_id = get_post_thumbnail_id( $post_id ); if ( ! $thumb_id ) return null; $source_file = get_attached_file( (int) $thumb_id ); if ( ! $source_file || ! file_exists( $source_file ) ) return null; $enabled = get_option( 'csdt_devtools_social_platforms', array_keys( self::SOCIAL_PLATFORMS ) ); if ( empty( $enabled ) ) return null; $upload = wp_upload_dir(); $dest_dir = trailingslashit( $upload['basedir'] ) . 'social-formats/' . $post_id; $dest_url = trailingslashit( $upload['baseurl'] ) . 'social-formats/' . $post_id; wp_mkdir_p( $dest_dir ); $ext = strtolower( pathinfo( $source_file, PATHINFO_EXTENSION ) ); if ( ! in_array( $ext, [ 'jpg', 'jpeg', 'png', 'webp' ], true ) ) { $ext = 'jpg'; } // Convert PNG/WebP to JPEG so lossy quality reduction can actually shrink the file. if ( in_array( $ext, [ 'png', 'webp' ], true ) ) { $ext = 'jpg'; } $results = []; foreach ( self::SOCIAL_PLATFORMS as $key => $platform ) { if ( ! in_array( $key, $enabled, true ) ) continue; $filename = "{$dest_dir}/{$key}.{$ext}"; $file_url = "{$dest_url}/{$key}.{$ext}"; $quality = 90; for ( $attempt = 0; $attempt < 4; $attempt++ ) { $editor = wp_get_image_editor( $source_file ); if ( is_wp_error( $editor ) ) { $results[ $key ] = [ 'success' => false, 'label' => $platform['label'], 'error' => $editor->get_error_message() ]; continue 2; } $editor->resize( $platform['w'], $platform['h'], true ); $editor->set_quality( $quality ); $saved = $editor->save( $filename ); if ( is_wp_error( $saved ) ) { $results[ $key ] = [ 'success' => false, 'label' => $platform['label'], 'error' => $saved->get_error_message() ]; continue 2; } $kb = round( (int) filesize( $filename ) / 1024, 1 ); if ( $kb <= $platform['target_kb'] || $quality <= 55 ) break; $quality -= 10; } $kb = round( (int) filesize( $filename ) / 1024, 1 ); $under_limit = $kb <= $platform['target_kb']; $results[ $key ] = [ 'success' => true, 'label' => $platform['label'], 'w' => $platform['w'], 'h' => $platform['h'], 'kb' => $kb, 'max_kb' => $platform['max_kb'], 'under_limit' => $under_limit, 'url' => $file_url, 'preview_url' => $file_url . '?v=' . time(), ]; } update_post_meta( $post_id, '_csdt_social_formats', $results ); return $results; } // ─── Hook: auto-generate on post publish / update ──────────────────── public static function on_post_saved( int $post_id, \WP_Post $post, bool $update ): void { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return; if ( wp_is_post_revision( $post_id ) ) return; if ( $post->post_status !== 'publish' ) return; $thumb_id = (int) get_post_thumbnail_id( $post_id ); if ( ! $thumb_id ) return; // Skip if the thumbnail hasn't changed since last generation. $last_thumb = (int) get_post_meta( $post_id, '_csdt_social_formats_thumb_id', true ); if ( $last_thumb === $thumb_id ) return; $results = self::generate_social_formats_for_post( $post_id ); if ( $results === null ) return; update_post_meta( $post_id, '_csdt_social_formats_thumb_id', $thumb_id ); // Store for admin notice on next page load. $user_id = get_current_user_id(); set_transient( "cs_sfmt_{$user_id}_{$post_id}", $results, 120 ); } // ─── Admin notice: shown after auto-generation ─────────────────────── public static function social_format_admin_notice(): void { $screen = get_current_screen(); if ( ! $screen || $screen->base !== 'post' ) return; $post_id = isset( $_GET['post'] ) ? (int) $_GET['post'] : 0; if ( ! $post_id ) return; $user_id = get_current_user_id(); $results = get_transient( "cs_sfmt_{$user_id}_{$post_id}" ); if ( ! $results ) return; delete_transient( "cs_sfmt_{$user_id}_{$post_id}" ); $ok_labels = []; $fail_labels = []; foreach ( $results as $r ) { if ( ! empty( $r['success'] ) ) { $size_note = $r['under_limit'] ? '' : ' ⚠'; $ok_labels[] = $r['label'] . ' (' . $r['w'] . '×' . $r['h'] . ', ' . $r['kb'] . ' KB' . $size_note . ')'; } else { $fail_labels[] = $r['label']; } } if ( empty( $ok_labels ) ) return; echo '
'; echo '🎨'; echo '
' . esc_html__( 'Social format images generated automatically', 'cloudscale-devtools' ) . '
'; echo '' . esc_html( implode( '  ·  ', $ok_labels ) ) . ''; if ( ! empty( $fail_labels ) ) { echo '
✘ Failed: ' . esc_html( implode( ', ', $fail_labels ) ) . ''; } echo '
'; } // ─── AJAX: generate per-platform social formats ─────────────────────── public static function ajax_social_generate_formats(): void { check_ajax_referer( self::THUMB_NONCE, 'nonce' ); if ( ! current_user_can( 'upload_files' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $post_id = absint( $_POST['post_id'] ?? 0 ); if ( ! $post_id ) { wp_send_json_error( [ 'message' => 'No post ID.' ] ); } if ( ! get_post_thumbnail_id( $post_id ) ) { wp_send_json_error( [ 'message' => 'No featured image set for this post.' ] ); } $results = self::generate_social_formats_for_post( $post_id ); if ( $results === null ) { wp_send_json_error( [ 'message' => 'Could not generate formats — check the featured image file and platform settings.' ] ); } // Mark as up-to-date so the save hook won't re-run for this thumbnail. update_post_meta( $post_id, '_csdt_social_formats_thumb_id', (int) get_post_thumbnail_id( $post_id ) ); wp_send_json_success( $results ); } // ─── AJAX: diagnose social formats for a post ──────────────────────── // Checks: (1) what's stored in meta, (2) whether image files exist on disk, // (3) whether image URLs are reachable by each crawler UA. public static function ajax_social_diagnose_formats(): void { check_ajax_referer( self::THUMB_NONCE, 'nonce' ); if ( ! current_user_can( 'upload_files' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $post_id = absint( $_POST['post_id'] ?? 0 ); if ( ! $post_id ) { wp_send_json_error( [ 'message' => 'No post ID.' ] ); } $result = []; // ── 1. Meta state ──────────────────────────────────────────────── $formats = get_post_meta( $post_id, '_csdt_social_formats', true ); $old_formats = get_post_meta( $post_id, '_cs_social_formats', true ); $thumb_id = (int) get_post_meta( $post_id, '_csdt_social_formats_thumb_id', true ); $current_thumb = (int) get_post_thumbnail_id( $post_id ); $result['meta'] = [ 'has_new_key' => ! empty( $formats ), 'has_old_key' => ! empty( $old_formats ), 'thumb_id_saved' => $thumb_id, 'thumb_id_now' => $current_thumb, 'thumb_stale' => $thumb_id !== $current_thumb, 'no_thumbnail' => ! $current_thumb, ]; if ( empty( $formats ) && ! empty( $old_formats ) ) { $formats = $old_formats; $result['meta']['using_old_key'] = true; } // ── 2. Per-platform: file existence + URL reachability ─────────── $upload = wp_upload_dir(); $dest_dir = trailingslashit( $upload['basedir'] ) . 'social-formats/' . $post_id; $test_uas = [ 'LinkedInBot' => 'LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient +http://www.linkedin.com)', 'Facebook' => 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)', 'Twitterbot' => 'Twitterbot/1.0', ]; $platforms_out = []; foreach ( self::SOCIAL_PLATFORMS as $key => $p ) { $meta_entry = $formats[ $key ] ?? null; $entry = [ 'label' => $p['label'], 'meta_status' => 'missing', 'url' => null, 'file_exists' => false, 'ua_results' => [], ]; if ( $meta_entry !== null ) { if ( ! empty( $meta_entry['success'] ) ) { $entry['meta_status'] = 'ok'; $entry['url'] = $meta_entry['url'] ?? null; $entry['kb'] = $meta_entry['kb'] ?? null; $entry['w'] = $meta_entry['w'] ?? null; $entry['h'] = $meta_entry['h'] ?? null; } else { $entry['meta_status'] = 'failed'; $entry['error'] = $meta_entry['error'] ?? 'Unknown error'; } } // Check file on disk (try .jpg and .png). foreach ( [ 'jpg', 'png', 'webp' ] as $ext ) { $path = "{$dest_dir}/{$key}.{$ext}"; if ( file_exists( $path ) ) { $entry['file_exists'] = true; $entry['file_path'] = $path; $entry['file_kb'] = round( filesize( $path ) / 1024, 1 ); break; } } // Test URL reachability with crawler UAs (only if a URL is stored). if ( ! empty( $entry['url'] ) ) { foreach ( $test_uas as $ua_label => $ua_string ) { $resp = wp_remote_head( $entry['url'], [ 'user-agent' => $ua_string, 'timeout' => 8, 'redirection' => 3, ] ); if ( is_wp_error( $resp ) ) { $entry['ua_results'][ $ua_label ] = [ 'code' => 0, 'ok' => false, 'error' => $resp->get_error_message() ]; } else { $code = (int) wp_remote_retrieve_response_code( $resp ); $entry['ua_results'][ $ua_label ] = [ 'code' => $code, 'ok' => $code === 200 ]; } } } $platforms_out[ $key ] = $entry; } $result['platforms'] = $platforms_out; // ── 3. What og:image would each crawler see ────────────────────── $post_url = get_permalink( $post_id ); $og_seen = []; foreach ( $test_uas as $ua_label => $ua_string ) { $resp = wp_remote_get( $post_url, [ 'user-agent' => $ua_string, 'timeout' => 10, 'redirection' => 5, ] ); if ( is_wp_error( $resp ) ) { $og_seen[ $ua_label ] = [ 'ok' => false, 'error' => $resp->get_error_message() ]; continue; } $code = (int) wp_remote_retrieve_response_code( $resp ); $body = wp_remote_retrieve_body( $resp ); preg_match( '/]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']|]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']/', $body, $m ); $og_url = $m[1] ?? $m[2] ?? null; $og_seen[ $ua_label ] = [ 'ok' => $code === 200, 'code' => $code, 'og_url' => $og_url, 'has_og' => ! empty( $og_url ), ]; } $result['og_seen'] = $og_seen; wp_send_json_success( $result ); } // ─── AJAX: batch fix all posts ──────────────────────────────────────── public static function ajax_social_fix_all_batch(): void { check_ajax_referer( self::THUMB_NONCE, 'nonce' ); if ( ! current_user_can( 'upload_files' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $offset = absint( $_POST['offset'] ?? 0 ); $batch_size = 10; // process 10 per request to avoid timeouts $total = (int) wp_count_posts( 'post' )->publish; $posts = get_posts( [ 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => $batch_size, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ids', 'suppress_filters' => false, ] ); $batch_results = []; foreach ( $posts as $post_id ) { $thumb_id = get_post_thumbnail_id( $post_id ); if ( ! $thumb_id ) { $batch_results[] = [ 'post_id' => $post_id, 'skipped' => true, 'reason' => 'no_thumbnail' ]; continue; } $file = get_attached_file( (int) $thumb_id ); if ( ! $file || ! file_exists( $file ) ) { $batch_results[] = [ 'post_id' => $post_id, 'skipped' => true, 'reason' => 'file_missing' ]; continue; } $results = self::generate_social_formats_for_post( $post_id ); if ( $results ) { update_post_meta( $post_id, '_csdt_social_formats_thumb_id', (int) $thumb_id ); } $batch_results[] = [ 'post_id' => $post_id, 'skipped' => false, 'ok' => $results !== null ]; } $next_offset = $offset + count( $posts ); wp_send_json_success( [ 'total' => $total, 'offset' => $offset, 'next_offset' => $next_offset, 'has_more' => $next_offset < $total, 'batch' => $batch_results, ] ); } // ─── Crawler UA detection: serve platform-specific og:image ────────── public static function output_crawler_og_image(): void { if ( ! is_singular( 'post' ) ) return; $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; if ( ! $ua ) return; $platform = null; if ( str_contains( $ua, 'WhatsApp' ) ) $platform = 'whatsapp'; elseif ( str_contains( $ua, 'facebookexternalhit' ) || str_contains( $ua, 'Facebot' ) ) $platform = 'facebook'; elseif ( str_contains( $ua, 'Twitterbot' ) ) $platform = 'twitter'; elseif ( str_contains( $ua, 'LinkedInBot' ) ) $platform = 'linkedin'; elseif ( str_contains( $ua, 'Instagram' ) ) $platform = 'instagram'; if ( ! $platform ) return; $post_id = get_the_ID(); if ( ! $post_id ) return; $formats = get_post_meta( $post_id, '_csdt_social_formats', true ); // Backward compat: fall back to old meta key from before the cs_ → csdt_ rename. if ( empty( $formats ) ) { $formats = get_post_meta( $post_id, '_cs_social_formats', true ); } if ( empty( $formats[ $platform ]['url'] ) ) return; $img_url = esc_url( $formats[ $platform ]['url'] ); $p = self::SOCIAL_PLATFORMS[ $platform ]; // Output early — this fires at priority 1 so it lands before SEO plugin tags. // Duplicate og:image tags are fine; the first one is used by most crawlers. echo "\n\n"; echo '' . "\n"; echo '' . "\n"; echo '' . "\n"; } // ─── AJAX: Cloudflare crawler UA test ──────────────────────────────── public static function ajax_social_cf_test(): void { check_ajax_referer( self::THUMB_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : home_url( '/' ); if ( ! self::is_safe_external_url( $url ) ) { wp_send_json_error( [ 'message' => 'URL must be a publicly accessible address.' ] ); } wp_send_json_success( self::social_test_crawlers( $url ) ); } // ─── AJAX: Cloudflare cache purge ──────────────────────────────────── public static function ajax_cf_purge(): void { check_ajax_referer( self::THUMB_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $zone_id = get_option( 'csdt_devtools_cf_zone_id', '' ); $token = get_option( 'csdt_devtools_cf_api_token', '' ); if ( ! $zone_id || ! $token ) { wp_send_json_error( [ 'message' => __( 'Cloudflare Zone ID and API Token are required. Please save them above.', 'cloudscale-devtools' ) ] ); } $purge_url = isset( $_POST['purge_url'] ) ? esc_url_raw( wp_unslash( $_POST['purge_url'] ) ) : ''; // Ensure purge_url belongs to this site — prevents purging arbitrary Cloudflare-cached URLs. if ( $purge_url && strpos( $purge_url, home_url() ) !== 0 ) { wp_send_json_error( [ 'message' => __( 'URL must belong to this site.', 'cloudscale-devtools' ) ] ); } $cf_api = "https://api.cloudflare.com/client/v4/zones/{$zone_id}/purge_cache"; $body = $purge_url ? wp_json_encode( [ 'files' => [ $purge_url ] ] ) : wp_json_encode( [ 'purge_everything' => true ] ); $response = wp_remote_post( $cf_api, [ 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], 'body' => $body, 'timeout' => 15, ] ); if ( is_wp_error( $response ) ) { wp_send_json_error( [ 'message' => $response->get_error_message() ] ); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $data['success'] ) ) { wp_send_json_success( [ 'message' => $purge_url ? sprintf( __( 'Cache purged for: %s', 'cloudscale-devtools' ), $purge_url ) : __( 'Entire Cloudflare cache purged successfully.', 'cloudscale-devtools' ), ] ); } else { $errors = isset( $data['errors'] ) ? wp_json_encode( $data['errors'] ) : __( 'Unknown error', 'cloudscale-devtools' ); wp_send_json_error( [ 'message' => $errors ] ); } } // ─── AJAX: save CF credentials ─────────────────────────────────────── public static function ajax_cf_save(): void { check_ajax_referer( self::THUMB_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $zone_id = isset( $_POST['zone_id'] ) ? sanitize_text_field( wp_unslash( $_POST['zone_id'] ) ) : ''; $token = isset( $_POST['api_token'] ) ? sanitize_text_field( wp_unslash( $_POST['api_token'] ) ) : ''; update_option( 'csdt_devtools_cf_zone_id', $zone_id ); if ( $token !== '' ) { update_option( 'csdt_devtools_cf_api_token', $token ); } wp_send_json_success( [ 'message' => __( 'Cloudflare settings saved.', 'cloudscale-devtools' ) ] ); } // ─── Private: full URL diagnostic ──────────────────────────────────── /** * Runs all social-preview checks against a URL and returns a structured * result array with sections (title + result items) and summary totals. */ private static function social_diagnose_url( string $url ): array { $sections = []; $og_image = ''; $img_kb = null; $img_w = null; $img_h = null; $wa_ua = self::SOCIAL_UAS['WhatsApp']; // 1. HTTPS $pass = str_starts_with( $url, 'https://' ); $sections[] = [ 'title' => 'HTTPS', 'results' => [ $pass ? [ 'type' => 'pass', 'msg' => 'URL uses HTTPS' ] : [ 'type' => 'fail', 'msg' => 'URL uses HTTP — WhatsApp requires HTTPS for link previews.', 'fix' => 'Install an SSL certificate (Let\'s Encrypt is free) and update your WordPress Address and Site Address in Settings → General to use https://. Add a redirect rule to force HTTP → HTTPS.' ] ], ]; // 2. HTTP response (WhatsApp UA) $head = wp_remote_head( $url, [ 'user-agent' => $wa_ua, 'redirection' => 5, 'timeout' => 12, 'sslverify' => true ] ); $http_ok = false; if ( is_wp_error( $head ) ) { $sections[] = [ 'title' => 'HTTP Response', 'results' => [ [ 'type' => 'fail', 'msg' => 'Could not connect: ' . $head->get_error_message() ] ] ]; } else { $code = wp_remote_retrieve_response_code( $head ); $http_ok = ( $code === 200 ); $is_redirect = in_array( $code, [ 301, 302, 307, 308 ], true ); $sections[] = [ 'title' => 'HTTP Response (WhatsApp UA)', 'results' => [ $code === 200 ? [ 'type' => 'pass', 'msg' => 'HTTP 200 OK' ] : [ 'type' => $is_redirect ? 'warn' : 'fail', 'msg' => "HTTP $code — " . ( $is_redirect ? 'Redirect (crawlers follow, but adds latency)' : 'Non-200 response; crawler may not read OG tags' ), 'fix' => $is_redirect ? 'Update og:url and your canonical tag to point directly to the final URL to avoid the redirect chain.' : 'Check your WAF, Cloudflare firewall rules, or bot-protection plugins — they may be blocking the WhatsApp crawler User-Agent. Add facebookexternalhit, WhatsApp, Twitterbot, and LinkedInBot to your allowlist.' ] ] ]; } // 3. Fetch HTML + measure response time $start = microtime( true ); $resp = wp_remote_get( $url, [ 'user-agent' => $wa_ua, 'redirection' => 5, 'timeout' => 18 ] ); $elapsed = round( microtime( true ) - $start, 2 ); $html = is_wp_error( $resp ) ? '' : wp_remote_retrieve_body( $resp ); $sections[] = [ 'title' => 'Response Time', 'results' => [ $elapsed < 3.0 ? [ 'type' => 'pass', 'msg' => "{$elapsed}s — within 3s crawler timeout" ] : ( $elapsed < 5.0 ? [ 'type' => 'warn', 'msg' => "{$elapsed}s — approaching WhatsApp 3–5s timeout", 'fix' => 'Enable a page caching plugin (e.g. WP Super Cache or W3 Total Cache) and enable Cloudflare\'s HTML caching. Crawlers hit cold pages — caching ensures they get a fast response every time.' ] : [ 'type' => 'fail', 'msg' => "{$elapsed}s — exceeds 5s; crawler will likely abort before reading OG tags", 'fix' => 'Enable full-page caching immediately. Check for slow database queries using the CS Monitor DB tab, deactivate heavy plugins, and enable Cloudflare in front of the origin server.' ] ) ] ]; // 4. OG tags $og_results = []; $og_fixes = [ 'og:title' => 'Add to the . Use an SEO plugin like Yoast or Rank Math — they generate this automatically from your post title.', 'og:description' => 'Add . Most SEO plugins set this from the meta description field on each post/page.', 'og:image' => 'Add . Use a 1200×630px JPEG/PNG under 300 KB. Set a site-wide fallback in your SEO plugin settings.', 'og:url' => 'Add using the canonical URL of this page.', 'og:type' => 'Add (or "article" for blog posts). Most SEO plugins set this automatically.', ]; foreach ( [ 'og:title', 'og:description', 'og:image', 'og:url', 'og:type' ] as $prop ) { $val = self::social_extract_property( $html, $prop ); $og_results[] = $val ? [ 'type' => 'pass', 'msg' => "$prop: " . mb_substr( $val, 0, 80 ) ] : [ 'type' => 'fail', 'msg' => "$prop is missing", 'fix' => $og_fixes[ $prop ] ?? '' ]; } foreach ( [ 'twitter:card', 'twitter:image' ] as $name ) { $val = self::social_extract_name( $html, $name ); $og_results[] = $val ? [ 'type' => 'pass', 'msg' => "$name: " . mb_substr( $val, 0, 80 ) ] : [ 'type' => 'warn', 'msg' => "$name missing — X/Twitter may not render large card", 'fix' => $name === 'twitter:card' ? 'Add to show the full-width image card on X/Twitter. Most SEO plugins have a Twitter Card setting.' : 'Add . X/Twitter uses this over og:image if present.' ]; } $sections[] = [ 'title' => 'Open Graph Tags', 'results' => $og_results ]; // 5. og:image analysis $og_image = self::social_extract_property( $html, 'og:image' ); $img_results = []; if ( ! $og_image ) { $img_results[] = [ 'type' => 'fail', 'msg' => 'og:image is missing — cannot analyse image.', 'fix' => 'Set a featured image on this post/page and ensure your SEO plugin is configured to use it as og:image. Add a site-wide fallback image in your SEO plugin settings.' ]; } else { $img_head = wp_remote_head( $og_image, [ 'user-agent' => $wa_ua, 'timeout' => 10, 'redirection' => 3 ] ); if ( is_wp_error( $img_head ) ) { $img_results[] = [ 'type' => 'fail', 'msg' => 'og:image URL unreachable: ' . $img_head->get_error_message(), 'fix' => 'Verify the image URL is publicly accessible. Check that the file exists in your Media Library and that no security plugin or Cloudflare rule is blocking direct image access.' ]; } else { $img_code = wp_remote_retrieve_response_code( $img_head ); $img_results[] = $img_code === 200 ? [ 'type' => 'pass', 'msg' => 'og:image URL returns HTTP 200' ] : [ 'type' => 'fail', 'msg' => "og:image URL returns HTTP $img_code — image inaccessible", 'fix' => "The image file returned HTTP $img_code. Re-upload the image to your Media Library, update the og:image URL to the new path, and confirm the file is publicly readable (check file permissions and any WAF rules blocking image requests)." ]; $ct = wp_remote_retrieve_header( $img_head, 'content-type' ); $img_results[] = str_contains( (string) $ct, 'image/' ) ? [ 'type' => 'pass', 'msg' => "Content-Type: $ct" ] : [ 'type' => 'warn', 'msg' => "Unexpected Content-Type: '$ct'", 'fix' => "The URL is not serving an image file. Verify the og:image URL points directly to a JPEG, PNG, or WebP file (not a page or redirect). If using a CDN, check that it is not transforming the response Content-Type." ]; } $img_results[] = str_starts_with( $og_image, 'https://' ) ? [ 'type' => 'pass', 'msg' => 'og:image uses HTTPS' ] : [ 'type' => 'fail', 'msg' => 'og:image uses HTTP — WhatsApp requires HTTPS images', 'fix' => 'Update the og:image URL to use https://. If your site has SSL, the image URL should automatically use HTTPS — check your SEO plugin settings or the post\'s custom OG image field.' ]; $img_resp = wp_remote_get( $og_image, [ 'user-agent' => $wa_ua, 'timeout' => 20, 'redirection' => 3 ] ); if ( ! is_wp_error( $img_resp ) ) { $img_body = wp_remote_retrieve_body( $img_resp ); $img_bytes = strlen( $img_body ); $img_kb = round( $img_bytes / 1024, 1 ); if ( $img_bytes > 307200 ) { $img_results[] = [ 'type' => 'fail', 'msg' => "Image is {$img_kb} KB — exceeds WhatsApp's 300 KB silent-failure threshold. Compress to under 250 KB.", 'fix' => "Use the Media Library Audit below to recompress this image, or use squoosh.app / TinyPNG to manually compress it to under 250 KB. Then re-upload and update the og:image URL." ]; } elseif ( $img_bytes > 204800 ) { $img_results[] = [ 'type' => 'warn', 'msg' => "Image is {$img_kb} KB — approaching 300 KB WhatsApp limit. Consider optimising.", 'fix' => "Compress the image to under 200 KB using TinyPNG or the Media Library Audit recompress tool below. JPEG at 80% quality typically achieves good compression without visible quality loss." ]; } else { $img_results[] = [ 'type' => 'pass', 'msg' => "Image is {$img_kb} KB — within the 300 KB WhatsApp limit." ]; } if ( function_exists( 'imagecreatefromstring' ) ) { $res = @imagecreatefromstring( $img_body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( $res ) { $img_w = imagesx( $res ); $img_h = imagesy( $res ); imagedestroy( $res ); if ( $img_w >= 1200 && $img_h >= 630 ) { $img_results[] = [ 'type' => 'pass', 'msg' => "Dimensions: {$img_w}×{$img_h}px — meets 1200×630 minimum" ]; } elseif ( $img_w >= 600 ) { $img_results[] = [ 'type' => 'warn', 'msg' => "Dimensions: {$img_w}×{$img_h}px — below recommended 1200×630", 'fix' => "Resize or recreate the image at 1200×630px. This is the optimal size for Facebook, LinkedIn, and WhatsApp. Use Canva or your image editor to export at exactly 1200×630px." ]; } else { $img_results[] = [ 'type' => 'fail', 'msg' => "Dimensions: {$img_w}×{$img_h}px — too small for reliable social previews", 'fix' => "Replace this image with one at least 1200×630px. Images smaller than 600px wide are often ignored by social crawlers entirely. Create a new featured image at 1200×630px." ]; } } } } } $sections[] = [ 'title' => 'og:image Analysis', 'results' => $img_results ]; // 6. robots.txt $base_url = preg_replace( '#(https?://[^/]+).*#', '$1', $url ); $robots_resp = wp_remote_get( "$base_url/robots.txt", [ 'timeout' => 8 ] ); $rb_results = []; if ( is_wp_error( $robots_resp ) || wp_remote_retrieve_response_code( $robots_resp ) !== 200 ) { $rb_results[] = [ 'type' => 'warn', 'msg' => 'robots.txt not found — ensure crawlers are not blocked elsewhere', 'fix' => 'Create a robots.txt at your domain root. In WordPress, go to Settings → Reading and ensure "Discourage search engines" is unchecked. Yoast SEO auto-generates robots.txt — enable it if available.' ]; } else { $rb_body = wp_remote_retrieve_body( $robots_resp ); foreach ( [ 'facebookexternalhit', 'WhatsApp', 'Facebot', 'LinkedInBot', 'Twitterbot' ] as $bot ) { if ( preg_match( '/User-agent:\s*' . preg_quote( $bot, '/' ) . '.*?Disallow:\s*\//si', $rb_body ) ) { $rb_results[] = [ 'type' => 'fail', 'msg' => "robots.txt blocks $bot — this prevents all previews from that platform", 'fix' => "Remove the Disallow rule for $bot from your robots.txt. Add \"User-agent: $bot\\nDisallow:\" (empty Disallow = allow all) to explicitly permit this crawler. Edit robots.txt via your SEO plugin or directly in the site root." ]; } else { $rb_results[] = [ 'type' => 'pass', 'msg' => "robots.txt does not block $bot" ]; } } } $sections[] = [ 'title' => 'robots.txt', 'results' => $rb_results ]; // 7. Cloudflare detection $cf_results = []; $cf_ray = wp_remote_retrieve_header( is_wp_error( $head ) ? [] : $head, 'cf-ray' ); if ( $cf_ray ) { $cf_cache = wp_remote_retrieve_header( is_wp_error( $head ) ? [] : $head, 'cf-cache-status' ); $cf_results[] = [ 'type' => 'pass', 'msg' => "Cloudflare active: cf-ray $cf_ray" . ( $cf_cache ? " | Cache: $cf_cache" : '' ) ]; $cf_results[] = [ 'type' => 'info', 'msg' => 'If any crawler UA test failed, set up a WAF Skip rule in Cloudflare for social crawler user agents — see the Cloudflare Setup panel.' ]; } else { $cf_results[] = [ 'type' => 'pass', 'msg' => 'No Cloudflare detected — WAF skip rule not required' ]; } $sections[] = [ 'title' => 'Cloudflare', 'results' => $cf_results ]; // Totals $pass = $warn = $fail = 0; foreach ( $sections as $s ) { foreach ( $s['results'] as $r ) { match ( $r['type'] ) { 'pass' => $pass++, 'warn' => $warn++, 'fail' => $fail++, default => null }; } } return [ 'url' => $url, 'sections' => $sections, 'totals' => [ 'pass' => $pass, 'warn' => $warn, 'fail' => $fail ], 'og_image' => $og_image, 'img_kb' => $img_kb, 'img_w' => $img_w, 'img_h' => $img_h, ]; } /** Runs the five social crawler UA tests against a URL — used by the CF test button. */ private static function social_test_crawlers( string $url ): array { $results = []; foreach ( self::SOCIAL_UAS as $label => $ua ) { $resp = wp_remote_get( $url, [ 'user-agent' => $ua, 'redirection' => 5, 'timeout' => 15 ] ); if ( is_wp_error( $resp ) ) { $results[ $label ] = [ 'type' => 'fail', 'code' => 0, 'og' => false, 'msg' => $resp->get_error_message() ]; continue; } $code = wp_remote_retrieve_response_code( $resp ); $body = wp_remote_retrieve_body( $resp ); $has_og = (bool) preg_match( '/property=["\']og:image["\']/', $body ); $challenged = str_contains( $body, 'challenge-platform' ); if ( $code === 200 && $has_og ) { $results[ $label ] = [ 'type' => 'pass', 'code' => $code, 'og' => true, 'msg' => $challenged ? 'HTTP 200, og:image present (Cloudflare challenge script detected — WAF skip rule is working)' : 'HTTP 200, og:image present' ]; } elseif ( $code === 200 && ! $has_og ) { $results[ $label ] = [ 'type' => 'fail', 'code' => $code, 'og' => false, 'msg' => $challenged ? 'HTTP 200 but og:image absent — Bot Fight Mode is blocking this crawler. WAF skip rule needed.' : 'HTTP 200 but og:image absent in response' ]; } else { $results[ $label ] = [ 'type' => 'fail', 'code' => $code, 'og' => false, 'msg' => "HTTP $code — crawler is being blocked" ]; } } return $results; } /** Helper: extract og:meta property content. */ private static function social_extract_property( string $html, string $prop ): string { if ( preg_match( '/property=["\']' . preg_quote( $prop, '/' ) . '["\'][^>]+content=["\']([^"\']+)["\']/', $html, $m ) ) { return trim( $m[1] ); } if ( preg_match( '/content=["\']([^"\']+)["\'][^>]+property=["\']' . preg_quote( $prop, '/' ) . '["\']/', $html, $m ) ) { return trim( $m[1] ); } return ''; } /** Helper: extract meta name content. */ private static function social_extract_name( string $html, string $name ): string { if ( preg_match( '/name=["\']' . preg_quote( $name, '/' ) . '["\'][^>]+content=["\']([^"\']+)["\']/', $html, $m ) ) { return trim( $m[1] ); } if ( preg_match( '/content=["\']([^"\']+)["\'][^>]+name=["\']' . preg_quote( $name, '/' ) . '["\']/', $html, $m ) ) { return trim( $m[1] ); } return ''; } /** * Helper: recompress an attachment to under 300 KB using WP_Image_Editor. * * @return array|\WP_Error */ private static function social_recompress_image( int $attachment_id ) { $file_path = get_attached_file( $attachment_id ); if ( ! $file_path || ! file_exists( $file_path ) ) { return new \WP_Error( 'not_found', __( 'Attachment file not found on disk.', 'cloudscale-devtools' ) ); } $ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ); if ( ! in_array( $ext, [ 'jpg', 'jpeg', 'png' ], true ) ) { return new \WP_Error( 'unsupported', __( 'Only JPEG and PNG images can be recompressed.', 'cloudscale-devtools' ) ); } // Backup the original. $backup = $file_path . '.cs-backup'; if ( ! copy( $file_path, $backup ) ) { return new \WP_Error( 'backup_failed', __( 'Could not create backup — aborting to protect original.', 'cloudscale-devtools' ) ); } $editor = wp_get_image_editor( $file_path ); if ( is_wp_error( $editor ) ) { wp_delete_file( $backup ); return $editor; } // Resize if larger than 1200×630 (maintaining aspect ratio, no upscaling). $size = $editor->get_size(); if ( $size['width'] > 1200 || $size['height'] > 630 ) { $editor->resize( 1200, 630, false ); } $editor->set_quality( 80 ); $saved = $editor->save( $file_path ); if ( is_wp_error( $saved ) ) { copy( $backup, $file_path ); wp_delete_file( $backup ); return $saved; } $new_bytes = filesize( $file_path ); // Still over 300 KB? Try quality 65. if ( $new_bytes > 307200 ) { $e2 = wp_get_image_editor( $backup ); if ( ! is_wp_error( $e2 ) ) { $e2->set_quality( 65 ); $e2->save( $file_path ); $new_bytes = filesize( $file_path ); } } $new_kb = round( $new_bytes / 1024, 1 ); // Regenerate attachment metadata. $meta = wp_generate_attachment_metadata( $attachment_id, $file_path ); wp_update_attachment_metadata( $attachment_id, $meta ); return [ 'attachment_id' => $attachment_id, 'new_size_kb' => $new_kb, 'backup' => basename( $backup ), 'under_limit' => $new_bytes <= 307200, 'message' => $new_bytes <= 307200 ? sprintf( __( 'Recompressed to %s KB — within the WhatsApp 300 KB threshold.', 'cloudscale-devtools' ), $new_kb ) : sprintf( __( 'Recompressed to %s KB — still above threshold. Manual intervention needed.', 'cloudscale-devtools' ), $new_kb ), ]; } /* ================================================================== Security tab helpers + render ================================================================== */ private static function default_security_prompt(): string { return 'You are an expert WordPress security auditor with deep knowledge of WordPress internals, common attack vectors, and security hardening best practices.' . "\n\nAnalyse the provided site configuration and return a comprehensive, prioritised security assessment." . "\n\nReturn ONLY valid JSON — no markdown code fences, no explanation outside the JSON. Use this exact schema:" . "\n{\"score\":,\"score_label\":\"\",\"summary\":\"<2-3 sentence executive summary>\"," . "\"critical\":[{\"title\":\"...\",\"detail\":\"...\",\"fix\":\"...\"}]," . "\"high\":[{\"title\":\"...\",\"detail\":\"...\",\"fix\":\"...\"}]," . "\"medium\":[{\"title\":\"...\",\"detail\":\"...\",\"fix\":\"...\"}]," . "\"low\":[{\"title\":\"...\",\"detail\":\"...\",\"fix\":\"...\"}]," . "\"good\":[{\"title\":\"...\",\"detail\":\"...\"}]}" . "\n\nScoring: 90-100 Excellent, 75-89 Good, 55-74 Fair, 35-54 Poor, 0-34 Critical." . "\n\nFor each issue — title: concise problem name; detail: specific risk with exploit path; fix: exact actionable steps (include WP-CLI commands, file paths, or wp-config.php constants where relevant)." . "\n\nAnalyse: WordPress/PHP version currency, plugin/theme security posture, authentication hardening (2FA, brute-force, admin username), configuration security (debug mode, file editing, DB prefix), exposed sensitive files, HTTP security headers, HTTPS enforcement, and any notable risk combinations."; } private static function gather_security_data(): array { global $wpdb; if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); $active_plugins = (array) get_option( 'active_plugins', [] ); $plugin_updates = get_site_transient( 'update_plugins' ); $plugins_list = []; foreach ( $all_plugins as $file => $p ) { $has_upd = is_object( $plugin_updates ) && isset( $plugin_updates->response[ $file ] ); $plugins_list[] = [ 'name' => $p['Name'], 'version' => $p['Version'], 'active' => in_array( $file, $active_plugins, true ), 'update' => $has_upd, 'new_ver' => $has_upd ? ( $plugin_updates->response[ $file ]->new_version ?? null ) : null, ]; } usort( $plugins_list, fn( $a, $b ) => (int) $b['active'] - (int) $a['active'] ); $wp_updates = get_site_transient( 'update_core' ); $wp_current = get_bloginfo( 'version' ); $wp_latest = $wp_updates->updates[0]->version ?? $wp_current; $user_counts = count_users(); $admin_user_exists = (bool) get_user_by( 'login', 'admin' ); $sec_headers = []; $home_resp = wp_remote_get( home_url( '/' ), [ 'timeout' => 5, 'sslverify' => false, 'user-agent' => 'Mozilla/5.0 (compatible; CSDT-SecurityScan/1.0)', ] ); if ( ! is_wp_error( $home_resp ) ) { $h = wp_remote_retrieve_headers( $home_resp ); foreach ( [ 'x-frame-options', 'x-content-type-options', 'strict-transport-security', 'content-security-policy', 'referrer-policy', 'permissions-policy' ] as $hname ) { $sec_headers[ $hname ] = $h[ $hname ] ?? null; } } $exposed = []; foreach ( [ 'readme.html', 'license.txt', 'wp-config.php.bak', '.env' ] as $f ) { if ( file_exists( ABSPATH . $f ) ) { $check = wp_remote_head( home_url( '/' . $f ), [ 'timeout' => 3, 'sslverify' => false ] ); if ( ! is_wp_error( $check ) && (int) wp_remote_retrieve_response_code( $check ) === 200 ) { $exposed[] = $f; } } } $config_perms = file_exists( ABSPATH . 'wp-config.php' ) ? substr( sprintf( '%o', fileperms( ABSPATH . 'wp-config.php' ) ), -4 ) : 'unknown'; return [ 'wordpress' => [ 'version' => $wp_current, 'latest' => $wp_latest, 'up_to_date' => version_compare( $wp_current, $wp_latest, '>=' ), ], 'php_version' => PHP_VERSION, 'plugins' => $plugins_list, 'plugin_summary' => [ 'total' => count( $plugins_list ), 'active' => count( array_filter( $plugins_list, fn( $p ) => $p['active'] ) ), 'inactive' => count( array_filter( $plugins_list, fn( $p ) => ! $p['active'] ) ), 'outdated' => count( array_filter( $plugins_list, fn( $p ) => $p['update'] ) ), ], 'users' => [ 'admin_login_exists' => $admin_user_exists, 'admin_count' => $user_counts['avail_roles']['administrator'] ?? 0, 'total_users' => $user_counts['total_users'], ], 'configuration' => [ 'wp_debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, 'wp_debug_display' => defined( 'WP_DEBUG_DISPLAY' ) && WP_DEBUG_DISPLAY, 'wp_debug_log' => defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG, 'disallow_file_edit' => defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT, 'disallow_file_mods' => defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS, 'force_ssl_admin' => defined( 'FORCE_SSL_ADMIN' ) && FORCE_SSL_ADMIN, 'db_prefix' => $wpdb->prefix, 'db_prefix_default' => $wpdb->prefix === 'wp_', 'wp_config_perms' => $config_perms, ], 'site' => [ 'url' => home_url( '/' ), 'is_https' => is_ssl(), 'login_url_hidden' => get_option( 'csdt_devtools_login_hide_enabled', '0' ) === '1', 'xmlrpc_exists' => file_exists( ABSPATH . 'xmlrpc.php' ) && (bool) apply_filters( 'xmlrpc_enabled', true ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound 'open_registration' => (bool) get_option( 'users_can_register', 0 ), 'pingbacks_enabled' => get_option( 'default_ping_status' ) === 'open', 'wp_version_in_meta' => ! has_filter( 'the_generator', '__return_empty_string' ) && ! has_filter( 'wp_head', 'wp_generator' ), 'default_comment_status' => get_option( 'default_comment_status' ), ], 'security_features' => [ 'brute_force_enabled' => get_option( 'csdt_devtools_brute_force_enabled', '1' ) === '1', 'two_fa_site_method' => get_option( 'csdt_devtools_2fa_method', 'off' ), 'two_fa_totp_admins' => ( function () { $admins = get_users( [ 'role' => 'administrator', 'fields' => 'ID' ] ); $count = 0; foreach ( $admins as $id ) { if ( get_user_meta( (int) $id, 'csdt_devtools_totp_enabled', true ) === '1' ) { $count++; } } return $count; } )(), 'passkeys_admin_count' => ( function () { $admins = get_users( [ 'role' => 'administrator', 'fields' => 'ID' ] ); $count = 0; foreach ( $admins as $id ) { // Passkeys stored as JSON string — use the class method which decodes it $keys = CSDT_DevTools_Passkey::get_passkeys( (int) $id ); if ( ! empty( $keys ) ) { $count++; } } return $count; } )(), 'admin_count' => count( get_users( [ 'role' => 'administrator', 'fields' => 'ID' ] ) ), 'failed_logins_1h' => (int) get_transient( 'csdt_devtools_failed_logins_1h' ), 'failed_logins_24h' => (int) get_transient( 'csdt_devtools_failed_logins_24h' ), 'app_passwords' => ( function () { $enabled = function_exists( 'wp_is_application_passwords_available' ) && wp_is_application_passwords_available(); $admins_with = 0; $total_app_pw = 0; if ( $enabled ) { foreach ( get_users( [ 'role' => 'administrator', 'fields' => 'ID' ] ) as $id ) { $pws = WP_Application_Passwords::get_user_application_passwords( (int) $id ); if ( ! empty( $pws ) ) { $admins_with++; $total_app_pw += count( $pws ); } } } return [ 'enabled' => $enabled, 'admins_with_app_pw'=> $admins_with, 'total_app_passwords'=> $total_app_pw, ]; } )(), ], 'exposed_files' => $exposed, 'security_headers' => $sec_headers, ]; } public static function strip_asset_ver( string $src ): string { if ( strpos( $src, 'ver=' ) !== false ) { $src = remove_query_arg( 'ver', $src ); } return $src; } private static function get_quick_fixes(): array { $app_pw_available = function_exists( 'wp_is_application_passwords_available' ) && wp_is_application_passwords_available(); return [ [ 'id' => 'disable_pingbacks', 'title' => 'Pingbacks & trackbacks enabled', 'detail' => 'WordPress sends/receives trackback notifications — commonly abused for DDoS amplification and spam.', 'fixed' => get_option( 'default_ping_status' ) !== 'open', 'fix_label' => 'Disable Pingbacks', ], [ 'id' => 'close_registration', 'title' => 'Open user registration', 'detail' => 'Anyone can register an account on this site. Widens attack surface for spam and privilege escalation.', 'fixed' => ! (bool) get_option( 'users_can_register', 0 ), 'fix_label' => 'Disable Registration', ], [ 'id' => 'disable_app_passwords', 'title' => 'Application passwords enabled', 'detail' => get_option( 'csdt_devtools_test_accounts_enabled', '0' ) === '1' ? 'App passwords are required for the Test Account Manager feature and are intentionally enabled.' : 'App passwords allow REST API authentication and can bypass two-factor authentication. Disable unless needed.', 'fixed' => get_option( 'csdt_devtools_disable_app_passwords', '0' ) === '1' || ! $app_pw_available || get_option( 'csdt_devtools_test_accounts_enabled', '0' ) === '1', 'fix_label' => 'Disable App Passwords', ], [ 'id' => 'hide_wp_version', 'title' => 'WordPress version exposed in HTML', 'detail' => 'The meta tag and asset ?ver= query strings reveal your WP version, helping attackers target known vulnerabilities.', 'fixed' => get_option( 'csdt_devtools_hide_wp_version', '0' ) === '1' || has_filter( 'the_generator', '__return_empty_string' ), 'fix_label' => 'Hide WP Version', ], [ 'id' => 'close_comments', 'title' => 'Comments open by default on new posts', 'detail' => 'Open comments invite spam, XSS payloads, and link injection attacks.', 'fixed' => get_option( 'default_comment_status' ) !== 'open', 'fix_label' => 'Close Comments', ], [ 'id' => 'wpconfig_perms', 'title' => 'wp-config.php permissions too open (0644)', 'detail' => '0644 is world-readable. Tighten to 0600 so only the server process owner can read DB credentials and salts.', 'fixed' => ( function () { $f = ABSPATH . 'wp-config.php'; if ( ! file_exists( $f ) ) { return true; } $perms = substr( sprintf( '%o', fileperms( $f ) ), -4 ); return in_array( $perms, [ '0600', '0640' ], true ); } )(), 'fix_label' => 'Set to 0600', ], [ 'id' => 'security_headers', 'title' => 'Security headers not set', 'detail' => 'X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Permissions-Policy are missing. These prevent MIME sniffing, clickjacking, and referrer leakage.', 'fixed' => get_option( 'csdt_devtools_safe_headers_enabled', '0' ) === '1', 'fix_label' => 'Enable Headers', ], [ 'id' => 'block_debug_log', 'title' => 'debug.log exposed publicly', 'detail' => 'debug.log is HTTP-accessible. On nginx, .htaccess rules are ignored — the only PHP-level fix is to move the file one directory above the web root. It stays readable via the Server Logs tab.', 'fixed' => ! file_exists( WP_CONTENT_DIR . '/debug.log' ), 'fix_label' => 'Move Outside Web Root', ], ]; } // ── Security Headers ────────────────────────────────────────────────────── public static function output_security_headers(): void { if ( is_admin() ) { return; } if ( get_option( 'csdt_devtools_safe_headers_enabled', '0' ) === '1' ) { header( 'X-Content-Type-Options: nosniff' ); header( 'X-Frame-Options: SAMEORIGIN' ); header( 'Referrer-Policy: strict-origin-when-cross-origin' ); header( 'Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()' ); } if ( get_option( 'csdt_devtools_csp_enabled', '0' ) === '1' ) { $csp = self::build_csp_header(); if ( $csp ) { $mode = get_option( 'csdt_devtools_csp_mode', 'enforce' ); $hdr = $mode === 'report_only' ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; header( $hdr . ': ' . $csp ); } } } private static function build_csp_header(): string { $services = json_decode( get_option( 'csdt_devtools_csp_services', '[]' ), true ); if ( ! is_array( $services ) ) { $services = []; } $custom = trim( get_option( 'csdt_devtools_csp_custom', '' ) ); $d = [ 'default-src' => [ "'self'" ], 'script-src' => [ "'self'", "'unsafe-inline'" ], 'style-src' => [ "'self'", "'unsafe-inline'" ], 'img-src' => [ "'self'", 'data:', 'https:' ], 'font-src' => [ "'self'", 'data:' ], 'connect-src' => [ "'self'" ], 'frame-src' => [ "'self'" ], 'object-src' => [ "'none'" ], 'base-uri' => [ "'self'" ], 'form-action' => [ "'self'" ], ]; $map = [ 'google_analytics' => [ 'script-src' => [ 'https://www.googletagmanager.com', 'https://www.google-analytics.com' ], 'img-src' => [ 'https://www.google-analytics.com', 'https://www.googletagmanager.com' ], 'connect-src' => [ 'https://www.google-analytics.com', 'https://analytics.google.com', 'https://stats.g.doubleclick.net', 'https://region1.google-analytics.com' ], ], 'google_adsense' => [ 'script-src' => [ 'https://pagead2.googlesyndication.com', 'https://partner.googleadservices.com', 'https://tpc.googlesyndication.com' ], 'frame-src' => [ 'https://googleads.g.doubleclick.net', 'https://tpc.googlesyndication.com' ], 'img-src' => [ 'https://pagead2.googlesyndication.com' ], 'connect-src' => [ 'https://pagead2.googlesyndication.com' ], ], 'google_tag_manager' => [ 'script-src' => [ 'https://www.googletagmanager.com' ], 'img-src' => [ 'https://www.googletagmanager.com' ], ], 'cloudflare_insights' => [ 'script-src' => [ 'https://static.cloudflareinsights.com' ], 'connect-src' => [ 'https://cloudflareinsights.com' ], ], 'facebook_pixel' => [ 'script-src' => [ 'https://connect.facebook.net' ], 'img-src' => [ 'https://www.facebook.com' ], 'connect-src' => [ 'https://www.facebook.com' ], ], 'recaptcha' => [ 'script-src' => [ 'https://www.google.com', 'https://www.gstatic.com' ], 'frame-src' => [ 'https://www.google.com' ], ], 'youtube' => [ 'frame-src' => [ 'https://www.youtube.com', 'https://www.youtube-nocookie.com' ], ], 'vimeo' => [ 'frame-src' => [ 'https://player.vimeo.com' ], ], ]; foreach ( $services as $svc ) { if ( ! isset( $map[ $svc ] ) ) { continue; } foreach ( $map[ $svc ] as $dir => $vals ) { foreach ( $vals as $v ) { if ( ! in_array( $v, $d[ $dir ], true ) ) { $d[ $dir ][] = $v; } } } } $parts = []; foreach ( $d as $dir => $vals ) { $parts[] = $dir . ' ' . implode( ' ', $vals ); } if ( $custom ) { $parts[] = $custom; } return implode( '; ', $parts ); } private static function render_csp_panel(): void { $csp_on = get_option( 'csdt_devtools_csp_enabled', '0' ) === '1'; $csp_mode = get_option( 'csdt_devtools_csp_mode', 'enforce' ); $csp_services = json_decode( get_option( 'csdt_devtools_csp_services', '[]' ), true ); if ( ! is_array( $csp_services ) ) { $csp_services = []; } $csp_custom = get_option( 'csdt_devtools_csp_custom', '' ); $csp_backup = json_decode( get_option( 'csdt_devtools_csp_backup', '' ), true ); $backup_time = is_array( $csp_backup ) ? ( $csp_backup['saved_at'] ?? 0 ) : 0; $services = [ 'google_analytics' => 'Google Analytics (GA4 / gtag.js)', 'google_adsense' => 'Google AdSense', 'google_tag_manager' => 'Google Tag Manager', 'cloudflare_insights' => 'Cloudflare Web Analytics', 'facebook_pixel' => 'Facebook Pixel', 'recaptcha' => 'Google reCAPTCHA', 'youtube' => 'YouTube embeds', 'vimeo' => 'Vimeo embeds', ]; ?>
🛡️
$label ) : ?>

            
get_option( 'csdt_devtools_csp_enabled', '0' ), 'mode' => get_option( 'csdt_devtools_csp_mode', 'enforce' ), 'services' => get_option( 'csdt_devtools_csp_services', '[]' ), 'custom' => get_option( 'csdt_devtools_csp_custom', '' ), 'saved_at' => time(), ] ) ); update_option( 'csdt_devtools_csp_enabled', $enabled === '1' ? '1' : '0' ); update_option( 'csdt_devtools_csp_mode', in_array( $mode, [ 'enforce', 'report_only' ], true ) ? $mode : 'enforce' ); update_option( 'csdt_devtools_csp_services', wp_json_encode( $services ) ); update_option( 'csdt_devtools_csp_custom', $custom ); wp_send_json_success( [ 'has_backup' => true ] ); } public static function ajax_csp_rollback(): void { check_ajax_referer( 'csdt_devtools_security_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $raw = get_option( 'csdt_devtools_csp_backup', '' ); if ( ! $raw ) { wp_send_json_error( 'No backup available' ); } $backup = json_decode( $raw, true ); if ( ! is_array( $backup ) ) { wp_send_json_error( 'Backup corrupt' ); } update_option( 'csdt_devtools_csp_enabled', $backup['enabled'] ?? '0' ); update_option( 'csdt_devtools_csp_mode', $backup['mode'] ?? 'enforce' ); update_option( 'csdt_devtools_csp_services', $backup['services'] ?? '[]' ); update_option( 'csdt_devtools_csp_custom', $backup['custom'] ?? '' ); delete_option( 'csdt_devtools_csp_backup' ); wp_send_json_success( [ 'enabled' => $backup['enabled'] ?? '0', 'mode' => $backup['mode'] ?? 'enforce', 'services' => json_decode( $backup['services'] ?? '[]', true ), 'custom' => $backup['custom'] ?? '', ] ); } public static function register_dashboard_widget(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } wp_add_dashboard_widget( 'csdt_security_summary', '🤖 CloudScale Cyber and Devtools', [ __CLASS__, 'render_dashboard_widget' ] ); } public static function render_dashboard_widget(): void { $ai_provider = get_option( 'csdt_devtools_ai_provider', 'anthropic' ); $anthropic_key = get_option( 'csdt_devtools_anthropic_key', '' ); $gemini_key = get_option( 'csdt_devtools_gemini_key', '' ); $has_key = $ai_provider === 'gemini' ? ! empty( $gemini_key ) : ! empty( $anthropic_key ); $provider_lbl = $ai_provider === 'gemini' ? 'Google Gemini' : 'Anthropic Claude'; $history = get_option( 'csdt_scan_history', [] ); $last_scan = ! empty( $history ) ? $history[0] : null; $score_cls = '#888'; if ( $last_scan ) { $s = (int) ( $last_scan['score'] ?? 0 ); $score_cls = $s >= 75 ? '#16a34a' : ( $s >= 55 ? '#d97706' : '#dc2626' ); } $bf_on = get_option( 'csdt_devtools_brute_force_enabled', '1' ) === '1'; $login_slug = get_option( 'csdt_devtools_login_slug', '' ); $force_2fa = get_option( 'csdt_devtools_force_2fa', '0' ) === '1'; $email_2fa = get_option( 'csdt_devtools_2fa_method', 'off' ) === 'email'; $admins = get_users( [ 'role' => 'administrator' ] ); $adm_tot = count( $admins ); $adm_2fa = 0; foreach ( $admins as $u ) { if ( get_user_meta( $u->ID, 'csdt_devtools_totp_enabled', true ) === '1' || ! empty( get_user_meta( $u->ID, 'csdt_devtools_passkeys', true ) ) || $email_2fa ) { $adm_2fa++; } } $base_url = admin_url( 'tools.php?page=cloudscale-devtools' ); ?>
🤖
🛡️
🔒
= 75 ) { $score_cls = 'cs-hv-green'; } elseif ( $s >= 55 ) { $score_cls = 'cs-hv-orange'; } else { $score_cls = 'cs-hv-red'; } } $fixes = self::get_quick_fixes(); $fixes_tot = count( $fixes ); $fixes_done = count( array_filter( $fixes, function ( $f ) { return ! empty( $f['fixed'] ); } ) ); $fixes_cls = $fixes_done === $fixes_tot ? 'cs-hv-green' : ( $fixes_done >= $fixes_tot - 1 ? 'cs-hv-orange' : 'cs-hv-red' ); $bf_on = get_option( 'csdt_devtools_brute_force_enabled', '1' ) === '1'; $admins = get_users( [ 'role' => 'administrator' ] ); $adm_tot = count( $admins ); $email_2fa = get_option( 'csdt_devtools_2fa_method', 'off' ) === 'email'; $adm_2fa = 0; foreach ( $admins as $u ) { if ( get_user_meta( $u->ID, 'csdt_devtools_totp_enabled', true ) === '1' || ! empty( get_user_meta( $u->ID, 'csdt_devtools_passkeys', true ) ) || $email_2fa ) { $adm_2fa++; } } $login_slug = get_option( 'csdt_devtools_login_slug', '' ); $sched_on = get_option( 'csdt_scan_schedule_enabled', '0' ) === '1'; $sched_freq = get_option( 'csdt_scan_schedule_freq', 'weekly' ); $base_url = admin_url( 'tools.php?page=cloudscale-devtools' ); ?>
🛡️ 'Quick Fixes', 'rec' => 'Critical', 'html' => 'Automated one-click remediations for common misconfigurations — moving debug.log outside the web root, disabling XML-RPC, hiding the WordPress version, and more. Each fix shows its current status so you can see what still needs attention at a glance.' ], [ 'name' => 'Standard Cyber Scan', 'rec' => 'Recommended', 'html' => 'A fast scan (a few seconds) that checks your WordPress core settings, active plugins and themes, user accounts, file permissions, and wp-config.php for common security misconfigurations. Results are sent to an AI model which prioritises findings and gives tailored remediation advice.' ], [ 'name' => 'Deep Dive Scan', 'rec' => 'Recommended', 'html' => 'A comprehensive scan that adds: static code analysis of plugin PHP files (looking for eval, shell functions, obfuscation, and suspicious patterns), external HTTP probes (open redirects, directory listing on /wp-content/plugins/ and /wp-content/themes/, weak TLS protocols, CORS headers), DNS checks (SPF, DMARC, DKIM), PHP end-of-life status, and an AI-powered code triage step that classifies each static finding as confirmed, false positive, or needs-context.' ], [ 'name' => 'Code Triage', 'rec' => 'Info', 'html' => 'After a deep scan, the top 10 highest-risk static findings are sent to an AI model with ±10 lines of surrounding code. The model classifies each as Confirmed (genuine risk), False Positive (safe code), or Needs Context (depends on usage). Only confirmed findings are forwarded to the main audit AI, reducing noise.' ], [ 'name' => 'Scan History', 'rec' => 'Info', 'html' => 'The last 10 scan results are saved automatically. Click any entry in the history table to reload that report instantly — useful for comparing your security posture over time or reviewing a scan after making changes.' ], [ 'name' => 'Scheduled Scans', 'rec' => 'Optional', 'html' => 'Run a deep scan automatically on a daily or weekly schedule. Results are stored in scan history. Enable email alerts to receive the AI summary in your inbox whenever a scheduled scan completes.' ], [ 'name' => 'AI Providers', 'rec' => 'Info', 'html' => '

Two AI providers are supported. You supply your own API key — keys are stored only in your WordPress database (wp_options) and sent only to the provider\'s own API endpoint.

Anthropic Claude — recommended for best results.
Get your key: console.anthropic.com/settings/keys
Models: claude-sonnet-4-6 (fast, cost-effective) · claude-opus-4-7 (most capable)
View latest Claude models →

Google Gemini — free tier available.
Get your key: aistudio.google.com/app/apikey
Models: gemini-2.0-flash (fast, free tier) · gemini-2.5-pro (most capable)
View latest Gemini models →

Deep Dive scans run two AI calls — Code Triage pre-classification uses the faster model first to reduce cost before the main audit call.

' ], ], 'The AI Cyber Audit uses frontier AI — Anthropic Claude or Google Gemini — to analyse your WordPress installation and produce a prioritised, scored security report in under 60 seconds. Think of it as a security consultant in your admin panel: it doesn\'t just list what\'s wrong, it tells you what to fix first and exactly how to fix it. A Standard scan takes seconds; a Deep Dive goes further with live HTTP probes, DNS checks, TLS quality analysis, and static code scanning of your plugins. You need an API key from one of the two providers — a free Gemini tier is available with no credit card required.' ); ?>

AI Cyber Audit connects to a frontier AI model — Anthropic Claude or Google Gemini — to analyse your WordPress installation and deliver a prioritised security report in under 60 seconds. It checks your core configuration, plugins, user accounts, file permissions, and key wp-config.php settings, then uses AI to score each finding as Critical / High / Medium / Low and give you specific steps to fix it. The Deep Dive extends this with live HTTP probes, DNS checks, TLS quality analysis, static PHP code scanning, and AI-powered triage of suspicious code patterns.', 'cloudscale-devtools' ), [ 'strong' => [] ] ); ?>

Run AI Cyber Audit. You can get a free Gemini key at aistudio.google.com with no credit card required.', 'cloudscale-devtools' ), [ 'strong' => [], 'a' => [ 'href' => [], 'target' => [], 'rel' => [] ] ] ); ?>

console.anthropic.com. Stored encrypted in wp_options.', 'cloudscale-devtools' ), [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [] ] ] ); ?>

>
ntfy.sh. Use a private topic or self-hosted server.', 'cloudscale-devtools' ), [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [] ] ] ); ?>

✓' : ''; ?>
Fixed ✓

📈

= 90 ? '#22c55e' : ( $score >= 75 ? '#4ade80' : ( $score >= 55 ? '#fbbf24' : ( $score >= 35 ? '#f97316' : '#ef4444' ) ) ); ?>
30 * DAY_IN_SECONDS, 'display' => __( 'Once Monthly', 'cloudscale-devtools' ), ]; $schedules['csdt_every_5min'] = [ 'interval' => 5 * MINUTE_IN_SECONDS, 'display' => __( 'Every 5 Minutes', 'cloudscale-devtools' ), ]; return $schedules; } public static function ajax_save_schedule(): void { check_ajax_referer( 'csdt_devtools_security_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $enabled = ! empty( $_POST['enabled'] ) && $_POST['enabled'] === '1'; $freq = in_array( $_POST['freq'] ?? '', [ 'weekly', 'monthly' ], true ) ? sanitize_key( $_POST['freq'] ) : 'weekly'; $type = in_array( $_POST['type'] ?? '', [ 'standard', 'deep' ], true ) ? sanitize_key( $_POST['type'] ) : 'deep'; $email = ! empty( $_POST['email_notify'] ) && $_POST['email_notify'] === '1'; $ntfy_url = esc_url_raw( wp_unslash( $_POST['ntfy_url'] ?? '' ) ); $ntfy_tok = sanitize_text_field( wp_unslash( $_POST['ntfy_token'] ?? '' ) ); $ntfy_tok = trim( str_replace( '•', '', $ntfy_tok ) ); update_option( 'csdt_scan_schedule_enabled', $enabled ? '1' : '0', false ); update_option( 'csdt_scan_schedule_freq', $freq, false ); update_option( 'csdt_scan_schedule_type', $type, false ); update_option( 'csdt_scan_schedule_email', $email ? '1' : '0', false ); update_option( 'csdt_scan_schedule_ntfy_url', $ntfy_url, false ); if ( $ntfy_tok !== '' ) { update_option( 'csdt_scan_schedule_ntfy_token', $ntfy_tok, false ); } // Re-register cron event wp_clear_scheduled_hook( 'csdt_scheduled_scan' ); $next_run = null; if ( $enabled ) { $recurrence = $freq === 'monthly' ? 'csdt_monthly' : 'weekly'; wp_schedule_event( time() + HOUR_IN_SECONDS, $recurrence, 'csdt_scheduled_scan' ); $next_run = wp_next_scheduled( 'csdt_scheduled_scan' ); } wp_send_json_success( [ 'saved' => true, 'next_run' => $next_run ? wp_date( 'D j M Y, g:ia', $next_run ) : null, ] ); } public static function run_scheduled_scan(): void { $type = get_option( 'csdt_scan_schedule_type', 'deep' ); if ( $type === 'deep' ) { self::cron_deep_scan(); } else { self::cron_vuln_scan(); } // Fetch the freshly stored result to notify $result = $type === 'deep' ? get_option( 'csdt_deep_scan_v1' ) : get_option( 'csdt_security_scan_v2' ); if ( $result && isset( $result['report'] ) ) { self::send_scan_notifications( $result['report'], $type ); } } private static function send_scan_notifications( array $report, string $type ): void { $score = $report['score'] ?? '?'; $label = $report['score_label'] ?? ''; $summary = $report['summary'] ?? ''; $critical = count( $report['critical'] ?? [] ); $high = count( $report['high'] ?? [] ); $type_label = $type === 'deep' ? 'AI Deep Dive Cyber Audit' : 'AI Cyber Audit'; $site = get_bloginfo( 'name' ) ?: home_url(); $admin_url = admin_url( 'tools.php?page=' . self::TOOLS_SLUG . '&tab=security' ); $subject = sprintf( '[%s] Security Scan Complete — Score: %s/100 (%s)', $site, $score, $label ); $body = sprintf( "%s completed for %s\n\nScore: %s/100 (%s)\nCritical: %d | High: %d\n\n%s\n\nView full report: %s", $type_label, $site, $score, $label, $critical, $high, $summary, $admin_url ); // Email notification if ( get_option( 'csdt_scan_schedule_email', '1' ) === '1' ) { wp_mail( get_option( 'admin_email' ), $subject, $body ); } // ntfy.sh push notification $ntfy_url = get_option( 'csdt_scan_schedule_ntfy_url', '' ); if ( $ntfy_url ) { $priority = $critical > 0 ? 'urgent' : ( $high > 0 ? 'high' : 'default' ); $headers = [ 'Title' => $subject, 'Priority' => $priority, 'Tags' => $score >= 75 ? 'white_check_mark' : ( $score >= 55 ? 'warning' : 'rotating_light' ), 'Click' => $admin_url, ]; $ntfy_tok = get_option( 'csdt_scan_schedule_ntfy_token', '' ); if ( $ntfy_tok ) { $headers['Authorization'] = 'Bearer ' . $ntfy_tok; } wp_remote_post( $ntfy_url, [ 'timeout' => 10, 'headers' => $headers, 'body' => $body, ] ); } } public static function ajax_apply_quick_fix(): void { check_ajax_referer( 'csdt_devtools_security_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $action = isset( $_POST['fix_action'] ) ? sanitize_key( wp_unslash( $_POST['fix_action'] ) ) : ''; $fix_id = isset( $_POST['fix_id'] ) ? sanitize_key( wp_unslash( $_POST['fix_id'] ) ) : ''; if ( $action === 'list' ) { wp_send_json_success( [ 'fixes' => self::get_quick_fixes() ] ); return; } if ( $action !== 'apply' ) { wp_send_json_error( 'Invalid action' ); return; } switch ( $fix_id ) { case 'security_headers': update_option( 'csdt_devtools_safe_headers_enabled', '1' ); break; case 'disable_pingbacks': update_option( 'default_ping_status', 'closed' ); update_option( 'default_pingback_flag', 0 ); break; case 'close_registration': update_option( 'users_can_register', 0 ); break; case 'disable_app_passwords': update_option( 'csdt_devtools_disable_app_passwords', '1' ); break; case 'hide_wp_version': update_option( 'csdt_devtools_hide_wp_version', '1' ); break; case 'close_comments': update_option( 'default_comment_status', 'closed' ); break; case 'wpconfig_perms': $cfg_file = ABSPATH . 'wp-config.php'; if ( ! file_exists( $cfg_file ) || ! is_writable( dirname( $cfg_file ) ) ) { wp_send_json_error( 'wp-config.php not found or directory not writable.' ); return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod if ( ! chmod( $cfg_file, 0600 ) ) { wp_send_json_error( 'chmod failed — server may restrict permission changes.' ); return; } break; case 'block_debug_log': $old_log = WP_CONTENT_DIR . '/debug.log'; $new_log = rtrim( dirname( rtrim( ABSPATH, '/\\' ) ), '/\\' ) . '/wordpress-debug.log'; // 1. Migrate existing content and delete from web root. if ( file_exists( $old_log ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $existing = file_get_contents( $old_log ); if ( $existing !== false && $existing !== '' ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $new_log, $existing, FILE_APPEND ); } wp_delete_file( $old_log ); } // 2. Rewrite WP_DEBUG_LOG in wp-config.php so WordPress writes to the safe // path from the very first line of execution — before any mu-plugin runs. $cfg_file = ABSPATH . 'wp-config.php'; $cfg_updated = false; if ( is_readable( $cfg_file ) && is_writable( $cfg_file ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $cfg = file_get_contents( $cfg_file ); $safe_path = str_replace( "'", "\\'", $new_log ); $new_define = "define( 'WP_DEBUG_LOG', '" . $safe_path . "' );"; $pattern = "/define\s*\(\s*['\"]WP_DEBUG_LOG['\"]\s*,\s*(?:true|false|'[^']*'|\"[^\"]*\")\s*\)\s*;/i"; if ( preg_match( $pattern, $cfg ) ) { $cfg = preg_replace( $pattern, $new_define, $cfg ); } else { // No existing define — insert before the "stop editing" marker. $cfg = preg_replace( '/\/\*\s*That\'s all[^*]*\*\//is', $new_define . "\n\n/* That's all, stop editing! Happy publishing. */", $cfg, 1 ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents if ( $cfg && file_put_contents( $cfg_file, $cfg ) !== false ) { $cfg_updated = true; } } // 3. Store new path and write mu-plugin as belt-and-suspenders fallback. update_option( 'csdt_debug_log_path', $new_log, false ); $mu_dir = WP_CONTENT_DIR . '/mu-plugins'; if ( ! is_dir( $mu_dir ) ) { wp_mkdir_p( $mu_dir ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $mu_dir . '/csdt-secure-logs.php', ' self::get_quick_fixes(), 'warning' => 'debug.log deleted and mu-plugin installed, but wp-config.php is not writable — WP_DEBUG_LOG still points to the old path. The file may reappear on the next PHP error. To make this permanent, set WP_DEBUG_LOG to \'' . $new_log . '\' in wp-config.php manually.', ] ); return; } break; default: wp_send_json_error( 'Unknown fix ID' ); return; } wp_send_json_success( [ 'fixes' => self::get_quick_fixes() ] ); } public static function ajax_vuln_save_key(): void { check_ajax_referer( 'csdt_devtools_security_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $provider = isset( $_POST['provider'] ) ? sanitize_key( wp_unslash( $_POST['provider'] ) ) : 'anthropic'; $raw_key = isset( $_POST['api_key'] ) ? sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) : ''; $raw_gemini = isset( $_POST['gemini_key'] ) ? sanitize_text_field( wp_unslash( $_POST['gemini_key'] ) ) : ''; $clean_key = trim( str_replace( '•', '', $raw_key ) ); $clean_gemini = trim( str_replace( '•', '', $raw_gemini ) ); $model = isset( $_POST['model'] ) ? sanitize_text_field( wp_unslash( $_POST['model'] ) ) : '_auto'; $deep_model = isset( $_POST['deep_model'] ) ? sanitize_text_field( wp_unslash( $_POST['deep_model'] ) ) : '_auto_deep'; $prompt = isset( $_POST['prompt'] ) ? sanitize_textarea_field( wp_unslash( $_POST['prompt'] ) ) : ''; update_option( 'csdt_devtools_ai_provider', $provider, false ); if ( $clean_key !== '' ) { update_option( 'csdt_devtools_anthropic_key', $clean_key, false ); } if ( $clean_gemini !== '' ) { update_option( 'csdt_devtools_gemini_key', $clean_gemini, false ); } update_option( 'csdt_devtools_security_model', $model, false ); update_option( 'csdt_devtools_deep_scan_model', $deep_model, false ); update_option( 'csdt_devtools_security_prompt', $prompt, false ); delete_option( 'csdt_security_scan_v2' ); delete_option( 'csdt_deep_scan_v1' ); $saved_ant = get_option( 'csdt_devtools_anthropic_key', '' ); $saved_gem = get_option( 'csdt_devtools_gemini_key', '' ); $has_key = $provider === 'gemini' ? ! empty( $saved_gem ) : ! empty( $saved_ant ); wp_send_json_success( [ 'saved' => true, 'has_key' => $has_key, 'masked' => $saved_ant ? '••••••••' . substr( $saved_ant, -4 ) : '', 'maskedGemini' => $saved_gem ? '••••••••' . substr( $saved_gem, -4 ) : '', ] ); } public static function ajax_security_test_key(): void { check_ajax_referer( 'csdt_devtools_security_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $provider = isset( $_POST['provider'] ) ? sanitize_key( wp_unslash( $_POST['provider'] ) ) : 'anthropic'; $raw_key = isset( $_POST['api_key'] ) ? sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) : ''; $key = trim( str_replace( '•', '', $raw_key ) ); if ( $provider === 'gemini' ) { if ( ! $key ) { $key = get_option( 'csdt_devtools_gemini_key', '' ); } if ( ! $key ) { wp_send_json_error( [ 'message' => 'No Gemini API key provided.' ] ); return; } $url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' . rawurlencode( $key ); $resp = wp_remote_post( $url, [ 'timeout' => 15, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'contents' => [ [ 'role' => 'user', 'parts' => [ [ 'text' => 'Hi' ] ] ] ] ] ), ] ); } else { if ( ! $key ) { $key = get_option( 'csdt_devtools_anthropic_key', '' ); } if ( ! $key ) { wp_send_json_error( [ 'message' => 'No API key provided.' ] ); return; } $resp = wp_remote_post( 'https://api.anthropic.com/v1/messages', [ 'timeout' => 15, 'headers' => [ 'x-api-key' => $key, 'anthropic-version' => '2023-06-01', 'content-type' => 'application/json', ], 'body' => wp_json_encode( [ 'model' => 'claude-haiku-4-5-20251001', 'max_tokens' => 10, 'messages' => [ [ 'role' => 'user', 'content' => 'Hi' ] ], ] ), ] ); } if ( is_wp_error( $resp ) ) { wp_send_json_error( [ 'message' => 'Connection error: ' . $resp->get_error_message() ] ); return; } $code = wp_remote_retrieve_response_code( $resp ); if ( $code === 200 ) { wp_send_json_success( [ 'valid' => true, 'message' => '✓ API key is valid' ] ); } else { $body = json_decode( wp_remote_retrieve_body( $resp ), true ); $err = $body['error']['message'] ?? $body['error']['status'] ?? "HTTP {$code}"; wp_send_json_error( [ 'valid' => false, 'message' => $err ] ); } } // ── Background execution helper ────────────────────────────────── private static function send_json_and_continue( array $data ): void { if ( function_exists( 'set_time_limit' ) ) { set_time_limit( 0 ); } // Discard any output buffers so headers can be sent cleanly while ( ob_get_level() ) { ob_end_clean(); } header( 'Content-Type: application/json; charset=utf-8' ); header( 'Connection: close' ); $body = wp_json_encode( [ 'success' => true, 'data' => $data ] ); header( 'Content-Length: ' . strlen( $body ) ); echo $body; flush(); // On PHP-FPM: close the HTTP connection but keep the process running if ( function_exists( 'fastcgi_finish_request' ) ) { fastcgi_finish_request(); } } // ── Parallel AI calls via curl_multi ───────────────────────────── private static function build_ai_curl_handle( string $provider, string $system, string $user_message, string $model, int $max_tokens ): \CurlHandle { if ( $provider === 'gemini' ) { $key = get_option( 'csdt_devtools_gemini_key', '' ); if ( ! $key ) { throw new \RuntimeException( 'No Gemini API key configured.' ); } if ( $model === '_auto' || $model === '_auto_deep' ) { $model = 'gemini-2.0-flash'; } $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode( $model ) . ':generateContent?key=' . rawurlencode( $key ); $body = wp_json_encode( [ 'systemInstruction' => [ 'parts' => [ [ 'text' => $system ] ] ], 'contents' => [ [ 'role' => 'user', 'parts' => [ [ 'text' => $user_message ] ] ] ], 'generationConfig' => [ 'maxOutputTokens' => $max_tokens ], ] ); $headers = [ 'Content-Type: application/json' ]; } else { $key = get_option( 'csdt_devtools_anthropic_key', '' ); if ( ! $key ) { throw new \RuntimeException( 'No Anthropic API key configured.' ); } if ( $model === '_auto' ) { $model = 'claude-sonnet-4-6'; } if ( $model === '_auto_deep' ) { $model = 'claude-opus-4-7'; } $url = 'https://api.anthropic.com/v1/messages'; $body = wp_json_encode( [ 'model' => $model, 'max_tokens' => $max_tokens, 'system' => $system, 'messages' => [ [ 'role' => 'user', 'content' => $user_message ] ], ] ); $headers = [ 'x-api-key: ' . $key, 'anthropic-version: 2023-06-01', 'content-type: application/json', ]; } $ch = curl_init(); curl_setopt_array( $ch, [ CURLOPT_URL => $url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 180, CURLOPT_SSL_VERIFYPEER => true, ] ); return $ch; } private static function parse_ai_curl_text( string $provider, string $body ): string { $data = json_decode( $body, true ); if ( ! $data ) { throw new \RuntimeException( 'Empty or invalid API response.' ); } if ( $provider === 'gemini' ) { if ( isset( $data['error'] ) ) { throw new \RuntimeException( $data['error']['message'] ?? 'Gemini API error.' ); } return trim( $data['candidates'][0]['content']['parts'][0]['text'] ?? '' ); } if ( isset( $data['error'] ) ) { throw new \RuntimeException( $data['error']['message'] ?? 'Anthropic API error.' ); } return trim( $data['content'][0]['text'] ?? '' ); } private static function dispatch_parallel_ai_calls( array $calls ): array { $provider = get_option( 'csdt_devtools_ai_provider', 'anthropic' ); $mh = curl_multi_init(); $handles = []; foreach ( $calls as $i => $call ) { $ch = self::build_ai_curl_handle( $provider, $call['system'], $call['user'], $call['model'], $call['max_tokens'] ); $handles[$i] = $ch; curl_multi_add_handle( $mh, $ch ); } $running = null; do { curl_multi_exec( $mh, $running ); if ( $running ) { curl_multi_select( $mh, 1.0 ); } } while ( $running > 0 ); $texts = []; foreach ( $handles as $i => $ch ) { $texts[$i] = self::parse_ai_curl_text( $provider, (string) curl_multi_getcontent( $ch ) ); curl_multi_remove_handle( $mh, $ch ); curl_close( $ch ); } curl_multi_close( $mh ); return $texts; } private static function parse_ai_json( string $text ): array { $text = preg_replace( '/^```(?:json)?\s*/i', '', trim( $text ) ); $text = preg_replace( '/\s*```$/', '', $text ); $report = json_decode( $text, true ); if ( ! $report || ! isset( $report['score'] ) ) { throw new \RuntimeException( 'AI returned unexpected format.' ); } return $report; } private static function merge_reports( array $a, array $b ): array { $score = (int) round( $a['score'] * 0.45 + $b['score'] * 0.55 ); $label = $score >= 90 ? 'Excellent' : ( $score >= 75 ? 'Good' : ( $score >= 55 ? 'Fair' : ( $score >= 35 ? 'Poor' : 'Critical' ) ) ); $sum_a = rtrim( $a['summary'] ?? '', '. ' ); $sum_b = ltrim( $b['summary'] ?? '' ); return [ 'score' => $score, 'score_label' => $label, 'summary' => $sum_a . '. ' . $sum_b, 'critical' => array_merge( $a['critical'] ?? [], $b['critical'] ?? [] ), 'high' => array_merge( $a['high'] ?? [], $b['high'] ?? [] ), 'medium' => array_merge( $a['medium'] ?? [], $b['medium'] ?? [] ), 'low' => array_merge( $a['low'] ?? [], $b['low'] ?? [] ), 'good' => array_merge( $a['good'] ?? [], $b['good'] ?? [] ), ]; } private static function default_internal_scan_prompt(): string { return <<<'PROMPT' You are a WordPress security expert. Analyse the provided internal WordPress configuration data only. Focus on: WordPress/PHP version currency, WP_DEBUG/WP_DEBUG_DISPLAY flags (exposed to public = critical), DISALLOW_FILE_EDIT/MODS, database prefix (wp_ default is a risk), user accounts (admin username exists, counts), active plugin list (outdated plugins), brute force protection, 2FA configuration (email/TOTP/passkey counts per admin), login URL obfuscation, wp-config.php file permissions, open user registration, pingbacks enabled (DDoS amplification), WordPress version in meta generator tag, default comment status. Return ONLY a JSON object (no markdown, no code fences) with this exact schema: {"score":0-100,"score_label":"Excellent|Good|Fair|Poor|Critical","summary":"1-2 sentences on internal config security posture","critical":[{"title":"...","detail":"...","fix":"..."}],"high":[...],"medium":[...],"low":[...],"good":[{"title":"...","detail":"..."}]} Score the internal configuration on a 0-100 scale. Be strict. Include good practices for hardened settings. PROMPT; } private static function default_external_scan_prompt(): string { return <<<'PROMPT' You are a penetration tester. Analyse the provided external exposure checks and plugin code scan data only. For external checks assess: HTTP security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy), exposed endpoints (wp-login.php, xmlrpc.php, wp-cron.php, REST API user enumeration, author enumeration /?author=1, directory listing), SSL certificate validity and days to expiry, HTTP→HTTPS redirect enforcement, exposed sensitive files (debug.log, .env, wp-config.php.bak, .git/config, readme.html, phpinfo.php, error_log, composer.json, backup archives), database admin tools accessible (adminer, phpMyAdmin), server-status/server-info pages, and email DNS security (SPF and DMARC records present). For plugin code scan (plugin_code_scan): list detected patterns as context only — raw static analysis that may include false positives. For code_triage: AI-verified verdicts on the static findings. Each entry has verdict (confirmed|false_positive|needs_context), severity, type, explanation, and fix. Only raise confirmed findings as real issues — do not report false_positive items as vulnerabilities. Use the severity from code_triage for confirmed items. For needs_context items, mention them at low severity. Name plugin, file, and line number in every code finding. Return ONLY a JSON object (no markdown, no code fences) with this exact schema: {"score":0-100,"score_label":"Excellent|Good|Fair|Poor|Critical","summary":"1-2 sentences on external exposure and code scan posture","critical":[{"title":"...","detail":"...","fix":"..."}],"high":[...],"medium":[...],"low":[...],"good":[{"title":"...","detail":"..."}]} Score external exposure on a 0-100 scale. Prioritise externally reachable issues at critical/high. Include good practices for blocked endpoints and hardened headers. PROMPT; } // ── Cancel scan ─────────────────────────────────────────────────── public static function ajax_cancel_scan(): void { check_ajax_referer( 'csdt_devtools_security_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $type = isset( $_POST['type'] ) ? sanitize_key( $_POST['type'] ) : 'deep'; if ( $type === 'deep' ) { set_transient( 'csdt_deep_scan_cancelled', '1', 300 ); delete_transient( 'csdt_deep_scan_status' ); } else { set_transient( 'csdt_vuln_scan_cancelled', '1', 300 ); delete_transient( 'csdt_vuln_scan_status' ); } wp_send_json_success( [ 'cancelled' => true ] ); } // ── AI dispatcher — Anthropic or Gemini ────────────────────────── private static function dispatch_ai_call( string $system, string $user_message, string $model, int $max_tokens ): string { $provider = get_option( 'csdt_devtools_ai_provider', 'anthropic' ); if ( $provider === 'gemini' ) { $key = get_option( 'csdt_devtools_gemini_key', '' ); if ( ! $key ) { throw new \RuntimeException( 'No Gemini API key configured.' ); } if ( $model === '_auto' || $model === '_auto_deep' ) { $model = 'gemini-2.0-flash'; } $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode( $model ) . ':generateContent?key=' . rawurlencode( $key ); $resp = wp_remote_post( $url, [ 'timeout' => 180, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'systemInstruction' => [ 'parts' => [ [ 'text' => $system ] ] ], 'contents' => [ [ 'role' => 'user', 'parts' => [ [ 'text' => $user_message ] ] ] ], 'generationConfig' => [ 'maxOutputTokens' => $max_tokens ], ] ), ] ); if ( is_wp_error( $resp ) ) { throw new \RuntimeException( $resp->get_error_message() ); } $code = wp_remote_retrieve_response_code( $resp ); $body = json_decode( wp_remote_retrieve_body( $resp ), true ); if ( $code !== 200 ) { $err = $body['error']['message'] ?? "HTTP {$code}"; throw new \RuntimeException( $err ); } return trim( $body['candidates'][0]['content']['parts'][0]['text'] ?? '' ); } else { $key = get_option( 'csdt_devtools_anthropic_key', '' ); if ( ! $key ) { throw new \RuntimeException( 'No Anthropic API key configured.' ); } if ( $model === '_auto' ) { $model = 'claude-sonnet-4-6'; } if ( $model === '_auto_deep' ) { $model = 'claude-opus-4-7'; } $resp = wp_remote_post( 'https://api.anthropic.com/v1/messages', [ 'timeout' => 180, 'headers' => [ 'x-api-key' => $key, 'anthropic-version' => '2023-06-01', 'content-type' => 'application/json', ], 'body' => wp_json_encode( [ 'model' => $model, 'max_tokens' => $max_tokens, 'system' => $system, 'messages' => [ [ 'role' => 'user', 'content' => $user_message ] ], ] ), ] ); if ( is_wp_error( $resp ) ) { throw new \RuntimeException( $resp->get_error_message() ); } $code = wp_remote_retrieve_response_code( $resp ); $raw = wp_remote_retrieve_body( $resp ); $api = json_decode( $raw, true ); if ( $code !== 200 ) { $err = $api['error']['message'] ?? "HTTP {$code}"; throw new \RuntimeException( $err ); } return trim( $api['content'][0]['text'] ?? '' ); } } public static function ajax_vuln_scan(): void { check_ajax_referer( 'csdt_devtools_security_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $cache_only = ! empty( $_POST['cache_only'] ); // Page-load pre-fill: return cache silently or signal nothing cached if ( $cache_only ) { $cached = get_option( 'csdt_security_scan_v2' ); if ( $cached !== false ) { wp_send_json_success( array_merge( $cached, [ 'from_cache' => true ] ) ); } else { wp_send_json_success( [ 'no_cache' => true ] ); } return; } $provider = get_option( 'csdt_devtools_ai_provider', 'anthropic' ); $has_key = $provider === 'gemini' ? ! empty( get_option( 'csdt_devtools_gemini_key', '' ) ) : ! empty( get_option( 'csdt_devtools_anthropic_key', '' ) ); if ( ! $has_key ) { wp_send_json_error( [ 'message' => 'No API key configured.', 'need_key' => true ] ); return; } // Clear previous result and mark as running delete_option( 'csdt_security_scan_v2' ); set_transient( 'csdt_vuln_scan_status', [ 'status' => 'running', 'started_at' => time() ], 600 ); // Send response immediately, then run scan after connection closes self::send_json_and_continue( [ 'queued' => true ] ); self::cron_vuln_scan(); exit; } public static function cron_vuln_scan(): void { if ( function_exists( 'set_time_limit' ) ) { set_time_limit( 0 ); } if ( get_transient( 'csdt_vuln_scan_cancelled' ) ) { delete_transient( 'csdt_vuln_scan_cancelled' ); return; } try { $model = get_option( 'csdt_devtools_security_model', '_auto' ); $system_prompt = get_option( 'csdt_devtools_security_prompt', '' ) ?: self::default_security_prompt(); $user_message = 'WordPress site security data (JSON):' . "\n\n" . wp_json_encode( self::gather_security_data(), JSON_PRETTY_PRINT ); error_log( '[CSDT-SCAN] cron running, model=' . $model ); $text = self::dispatch_ai_call( $system_prompt, $user_message, $model, 4096 ); } catch ( \Throwable $e ) { set_transient( 'csdt_vuln_scan_status', [ 'status' => 'error', 'message' => $e->getMessage() ], 300 ); return; } $text = preg_replace( '/^```(?:json)?\s*/i', '', trim( $text ) ); $text = preg_replace( '/\s*```$/', '', $text ); $report = json_decode( $text, true ); if ( ! $report || ! isset( $report['score'] ) ) { set_transient( 'csdt_vuln_scan_status', [ 'status' => 'error', 'message' => 'AI returned unexpected format.' ], 300 ); return; } $output = [ 'report' => $report, 'model_used' => get_option( 'csdt_devtools_ai_provider', 'anthropic' ) . '/' . $model, 'scanned_at' => time(), 'from_cache' => false, ]; update_option( 'csdt_security_scan_v2', $output, false ); set_transient( 'csdt_vuln_scan_status', [ 'status' => 'complete', 'completed_at' => time() ], 600 ); self::append_scan_history( 'standard', $report, $output['model_used'], $output['scanned_at'] ); error_log( '[CSDT-SCAN] cron complete, score=' . $report['score'] ); } /* ================================================================== Deep Scan — internal config + external exposure checks ================================================================== */ private static function check_ssl_certificate( string $host ): array { if ( empty( $host ) ) { return [ 'available' => false, 'error' => 'No host' ]; } $ctx = stream_context_create( [ 'ssl' => [ 'capture_peer_cert' => true, 'verify_peer' => false, 'verify_peer_name' => false, ], ] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $stream = @stream_socket_client( 'ssl://' . $host . ':443', $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $ctx ); if ( ! $stream ) { return [ 'available' => false, 'error' => $errstr ?: "errno $errno" ]; } $params = stream_context_get_params( $stream ); fclose( $stream ); $cert_res = $params['options']['ssl']['peer_certificate'] ?? null; if ( ! $cert_res ) { return [ 'available' => false, 'error' => 'No peer cert captured' ]; } $cert = openssl_x509_parse( $cert_res ); if ( ! $cert ) { return [ 'available' => false, 'error' => 'openssl_x509_parse failed' ]; } $valid_to = $cert['validTo_time_t'] ?? 0; $valid_from= $cert['validFrom_time_t'] ?? 0; $now = time(); $days_left = $valid_to ? (int) floor( ( $valid_to - $now ) / DAY_IN_SECONDS ) : null; return [ 'available' => true, 'subject_cn' => $cert['subject']['CN'] ?? '', 'issuer' => $cert['issuer']['CN'] ?? ( $cert['issuer']['O'] ?? '' ), 'valid_from' => $valid_from ? gmdate( 'Y-m-d', $valid_from ) : null, 'valid_to' => $valid_to ? gmdate( 'Y-m-d', $valid_to ) : null, 'days_left' => $days_left, 'expired' => $days_left !== null && $days_left < 0, 'expiring_soon' => $days_left !== null && $days_left >= 0 && $days_left < 30, 'san' => $cert['extensions']['subjectAltName'] ?? null, ]; } private static function check_email_dns( string $host ): array { // Check MX records first — if none exist, email is not configured for this domain // and missing SPF/DMARC/DKIM is not a finding (there's nothing to protect). $mx_records = @dns_get_record( $host, DNS_MX ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $has_mx = is_array( $mx_records ) && ! empty( $mx_records ); if ( ! $has_mx ) { return [ 'mx_present' => false, 'spf_present' => false, 'spf_record' => null, 'spf_strictness' => 'not_applicable', 'dmarc_present' => false, 'dmarc_record' => null, 'dmarc_policy' => 'not_applicable', 'dmarc_pct' => null, 'dkim_present' => false, 'dkim_selector' => null, ]; } $spf_found = false; $dmarc_found = false; $spf_record = null; $dmarc_record= null; // SPF — TXT record on the apex domain $txt = @dns_get_record( $host, DNS_TXT ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( is_array( $txt ) ) { foreach ( $txt as $r ) { if ( isset( $r['txt'] ) && stripos( $r['txt'], 'v=spf1' ) === 0 ) { $spf_found = true; $spf_record = $r['txt']; break; } } } // DMARC — TXT record on _dmarc.domain $dmarc_txt = @dns_get_record( '_dmarc.' . $host, DNS_TXT ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( is_array( $dmarc_txt ) ) { foreach ( $dmarc_txt as $r ) { if ( isset( $r['txt'] ) && stripos( $r['txt'], 'v=DMARC1' ) === 0 ) { $dmarc_found = true; $dmarc_record = $r['txt']; break; } } } // DKIM — probe common selectors used by major ESPs $dkim_found = false; $dkim_selector = null; foreach ( [ 'google', 'default', 'mail', 'dkim', 'k1', 'selector1', 'selector2', 'mandrill', 'mailjet', 'sendgrid', 'amazonses', 'smtp' ] as $sel ) { $dkim_txt = @dns_get_record( $sel . '._domainkey.' . $host, DNS_TXT ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( is_array( $dkim_txt ) ) { foreach ( $dkim_txt as $r ) { if ( isset( $r['txt'] ) && stripos( $r['txt'], 'v=DKIM1' ) !== false ) { $dkim_found = true; $dkim_selector = $sel; break 2; } } } } // SPF strictness — ~all (soft fail) still lets spoofed mail through $spf_strictness = 'missing'; if ( $spf_found && $spf_record ) { if ( strpos( $spf_record, '+all' ) !== false ) { $spf_strictness = 'pass_all'; } elseif ( strpos( $spf_record, '-all' ) !== false ) { $spf_strictness = 'hard_fail'; } elseif ( strpos( $spf_record, '~all' ) !== false ) { $spf_strictness = 'soft_fail'; } elseif ( strpos( $spf_record, '?all' ) !== false ) { $spf_strictness = 'neutral'; } else { $spf_strictness = 'unknown'; } } // DMARC policy — p=none does nothing (monitoring only) $dmarc_policy = 'missing'; $dmarc_pct = 100; if ( $dmarc_found && $dmarc_record ) { if ( preg_match( '/\bp=([^;\s]+)/i', $dmarc_record, $pm ) ) { $dmarc_policy = strtolower( trim( $pm[1] ) ); } if ( preg_match( '/\bpct=(\d+)/i', $dmarc_record, $pm ) ) { $dmarc_pct = (int) $pm[1]; } } return [ 'mx_present' => true, 'spf_present' => $spf_found, 'spf_record' => $spf_record, 'spf_strictness' => $spf_strictness, 'dmarc_present' => $dmarc_found, 'dmarc_record' => $dmarc_record, 'dmarc_policy' => $dmarc_policy, 'dmarc_pct' => $dmarc_pct, 'dkim_present' => $dkim_found, 'dkim_selector' => $dkim_selector, ]; } private static function gather_external_checks(): array { $base = home_url( '/' ); $host = (string) wp_parse_url( $base, PHP_URL_HOST ); $ext = []; // SSL certificate $ext['ssl'] = self::check_ssl_certificate( $host ); // Helper: head request, returns [code, error] $head = function ( string $url ): array { $r = wp_remote_head( $url, [ 'timeout' => 4, 'sslverify' => false, 'redirection' => 0 ] ); return is_wp_error( $r ) ? [ 'code' => 'error', 'error' => $r->get_error_message() ] : [ 'code' => wp_remote_retrieve_response_code( $r ), 'location' => wp_remote_retrieve_header( $r, 'location' ) ]; }; // wp-login.php exposure $login_r = $head( $base . 'wp-login.php' ); $ext['wp_login'] = [ 'code' => $login_r['code'], 'accessible' => isset( $login_r['code'] ) && is_int( $login_r['code'] ) && $login_r['code'] < 400, ]; // xmlrpc.php $xmlrpc_r = $head( $base . 'xmlrpc.php' ); $ext['xmlrpc'] = [ 'code' => $xmlrpc_r['code'], 'accessible' => isset( $xmlrpc_r['code'] ) && is_int( $xmlrpc_r['code'] ) && $xmlrpc_r['code'] < 400, ]; // REST API user enumeration $rest_r = wp_remote_get( $base . 'wp-json/wp/v2/users', [ 'timeout' => 5, 'sslverify' => false ] ); $ext['rest_users'] = [ 'exposed' => false, 'count' => 0, 'slugs' => [] ]; if ( ! is_wp_error( $rest_r ) && wp_remote_retrieve_response_code( $rest_r ) === 200 ) { $users = json_decode( wp_remote_retrieve_body( $rest_r ), true ); if ( is_array( $users ) && ! empty( $users ) ) { $ext['rest_users']['exposed'] = true; $ext['rest_users']['count'] = count( $users ); $ext['rest_users']['slugs'] = array_values( array_slice( array_column( $users, 'slug' ), 0, 5 ) ); } } // Author enumeration /?author=1 $author_r = wp_remote_head( $base . '?author=1', [ 'timeout' => 4, 'sslverify' => false, 'redirection' => 0 ] ); $ext['author_enum'] = [ 'exposed' => false ]; if ( ! is_wp_error( $author_r ) ) { $code = wp_remote_retrieve_response_code( $author_r ); $loc = wp_remote_retrieve_header( $author_r, 'location' ); if ( $code >= 300 && $code < 400 && $loc && strpos( $loc, '/author/' ) !== false ) { $ext['author_enum'] = [ 'exposed' => true, 'redirects_to' => $loc ]; } } // Uploads directory listing $uploads_r = wp_remote_get( $base . 'wp-content/uploads/', [ 'timeout' => 4, 'sslverify' => false ] ); $uploads_body = is_wp_error( $uploads_r ) ? '' : wp_remote_retrieve_body( $uploads_r ); $ext['uploads_listing'] = ( ! is_wp_error( $uploads_r ) && wp_remote_retrieve_response_code( $uploads_r ) === 200 && ( stripos( $uploads_body, 'Index of' ) !== false || stripos( $uploads_body, 'Parent Directory' ) !== false ) ); // Plugins and themes directory listing (reveals installed software to targeted attackers) $plugins_r = wp_remote_get( $base . 'wp-content/plugins/', [ 'timeout' => 4, 'sslverify' => false ] ); $plugins_body = is_wp_error( $plugins_r ) ? '' : wp_remote_retrieve_body( $plugins_r ); $ext['plugins_listing'] = ( ! is_wp_error( $plugins_r ) && wp_remote_retrieve_response_code( $plugins_r ) === 200 && ( stripos( $plugins_body, 'Index of' ) !== false || stripos( $plugins_body, 'Parent Directory' ) !== false ) ); $themes_r = wp_remote_get( $base . 'wp-content/themes/', [ 'timeout' => 4, 'sslverify' => false ] ); $themes_body = is_wp_error( $themes_r ) ? '' : wp_remote_retrieve_body( $themes_r ); $ext['themes_listing'] = ( ! is_wp_error( $themes_r ) && wp_remote_retrieve_response_code( $themes_r ) === 200 && ( stripos( $themes_body, 'Index of' ) !== false || stripos( $themes_body, 'Parent Directory' ) !== false ) ); // Exposed sensitive files $ext['exposed_files'] = []; foreach ( [ 'readme.html', 'license.txt', 'phpinfo.php', 'wp-config.php.bak', '.env', '.htaccess', '.git/config', 'error_log', 'composer.json', 'package.json' ] as $f ) { $r = $head( $base . $f ); if ( isset( $r['code'] ) && is_int( $r['code'] ) && $r['code'] === 200 ) { $ext['exposed_files'][] = $f; } } // wp-cron.php publicly accessible (DDoS / resource-abuse vector) $cron_r = $head( $base . 'wp-cron.php' ); $ext['wp_cron_public'] = isset( $cron_r['code'] ) && is_int( $cron_r['code'] ) && $cron_r['code'] < 400; // debug.log exposed (leaks credentials, stack traces, internal paths) $debug_r = $head( $base . 'wp-content/debug.log' ); $ext['debug_log_exposed'] = isset( $debug_r['code'] ) && $debug_r['code'] === 200; // Adminer / phpMyAdmin reachable (full DB access) $db_tools_exposed = []; foreach ( [ 'adminer.php', 'adminer/', 'phpmyadmin/', 'pma/', 'phpMyAdmin/', 'db/' ] as $path ) { $r = $head( $base . $path ); if ( isset( $r['code'] ) && is_int( $r['code'] ) && $r['code'] < 400 ) { $db_tools_exposed[] = $path; } } $ext['db_tools_exposed'] = $db_tools_exposed; // Apache server-status / server-info (leaks live requests and internal IPs) $server_status_r = $head( $base . 'server-status' ); $server_info_r = $head( $base . 'server-info' ); $ext['server_status_exposed'] = isset( $server_status_r['code'] ) && $server_status_r['code'] === 200; $ext['server_info_exposed'] = isset( $server_info_r['code'] ) && $server_info_r['code'] === 200; // Backup archives exposed in webroot (full site or DB dump) $backup_files_exposed = []; $domain_slug = str_replace( '.', '', (string) wp_parse_url( $base, PHP_URL_HOST ) ); $backup_candidates = [ 'backup.zip', 'backup.tar.gz', 'backup.sql', 'site.zip', 'site.tar.gz', 'wordpress.zip', 'wordpress.tar.gz', 'db.sql', 'database.sql', 'dump.sql', $domain_slug . '.zip', $domain_slug . '.sql', 'wp-backup.zip', 'backup.bak', ]; foreach ( $backup_candidates as $f ) { $r = $head( $base . $f ); if ( isset( $r['code'] ) && $r['code'] === 200 ) { $backup_files_exposed[] = $f; } } $ext['backup_files_exposed'] = $backup_files_exposed; // HTTP → HTTPS redirect enforcement $http_base = preg_replace( '/^https:/i', 'http:', $base ); $http_r = wp_remote_head( $http_base, [ 'timeout' => 5, 'sslverify' => false, 'redirection' => 0 ] ); $http_code = is_wp_error( $http_r ) ? null : wp_remote_retrieve_response_code( $http_r ); $http_loc = is_wp_error( $http_r ) ? null : wp_remote_retrieve_header( $http_r, 'location' ); $ext['http_to_https'] = [ 'redirects' => $http_code !== null && $http_code >= 300 && $http_code < 400 && $http_loc && stripos( $http_loc, 'https://' ) === 0, 'http_code' => $http_code, ]; // TLS weak protocol check — test whether TLS 1.0 / 1.1 are still accepted $ext['tls_weak_protocols'] = [ 'checked' => false, 'tls10_accepted' => false, 'tls11_accepted' => false ]; if ( function_exists( 'stream_socket_client' ) ) { $tls_tests = []; if ( defined( 'STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT' ) ) { $tls_tests['tls10_accepted'] = STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT; } if ( defined( 'STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT' ) ) { $tls_tests['tls11_accepted'] = STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT; } foreach ( $tls_tests as $field => $crypto_method ) { $ext['tls_weak_protocols']['checked'] = true; $ctx = stream_context_create( [ 'ssl' => [ 'crypto_method' => $crypto_method, 'verify_peer' => false, 'verify_peer_name' => false, ], ] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $sock = @stream_socket_client( 'ssl://' . $host . ':443', $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $ctx ); if ( $sock ) { $ext['tls_weak_protocols'][ $field ] = true; fclose( $sock ); } } } // Cookie security flags — inspect Set-Cookie headers from wp-login.php $cookie_r = wp_remote_get( $base . 'wp-login.php', [ 'timeout' => 5, 'sslverify' => false ] ); $ext['cookie_security'] = [ 'checked' => false ]; if ( ! is_wp_error( $cookie_r ) ) { $raw_headers = wp_remote_retrieve_headers( $cookie_r ); $set_cookies = []; // WP_HTTP_Requests_Response may return Set-Cookie as array or string $sc = $raw_headers['set-cookie'] ?? []; if ( is_string( $sc ) ) { $sc = [ $sc ]; } foreach ( (array) $sc as $cookie_str ) { if ( stripos( $cookie_str, 'wordpress' ) !== false ) { $set_cookies[] = $cookie_str; } } if ( ! empty( $set_cookies ) ) { $all_secure = true; $all_httponly = true; $all_samesite = true; foreach ( $set_cookies as $cs ) { if ( stripos( $cs, '; Secure' ) === false ) { $all_secure = false; } if ( stripos( $cs, '; HttpOnly' ) === false ) { $all_httponly = false; } if ( stripos( $cs, 'SameSite' ) === false ) { $all_samesite = false; } } $ext['cookie_security'] = [ 'checked' => true, 'secure' => $all_secure, 'httponly' => $all_httponly, 'samesite' => $all_samesite, 'cookie_secure_constant' => defined( 'COOKIE_SECURE' ) && COOKIE_SECURE, ]; } } // WAF / CDN detection $waf_detected = []; $waf_headers_r = wp_remote_get( $base, [ 'timeout' => 5, 'sslverify' => false ] ); if ( ! is_wp_error( $waf_headers_r ) ) { $wh = wp_remote_retrieve_headers( $waf_headers_r ); if ( $wh['cf-ray'] || $wh['cf-cache-status'] || $wh['cf-request-id'] ) { $waf_detected[] = 'Cloudflare'; } if ( $wh['x-sucuri-id'] || $wh['x-sucuri-cache'] ) { $waf_detected[] = 'Sucuri'; } if ( $wh['x-fw-hash'] || $wh['x-fw-static'] ) { $waf_detected[] = 'Wordfence'; } if ( $wh['x-cache'] && stripos( (string) $wh['x-cache'], 'cloudfront' ) !== false ) { $waf_detected[] = 'CloudFront'; } } // Also check if Wordfence plugin is active (server-side indicator) $active_plugins = (array) get_option( 'active_plugins', [] ); foreach ( $active_plugins as $pf ) { if ( stripos( $pf, 'wordfence' ) !== false && ! in_array( 'Wordfence', $waf_detected, true ) ) { $waf_detected[] = 'Wordfence (plugin active)'; } } $ext['waf_cdn'] = [ 'detected' => ! empty( $waf_detected ), 'providers'=> $waf_detected, ]; // Email security — SPF and DMARC DNS records $ext['email_dns'] = self::check_email_dns( $host ); // Security headers (from external perspective via public URL) $headers_r = wp_remote_get( $base, [ 'timeout' => 5, 'sslverify' => false ] ); $ext['security_headers_external'] = []; if ( ! is_wp_error( $headers_r ) ) { $h = wp_remote_retrieve_headers( $headers_r ); foreach ( [ 'x-frame-options', 'x-content-type-options', 'strict-transport-security', 'content-security-policy', 'referrer-policy', 'permissions-policy', 'access-control-allow-origin', 'x-powered-by', 'server' ] as $hname ) { $ext['security_headers_external'][ $hname ] = $h[ $hname ] ?? null; } } // CSP quality — presence alone is not enough; weak directives leave XSS open $csp_val = $ext['security_headers_external']['content-security-policy'] ?? null; $csp_quality = [ 'present' => (bool) $csp_val, 'issues' => [] ]; if ( $csp_val ) { if ( stripos( $csp_val, "'unsafe-inline'" ) !== false ) { $csp_quality['issues'][] = 'unsafe-inline'; } if ( stripos( $csp_val, "'unsafe-eval'" ) !== false ) { $csp_quality['issues'][] = 'unsafe-eval'; } if ( preg_match( '/(?:^|[\s;])(\*)[\s;]/', $csp_val ) ) { $csp_quality['issues'][] = 'wildcard-source'; } if ( stripos( $csp_val, 'default-src' ) === false ) { $csp_quality['issues'][] = 'no-default-src'; } $csp_quality['grade'] = empty( $csp_quality['issues'] ) ? 'good' : 'weak'; } else { $csp_quality['grade'] = 'missing'; } $ext['csp_quality'] = $csp_quality; // HSTS quality — max-age must be ≥1 year to be effective $hsts_val = $ext['security_headers_external']['strict-transport-security'] ?? null; $hsts_quality = [ 'present' => (bool) $hsts_val, 'issues' => [] ]; if ( $hsts_val ) { $max_age = 0; if ( preg_match( '/max-age=(\d+)/i', $hsts_val, $m ) ) { $max_age = (int) $m[1]; } $hsts_quality['max_age'] = $max_age; $hsts_quality['includes_subdomains'] = stripos( $hsts_val, 'includeSubDomains' ) !== false; $hsts_quality['preload'] = stripos( $hsts_val, 'preload' ) !== false; if ( $max_age < 31536000 ) { $hsts_quality['issues'][] = 'max-age-too-short'; } if ( ! $hsts_quality['includes_subdomains'] ) { $hsts_quality['issues'][] = 'no-includeSubDomains'; } $hsts_quality['grade'] = empty( $hsts_quality['issues'] ) ? 'good' : 'weak'; } else { $hsts_quality['grade'] = 'missing'; } $ext['hsts_quality'] = $hsts_quality; // Server header version leak — e.g. "nginx/1.18.0" reveals exact version for CVE targeting $server_hdr = $ext['security_headers_external']['server'] ?? null; $ext['server_version_leak'] = [ 'header' => $server_hdr, 'leaks_version' => $server_hdr !== null && (bool) preg_match( '/\/[\d.]+/', $server_hdr ), ]; return $ext; } private static function scan_plugin_code(): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $active_plugins = (array) get_option( 'active_plugins', [] ); $plugins_dir = WP_PLUGIN_DIR; // Patterns that warrant attention in plugin code $patterns = [ // Remote code execution 'eval(' => 'eval()', 'base64_decode(' => 'base64_decode()', 'exec(' => 'exec()', 'shell_exec(' => 'shell_exec()', 'system(' => 'system()', 'passthru(' => 'passthru()', 'popen(' => 'popen()', 'proc_open(' => 'proc_open()', 'assert(' => 'assert()', 'preg_replace.*\/e' => 'preg_replace /e modifier', 'create_function(' => 'create_function()', // File operations with user input 'file_put_contents.*\$_' => 'file_put_contents with user input', 'move_uploaded_file' => 'move_uploaded_file()', // Outbound requests with user input 'wp_remote_get.*\$_' => 'outbound request with user input', // SQL injection — direct use of user input in DB queries '\$wpdb->(query|get_results|get_row|get_var|prepare).*\$_(GET|POST|REQUEST|COOKIE)' => 'SQL query with raw user input (SQLi risk)', // XSS — echoing user input without escaping 'echo\s+\$_(GET|POST|REQUEST|COOKIE|SERVER)\[' => 'echo user input without escaping (XSS risk)', 'print\s+\$_(GET|POST|REQUEST|COOKIE)\[' => 'print user input without escaping (XSS risk)', // Unsafe deserialization 'unserialize\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)' => 'unserialize() with user input (RCE/object injection)', // Remote file inclusion 'include\s*\(\s*\$_(GET|POST|REQUEST)' => 'include() with user input (RFI risk)', 'require\s*\(\s*\$_(GET|POST|REQUEST)' => 'require() with user input (RFI risk)', ]; $results = []; foreach ( $active_plugins as $plugin_file ) { $plugin_slug = dirname( $plugin_file ); if ( $plugin_slug === '.' ) { continue; // single-file plugin, skip } $plugin_path = $plugins_dir . '/' . $plugin_slug; if ( ! is_dir( $plugin_path ) ) { continue; } // Skip known safe large libraries $skip_dirs = [ 'vendor', 'node_modules', 'assets', 'dist', 'build' ]; $findings = []; $files_scanned = 0; $iter = new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator( $plugin_path, FilesystemIterator::SKIP_DOTS ), function ( $file, $key, $iter ) use ( $skip_dirs ) { if ( $iter->hasChildren() ) { return ! in_array( $file->getFilename(), $skip_dirs, true ); } return $file->getExtension() === 'php'; } ), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ( $iter as $file ) { if ( $files_scanned >= 200 ) { break; // cap per plugin } $files_scanned++; $content = @file_get_contents( $file->getPathname() ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( $content === false ) { continue; } $rel = str_replace( $plugin_path . '/', '', $file->getPathname() ); foreach ( $patterns as $needle => $label ) { if ( preg_match( '/' . $needle . '/i', $content ) ) { // Get the first matching line for context $lines = explode( "\n", $content ); foreach ( $lines as $ln => $line ) { if ( preg_match( '/' . $needle . '/i', $line ) ) { $findings[] = [ 'pattern' => $label, 'file' => $rel, 'line' => $ln + 1, 'snippet' => trim( substr( $line, 0, 120 ) ), ]; break; // one example per pattern per file } } if ( count( $findings ) >= 15 ) { break 2; // cap total findings per plugin } } } } if ( ! empty( $findings ) ) { $results[] = [ 'plugin' => $plugin_slug, 'files_scanned' => $files_scanned, 'findings' => $findings, ]; } } return $results; } /** * AI-powered triage of static code scan findings. * Reads ±10 lines of context around each flagged line, sends up to 10 snippets * to the cheapest available model, and returns per-snippet verdicts. */ private static function triage_code_snippets_with_ai( array $scan_results ): array { if ( empty( $scan_results ) ) { return [ 'skipped' => true, 'reason' => 'no_findings', 'results' => [] ]; } // Flatten findings and sort by risk priority $priority_order = [ 'eval()', 'unserialize() with user input (RCE/object injection)', 'preg_replace /e modifier', 'create_function()', 'SQL query with raw user input (SQLi risk)', 'include() with user input (RFI risk)', 'require() with user input (RFI risk)', 'exec()', 'shell_exec()', 'system()', 'passthru()', 'popen()', 'proc_open()', 'base64_decode()', 'assert()', 'echo user input without escaping (XSS risk)', 'print user input without escaping (XSS risk)', 'file_put_contents with user input', 'move_uploaded_file()', 'outbound request with user input', ]; $flat = []; foreach ( $scan_results as $plugin_result ) { foreach ( $plugin_result['findings'] as $finding ) { $flat[] = array_merge( $finding, [ 'plugin' => $plugin_result['plugin'] ] ); } } usort( $flat, function ( $a, $b ) use ( $priority_order ) { $ai = array_search( $a['pattern'], $priority_order, true ); $bi = array_search( $b['pattern'], $priority_order, true ); $ai = $ai === false ? 999 : $ai; $bi = $bi === false ? 999 : $bi; return $ai - $bi; } ); $top = array_slice( $flat, 0, 10 ); // Build snippet blocks with ±10 lines of context $blocks = []; foreach ( $top as $idx => $s ) { $abs = WP_PLUGIN_DIR . '/' . $s['plugin'] . '/' . $s['file']; $ctx = ''; if ( is_readable( $abs ) ) { $lines = @file( $abs, FILE_IGNORE_NEW_LINES ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( is_array( $lines ) ) { $start = max( 0, $s['line'] - 11 ); $end = min( count( $lines ) - 1, $s['line'] + 9 ); for ( $i = $start; $i <= $end; $i++ ) { $marker = ( $i + 1 === $s['line'] ) ? ' // <<< FLAGGED' : ''; $ctx .= ( $i + 1 ) . ': ' . $lines[ $i ] . $marker . "\n"; } } } if ( ! $ctx ) { $ctx = $s['line'] . ': ' . $s['snippet'] . " // <<< FLAGGED\n"; } $blocks[] = '[' . ( $idx + 1 ) . '] Plugin: ' . $s['plugin'] . ' | File: ' . $s['file'] . ' | Line: ' . $s['line'] . ' | Flagged as: ' . $s['pattern'] . "\n" . "```php\n" . $ctx . '```'; } $system = 'You are a WordPress PHP security expert. Analyse code snippets flagged by automated static analysis. Determine whether each is a genuine exploitable vulnerability or a false positive. Be precise — many static flags are false positives (e.g. eval() inside a template engine, base64_decode() for legitimate asset loading, shell_exec() behind a capability check). Return ONLY a valid JSON array with no markdown wrapping.'; $user = 'Analyse these ' . count( $blocks ) . " flagged PHP snippets from active WordPress plugins. The flagged line is marked // <<< FLAGGED.\n\n" . implode( "\n\n", $blocks ) . "\n\n" . "Return a JSON array — one object per snippet:\n" . '{"id":,"verdict":"confirmed|false_positive|needs_context","severity":"critical|high|medium|low|none","type":"","explanation":"<1-2 concise sentences>","fix":""}'; // Use cheapest/fastest model for triage — cost ~$0.01-0.03 per scan $provider = get_option( 'csdt_devtools_ai_provider', 'anthropic' ); $triage_model = $provider === 'gemini' ? 'gemini-2.0-flash' : 'claude-haiku-4-5-20251001'; try { $raw = self::dispatch_ai_call( $system, $user, $triage_model, 2048 ); } catch ( \Throwable $e ) { return [ 'skipped' => true, 'reason' => 'api_error', 'error' => $e->getMessage(), 'results' => [] ]; } // Strip markdown fences if present $raw = preg_replace( '/^```(?:json)?\s*/i', '', trim( $raw ) ); $raw = preg_replace( '/\s*```$/i', '', trim( $raw ) ); $verdicts = json_decode( $raw, true ); if ( ! is_array( $verdicts ) ) { return [ 'skipped' => true, 'reason' => 'parse_error', 'raw_preview' => substr( $raw, 0, 300 ), 'results' => [] ]; } // Index verdicts by id for merge $by_id = []; foreach ( $verdicts as $v ) { if ( isset( $v['id'] ) ) { $by_id[ (int) $v['id'] ] = $v; } } $output = []; foreach ( $top as $idx => $s ) { $v = $by_id[ $idx + 1 ] ?? []; $output[] = [ 'plugin' => $s['plugin'], 'file' => $s['file'], 'line' => $s['line'], 'pattern' => $s['pattern'], 'verdict' => $v['verdict'] ?? 'needs_context', 'severity' => $v['severity'] ?? 'unknown', 'type' => $v['type'] ?? null, 'explanation' => $v['explanation'] ?? null, 'fix' => $v['fix'] ?? null, ]; } $confirmed = array_filter( $output, function ( $r ) { return $r['verdict'] === 'confirmed'; } ); return [ 'skipped' => false, 'snippets_triaged' => count( $output ), 'confirmed_count' => count( $confirmed ), 'results' => $output, ]; } private static function audit_users(): array { $weak_usernames = [ 'admin', 'administrator', 'webmaster', 'root', 'wp-admin', 'wordpress', 'test', 'user', 'demo' ]; $admins = get_users( [ 'role' => 'administrator' ] ); $weak_admin_logins = []; $admins_no_2fa = []; foreach ( $admins as $user ) { if ( in_array( strtolower( $user->user_login ), $weak_usernames, true ) ) { $weak_admin_logins[] = $user->user_login; } $has_totp = get_user_meta( $user->ID, 'csdt_devtools_totp_enabled', true ) === '1'; $has_passkey = ! empty( get_user_meta( $user->ID, 'csdt_devtools_passkeys', true ) ); $has_email2fa= get_option( 'csdt_devtools_2fa_method', 'off' ) === 'email'; if ( ! $has_totp && ! $has_passkey && ! $has_email2fa ) { $admins_no_2fa[] = $user->user_login; } } $role_counts = []; foreach ( [ 'editor', 'author', 'contributor', 'subscriber' ] as $role ) { $count = count( get_users( [ 'role' => $role, 'fields' => 'ID' ] ) ); if ( $count > 0 ) { $role_counts[ $role ] = $count; } } return [ 'admin_count' => count( $admins ), 'weak_admin_usernames'=> $weak_admin_logins, 'admins_without_2fa' => $admins_no_2fa, 'admins_without_2fa_count' => count( $admins_no_2fa ), 'non_admin_role_counts'=> $role_counts, ]; } private static function audit_cron_events(): array { $crons = _get_cron_array(); if ( empty( $crons ) || ! is_array( $crons ) ) { return [ 'disable_wp_cron' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON, 'total_events' => 0, 'hooks' => [], 'suspicious_hooks' => [] ]; } // Collect all scheduled hook names $all_hooks = []; foreach ( $crons as $hooks ) { foreach ( array_keys( $hooks ) as $hook ) { $all_hooks[] = $hook; } } $unique_hooks = array_values( array_unique( $all_hooks ) ); // Known WP core hooks $core_hooks = [ 'wp_scheduled_delete', 'wp_update_plugins', 'wp_update_themes', 'wp_version_check', 'wp_scheduled_auto_draft_delete', 'delete_expired_transients', 'wp_privacy_delete_old_export_files', 'recovery_mode_clean_expired_keys', 'wp_site_health_scheduled_check', 'wp_update_user_counts', 'wp_delete_temp_updater_backups', ]; // Build known hooks from active plugins (use option-stored hook prefixes as heuristic) $active_plugins = (array) get_option( 'active_plugins', [] ); $plugin_prefixes = array_map( fn( $f ) => strtolower( str_replace( '-', '_', dirname( $f ) ) ), $active_plugins ); $suspicious = []; foreach ( $unique_hooks as $hook ) { if ( in_array( $hook, $core_hooks, true ) ) { continue; } $matched = false; foreach ( $plugin_prefixes as $prefix ) { if ( $prefix !== '.' && stripos( $hook, $prefix ) !== false ) { $matched = true; break; } } // Also pass through anything with common legit patterns if ( ! $matched && ! preg_match( '/^(wp_|wc_|woo|yoast|rank_math|acf_|tribe_|vc_|elementor|jetpack|akismet|wordfence|sucuri|updraft|backup|cache|cron|schedule|clean|purge|sync|check|update|send|mail|report)/i', $hook ) ) { $suspicious[] = $hook; } } return [ 'disable_wp_cron' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON, 'total_events' => count( $unique_hooks ), 'hooks' => array_slice( $unique_hooks, 0, 30 ), 'suspicious_hooks'=> $suspicious, ]; } private static function enrich_plugins_with_wporg( array $active_plugin_files ): array { $results = []; $two_years_ago = strtotime( '-2 years' ); foreach ( $active_plugin_files as $plugin_file ) { $slug = dirname( $plugin_file ); if ( $slug === '.' ) { continue; // single-file plugin, skip } $resp = wp_remote_get( 'https://api.wordpress.org/plugins/info/1.0/' . rawurlencode( $slug ) . '.json', [ 'timeout' => 6, 'sslverify' => true ] ); if ( is_wp_error( $resp ) || wp_remote_retrieve_response_code( $resp ) !== 200 ) { continue; } $data = json_decode( wp_remote_retrieve_body( $resp ), true ); if ( empty( $data ) || isset( $data['error'] ) ) { continue; // not in WP.org repo (premium plugin etc.) } $last_updated_ts = isset( $data['last_updated'] ) ? strtotime( $data['last_updated'] ) : null; $results[ $slug ] = [ 'slug' => $slug, 'last_updated' => $data['last_updated'] ?? null, 'last_updated_ts' => $last_updated_ts, 'abandoned' => $last_updated_ts && $last_updated_ts < $two_years_ago, 'years_since_update' => $last_updated_ts ? round( ( time() - $last_updated_ts ) / YEAR_IN_SECONDS, 1 ) : null, 'active_installs' => $data['active_installs'] ?? null, 'rating' => isset( $data['rating'] ) ? (int) $data['rating'] : null, 'requires_wp' => $data['requires'] ?? null, 'tested_up_to' => $data['tested'] ?? null, ]; } return $results; } private static function check_plugin_vulnerabilities( array $active_plugin_files, array $all_plugins ): array { $vulns = []; foreach ( $active_plugin_files as $plugin_file ) { $slug = dirname( $plugin_file ); $version = $all_plugins[ $plugin_file ]['Version'] ?? null; if ( $slug === '.' || ! $version ) { continue; } // Patchstack public vulnerability API — no key required $resp = wp_remote_get( 'https://patchstack.com/database/api/v1/vulnerability?search=' . rawurlencode( $slug ) . '&per_page=5', [ 'timeout' => 8, 'sslverify' => true ] ); if ( is_wp_error( $resp ) || wp_remote_retrieve_response_code( $resp ) !== 200 ) { continue; } $data = json_decode( wp_remote_retrieve_body( $resp ), true ); if ( empty( $data['data'] ) || ! is_array( $data['data'] ) ) { continue; } foreach ( $data['data'] as $vuln ) { $fixed_in = $vuln['fixed_in'] ?? null; // Only include if the installed version is affected (below fixed_in, or no fix released) $affected = ! $fixed_in || version_compare( $version, $fixed_in, '<' ); if ( ! $affected ) { continue; } $vulns[] = [ 'plugin' => $slug, 'version' => $version, 'cve' => $vuln['cve_id'] ?? null, 'title' => $vuln['title'] ?? $vuln['vuln_type'] ?? 'Unknown vulnerability', 'severity' => $vuln['severity'] ?? null, 'cvss' => $vuln['cvss_score'] ?? null, 'fixed_in' => $fixed_in, 'disclosed_at' => $vuln['disclosed_at'] ?? null, ]; if ( count( $vulns ) >= 20 ) { break 2; // cap total } } } return $vulns; } private static function check_core_integrity(): array { $version = get_bloginfo( 'version' ); $resp = wp_remote_get( 'https://api.wordpress.org/core/checksums/1.0/?version=' . rawurlencode( $version ) . '&locale=en_US', [ 'timeout' => 8, 'sslverify' => true ] ); if ( is_wp_error( $resp ) || wp_remote_retrieve_response_code( $resp ) !== 200 ) { return [ 'available' => false, 'error' => 'Could not fetch checksums from WordPress.org' ]; } $body = json_decode( wp_remote_retrieve_body( $resp ), true ); $checksums = $body['checksums'] ?? null; if ( ! is_array( $checksums ) ) { return [ 'available' => false, 'error' => 'Invalid checksum response' ]; } // High-value files most commonly backdoored $check_files = [ 'index.php', 'wp-login.php', 'wp-settings.php', 'wp-load.php', 'wp-config-sample.php', 'wp-includes/functions.php', 'wp-includes/pluggable.php', 'wp-includes/class-wp-hook.php', 'wp-includes/class-wp-query.php', 'wp-includes/user.php', 'wp-admin/index.php', 'wp-admin/includes/file.php', ]; $modified = []; $missing = []; $checked = 0; foreach ( $check_files as $file ) { if ( ! isset( $checksums[ $file ] ) ) { continue; } $path = ABSPATH . $file; if ( ! file_exists( $path ) ) { $missing[] = $file; continue; } $checked++; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $actual = @md5_file( $path ); if ( $actual && $actual !== $checksums[ $file ] ) { $modified[] = $file; } } return [ 'available' => true, 'wp_version' => $version, 'files_checked' => $checked, 'modified_files' => $modified, 'missing_files' => $missing, 'clean' => empty( $modified ) && empty( $missing ), ]; } private static function scan_malware_indicators(): array { $uploads_dir = wp_upload_dir(); $uploads_base= $uploads_dir['basedir']; // 1. PHP files in uploads directory (should be zero) $php_in_uploads = []; if ( is_dir( $uploads_base ) ) { $iter = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $uploads_base, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ( $iter as $file ) { if ( $file->getExtension() === 'php' ) { $php_in_uploads[] = str_replace( $uploads_base . '/', '', $file->getPathname() ); if ( count( $php_in_uploads ) >= 10 ) { break; } } } } // 2. PHP files modified in the last 7 days outside plugin/theme dirs $recently_modified = []; $cutoff = time() - ( 7 * DAY_IN_SECONDS ); $skip_paths = [ WP_PLUGIN_DIR, get_theme_root() ]; $scan_dirs = [ ABSPATH, ABSPATH . 'wp-includes', ABSPATH . 'wp-admin' ]; foreach ( $scan_dirs as $dir ) { if ( ! is_dir( $dir ) ) { continue; } $iter = new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), function ( $file, $key, $iter ) use ( $skip_paths ) { if ( $iter->hasChildren() ) { foreach ( $skip_paths as $skip ) { if ( strpos( $file->getPathname(), $skip ) === 0 ) { return false; } } return true; } return $file->getExtension() === 'php'; } ), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ( $iter as $file ) { if ( $file->getMTime() > $cutoff ) { $recently_modified[] = str_replace( ABSPATH, '', $file->getPathname() ); if ( count( $recently_modified ) >= 15 ) { break 2; } } } } return [ 'php_files_in_uploads' => $php_in_uploads, 'php_files_in_uploads_count'=> count( $php_in_uploads ), 'recently_modified_php' => $recently_modified, 'recently_modified_count' => count( $recently_modified ), ]; } private static function gather_theme_data(): array { $theme = wp_get_theme(); $parent = $theme->parent(); $update_themes = get_site_transient( 'update_themes' ); $has_update = isset( $update_themes->response[ $theme->get_stylesheet() ] ); $parent_update = $parent ? isset( $update_themes->response[ $parent->get_stylesheet() ] ) : false; return [ 'active_theme' => $theme->get( 'Name' ), 'active_theme_version'=> $theme->get( 'Version' ), 'active_theme_update' => $has_update, 'parent_theme' => $parent ? $parent->get( 'Name' ) : null, 'parent_theme_update' => $parent_update, ]; } private static function check_auth_salts(): array { $defaults = [ 'put your unique phrase here', '' ]; $keys = [ 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT' ]; $weak = []; foreach ( $keys as $k ) { if ( ! defined( $k ) || in_array( constant( $k ), $defaults, true ) || strlen( constant( $k ) ) < 32 ) { $weak[] = $k; } } return [ 'all_set' => empty( $weak ), 'weak_keys'=> $weak, ]; } private static function gather_deep_security_data(): array { $base = self::gather_security_data(); $active_files = (array) get_option( 'active_plugins', [] ); if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); $external = self::gather_external_checks(); $code_scan = self::scan_plugin_code(); $theme = self::gather_theme_data(); $salts = self::check_auth_salts(); $wporg_data = self::enrich_plugins_with_wporg( $active_files ); $cve_data = self::check_plugin_vulnerabilities( $active_files, $all_plugins ); $core_integrity = self::check_core_integrity(); $malware = self::scan_malware_indicators(); $user_audit = self::audit_users(); $cron_audit = self::audit_cron_events(); // PHP end-of-life status $php_eol_dates = [ '5.6' => '2018-12-31', '7.0' => '2019-01-10', '7.1' => '2019-12-01', '7.2' => '2019-11-30', '7.3' => '2020-12-06', '7.4' => '2022-11-28', '8.0' => '2023-11-26', '8.1' => '2025-12-31', '8.2' => '2026-12-31', '8.3' => '2027-12-31', '8.4' => '2028-12-31', ]; $php_minor = implode( '.', array_slice( explode( '.', PHP_VERSION ), 0, 2 ) ); $php_eol_date = $php_eol_dates[ $php_minor ] ?? null; $php_is_eol = $php_eol_date !== null && strtotime( $php_eol_date ) < time(); $php_eol_info = [ 'version' => PHP_VERSION, 'minor' => $php_minor, 'eol_date' => $php_eol_date, 'is_eol' => $php_is_eol, 'days_since' => ( $php_is_eol && $php_eol_date ) ? (int) round( ( time() - strtotime( $php_eol_date ) ) / 86400 ) : null, 'known' => $php_eol_date !== null, ]; // WordPress auto-update configuration $auto_updates = [ 'updater_globally_disabled' => defined( 'AUTOMATIC_UPDATER_DISABLED' ) && AUTOMATIC_UPDATER_DISABLED, 'core_auto_update_constant' => defined( 'WP_AUTO_UPDATE_CORE' ) ? WP_AUTO_UPDATE_CORE : null, ]; $auto_updates['core_disabled'] = $auto_updates['updater_globally_disabled'] || $auto_updates['core_auto_update_constant'] === false; // PHP display_errors — exposes stack traces and file paths to all visitors $di_raw = (string) ini_get( 'display_errors' ); $display_errors = [ 'display_errors_on' => ! in_array( $di_raw, [ '', '0', 'Off', 'off', 'FALSE', 'false' ], true ), 'wp_debug_display' => defined( 'WP_DEBUG_DISPLAY' ) ? WP_DEBUG_DISPLAY : null, 'ini_value' => $di_raw, ]; // Inactive (deactivated) plugins — installed on disk, still exploitable via directory traversal $inactive_plugins = []; foreach ( $all_plugins as $plugin_file => $plugin_data ) { if ( ! in_array( $plugin_file, $active_files, true ) ) { $inactive_plugins[] = [ 'name' => $plugin_data['Name'], 'version' => $plugin_data['Version'], 'file' => $plugin_file, ]; } } return array_merge( $base, [ 'theme' => $theme, 'auth_salts' => $salts, 'user_audit' => $user_audit, 'cron_audit' => $cron_audit, 'plugin_wporg' => $wporg_data, 'plugin_cves' => $cve_data, 'core_integrity' => $core_integrity, 'malware_indicators' => $malware, 'external_checks' => $external, 'plugin_code_scan' => $code_scan, 'php_eol' => $php_eol_info, 'auto_updates' => $auto_updates, 'display_errors' => $display_errors, 'inactive_plugins' => $inactive_plugins, ] ); } private static function default_deep_scan_prompt(): string { return <<<'PROMPT' You are a professional penetration tester and WordPress security expert performing a comprehensive security audit. You will receive a JSON object with these categories: 1. Internal config — WP/PHP versions (also php_eol key: version, minor, eol_date, is_eol, days_since — EOL PHP receives no security patches, treat as critical if is_eol=true), debug flags, DISALLOW_FILE_EDIT/MODS, FORCE_SSL_ADMIN, database prefix, admin username, user counts, brute force, 2FA (email/TOTP/passkey counts), login URL obfuscation, wp-config.php permissions. Also includes app_passwords: enabled flag, how many admins have application passwords created (app passwords bypass 2FA). display_errors key: display_errors_on=true means PHP stack traces and file paths are exposed to all visitors — high risk on any production site. auto_updates key: updater_globally_disabled and core_disabled flags — if core_disabled=true the site will not auto-patch security releases. 2. Site config — open user registration, pingbacks enabled (DDoS amplification), WP version in meta generator tag, comment defaults. 3. Theme — active theme name/version, pending update for active or parent theme. 4. Auth salts — all 8 WP secret keys/salts set and non-default (weak salts = session forgery). 5. User audit (user_audit) — admin_count, weak_admin_usernames (e.g. "admin", "administrator"), admins_without_2fa (list of admin logins with no TOTP/passkey/email 2FA), non_admin_role_counts. 6. Cron audit (cron_audit) — disable_wp_cron flag, suspicious_hooks (scheduled hook names that don't match any active plugin or WP core hook — potential malware persistence). 7. Plugin WP.org data (plugin_wporg) — for each active plugin: last_updated, abandoned (>2 years since update), years_since_update, active_installs, tested_up_to. Abandoned plugins with low install counts are high risk. Also includes inactive_plugins key: list of installed-but-deactivated plugins (name, version, file) — they sit on disk unpatched and can be exploited via directory traversal or have known CVEs even though not running. 8. Known CVEs (plugin_cves) — each entry has: plugin slug, version installed, CVE ID, title, severity (critical/high/medium/low), CVSS score, fixed_in version. ANY unfixed CVE at critical/high severity is a critical finding. 9. Core file integrity (core_integrity) — MD5 comparison of key WP core files against WordPress.org checksums. modified_files = likely backdoor. This is CRITICAL if any files are listed. 10. Malware indicators (malware_indicators) — php_files_in_uploads (PHP files found in uploads dir — should be zero, any found = likely webshell), recently_modified_php (core PHP files modified in last 7 days outside plugin/theme dirs — warrants investigation). 11. External checks — SSL validity/expiry, HTTP→HTTPS redirect, TLS weak protocols (tls_weak_protocols: checked, tls10_accepted, tls11_accepted — TLS 1.0/1.1 deprecated since 2021, susceptible to POODLE/BEAST attacks), wp-login.php/xmlrpc.php/wp-cron.php access, REST API user enum (rest_users: exposed, count, slugs), author enum, directory listings (uploads_listing, plugins_listing, themes_listing — plugins/themes listing reveals exact software versions to attackers), exposed files (debug.log, .env, backup archives, phpinfo.php, .git/config etc), adminer/phpMyAdmin, server-status/server-info, WAF/CDN detected (waf_cdn.detected, waf_cdn.providers), cookie_security (WP session cookies Secure/HttpOnly/SameSite flags), email DNS (email_dns: mx_present=false means no email is configured for this domain — skip all SPF/DMARC/DKIM findings entirely; mx_present=true means email is active and all three records matter: spf_present, spf_strictness: hard_fail=good/soft_fail=weak/pass_all=dangerous; dmarc_present, dmarc_policy: none=monitoring-only-does-nothing/quarantine=acceptable/reject=best, dmarc_pct; dkim_present, dkim_selector — all three required with strong policies for full spoofing protection), security headers (csp_quality: grade good/weak/missing, issues: unsafe-inline/unsafe-eval/wildcard-source/no-default-src — any issue weakens XSS mitigation; hsts_quality: grade, max_age, includes_subdomains, issues — max-age < 31536000 means HTTPS not enforced for a full year; X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, access-control-allow-origin — wildcard "*" allows credential theft from any origin), server_version_leak: leaks_version=true means Server header discloses exact software version (e.g. nginx/1.18.0) aiding targeted CVE exploitation. 12. Plugin code scan — raw static analysis findings (may include false positives): RCE functions (eval, exec, shell_exec, base64_decode), SQLi (wpdb with raw $_GET/$_POST), XSS (unescaped echo of user input), unserialize with user input, RFI (include/require with user input). Includes plugin, file, line number. 13. Code triage (code_triage) — AI-verified verdicts on the static scan findings. Each entry: plugin, file, line, verdict (confirmed|false_positive|needs_context), severity, type, explanation, fix. ONLY report confirmed findings as real vulnerabilities — ignore false_positives. Use triage severity for confirmed items. For needs_context, mention at low severity with explanation. Cross-correlate ALL categories for compound risks: - Known CVE (critical/high) = immediately critical regardless of other factors - Modified core files = active compromise, treat as critical - PHP files in uploads = likely webshell, treat as critical - wp-login.php accessible + brute force disabled = critical combined risk - Abandoned plugin (>2 years) + known CVE = critical - No WAF/CDN detected + multiple exposed endpoints = significantly elevated risk - email_dns.mx_present=false = domain has no email — do NOT flag missing SPF/DMARC/DKIM, they are irrelevant; ADD a good[] entry: title "No email configured", detail "No MX records found for this domain — SPF, DMARC, and DKIM checks were skipped as they are not applicable." - email_dns.mx_present=true + missing SPF + DMARC = email spoofing trivially possible - email_dns.mx_present=true + spf_strictness=soft_fail (~all) = SPF won't block spoofed emails — flag medium; -all required - email_dns.mx_present=true + dmarc_policy=none = DMARC record exists but does nothing (monitoring only) — flag medium; quarantine/reject required to block - email_dns.mx_present=true + spf_strictness=soft_fail + dmarc_policy=none = email spoofing fully unblocked despite records existing — escalate to high - wp-cron.php public = unauthenticated resource exhaustion - Default auth salts = any active session can be forged - debug.log exposed = credentials and stack traces publicly readable - WP version in meta + outdated WP = targeted exploit possible - Admins without 2FA = single password compromise = full site takeover - Application passwords enabled + admins have app passwords = 2FA bypassable via REST API - Suspicious cron hooks = possible malware persistence mechanism - WP session cookies missing Secure/HttpOnly = session hijacking risk - PHP EOL (php_eol.is_eol=true) = no security patches for PHP engine itself — critical if days_since > 365 - TLS 1.0/1.1 accepted = deprecated protocols, POODLE/BEAST exploitable — mark high - Missing DKIM (dkim_present=false) = email spoofing possible even with SPF+DMARC — all three needed - plugins_listing or themes_listing exposed = reveals exact plugin/theme versions to targeted attackers - access-control-allow-origin: "*" = wildcard CORS allows any site to make credentialed requests — critical if combined with sensitive REST endpoints - REST API user enum exposed (rest_users.exposed=true) = real usernames exposed for credential stuffing — escalates brute force risk significantly - Abandoned plugin (plugin_wporg: abandoned=true) with no active CVEs = still high risk — unpatched future vulnerabilities likely - display_errors.display_errors_on=true = PHP stack traces with file paths and variable values visible to all visitors — mark high on production - auto_updates.core_disabled=true = WP core will not auto-patch security releases; combined with outdated WP version = high risk - inactive_plugins count > 0 = deactivated plugins on disk are unpatched attack surface; flag names and versions for awareness - csp_quality.grade=missing or weak + any XSS code finding = actively exploitable XSS without browser-side mitigation - hsts_quality.grade=missing or max_age < 31536000 = HTTPS not enforced long-term, HTTP downgrade / MITM possible - server_version_leak.leaks_version=true + unpatched software = version fingerprinting directly aids targeted exploitation — escalate severity Return ONLY a JSON object (no markdown, no code fences, no explanation): { "score": , "score_label": "", "summary": "<2-3 sentence executive summary — lead with the most critical finding>", "critical": [{"title":"...","detail":"...","fix":"..."}], "high": [{"title":"...","detail":"...","fix":"..."}], "medium": [{"title":"...","detail":"...","fix":"..."}], "low": [{"title":"...","detail":"...","fix":"..."}], "good": [{"title":"...","detail":"..."}] } Scoring (be strict — known CVEs and modified core files force score to 0-34): 90-100: Excellent — no CVEs, clean core, hardened config, no significant exposure 75-89: Good — minor issues only, no critical/high CVEs 55-74: Fair — medium CVEs or some external exposure 35-54: Poor — high CVEs, multiple exposures, or config weaknesses 0-34: Critical — critical CVE, modified core files, webshell indicators, or actively exploitable exposure Name exact plugin slugs, CVE IDs, file paths, and settings in every finding. Include GOOD PRACTICES for correctly hardened items. PROMPT; } public static function ajax_deep_scan(): void { check_ajax_referer( 'csdt_devtools_security_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $cache_only = ! empty( $_POST['cache_only'] ); // Page-load pre-fill: return cache silently or signal nothing cached if ( $cache_only ) { $cached = get_option( 'csdt_deep_scan_v1' ); if ( $cached !== false ) { wp_send_json_success( array_merge( $cached, [ 'from_cache' => true ] ) ); } else { wp_send_json_success( [ 'no_cache' => true ] ); } return; } $provider = get_option( 'csdt_devtools_ai_provider', 'anthropic' ); $has_key = $provider === 'gemini' ? ! empty( get_option( 'csdt_devtools_gemini_key', '' ) ) : ! empty( get_option( 'csdt_devtools_anthropic_key', '' ) ); if ( ! $has_key ) { wp_send_json_error( [ 'message' => 'No API key configured.', 'need_key' => true ] ); return; } // Clear previous result and mark as running delete_option( 'csdt_deep_scan_v1' ); set_transient( 'csdt_deep_scan_status', [ 'status' => 'running', 'started_at' => time() ], 900 ); // Send response immediately, then run scan after connection closes self::send_json_and_continue( [ 'queued' => true ] ); self::cron_deep_scan(); exit; } public static function cron_deep_scan(): void { if ( function_exists( 'set_time_limit' ) ) { set_time_limit( 0 ); } try { if ( get_transient( 'csdt_deep_scan_cancelled' ) ) { delete_transient( 'csdt_deep_scan_cancelled' ); return; } $model = get_option( 'csdt_devtools_deep_scan_model', '_auto_deep' ); $base_data = self::gather_security_data(); $external = self::gather_external_checks(); $code_scan = self::scan_plugin_code(); $code_triage = self::triage_code_snippets_with_ai( $code_scan ); if ( get_transient( 'csdt_deep_scan_cancelled' ) ) { delete_transient( 'csdt_deep_scan_cancelled' ); return; } $msg_internal = 'WordPress internal configuration data (JSON):' . "\n\n" . wp_json_encode( $base_data, JSON_PRETTY_PRINT ); $msg_external = 'WordPress external exposure, plugin code scan, and AI code triage data (JSON):' . "\n\n" . wp_json_encode( [ 'external_checks' => $external, 'plugin_code_scan' => $code_scan, 'code_triage' => $code_triage, ], JSON_PRETTY_PRINT ); if ( function_exists( 'curl_multi_init' ) ) { error_log( '[CSDT-DEEP] firing two parallel AI calls, model=' . $model ); $texts = self::dispatch_parallel_ai_calls( [ [ 'system' => self::default_internal_scan_prompt(), 'user' => $msg_internal, 'model' => $model, 'max_tokens' => 4096 ], [ 'system' => self::default_external_scan_prompt(), 'user' => $msg_external, 'model' => $model, 'max_tokens' => 4096 ], ] ); $report = self::merge_reports( self::parse_ai_json( $texts[0] ), self::parse_ai_json( $texts[1] ) ); } else { // Fallback: single sequential call error_log( '[CSDT-DEEP] curl_multi unavailable, falling back to sequential, model=' . $model ); $text = self::dispatch_ai_call( self::default_deep_scan_prompt(), 'WordPress site full security data (JSON):' . "\n\n" . wp_json_encode( [ 'internal' => $base_data, 'external_checks' => $external, 'plugin_code_scan' => $code_scan, 'code_triage' => $code_triage ], JSON_PRETTY_PRINT ), $model, 8192 ); $report = self::parse_ai_json( $text ); } } catch ( \Throwable $e ) { set_transient( 'csdt_deep_scan_status', [ 'status' => 'error', 'message' => $e->getMessage() ], 300 ); return; } if ( get_transient( 'csdt_deep_scan_cancelled' ) ) { delete_transient( 'csdt_deep_scan_cancelled' ); return; } $output = [ 'report' => $report, 'code_triage' => $code_triage, 'model_used' => get_option( 'csdt_devtools_ai_provider', 'anthropic' ) . '/' . $model, 'scanned_at' => time(), 'from_cache' => false, ]; update_option( 'csdt_deep_scan_v1', $output, false ); set_transient( 'csdt_deep_scan_status', [ 'status' => 'complete', 'completed_at' => time() ], 900 ); self::append_scan_history( 'deep', $report, $output['model_used'], $output['scanned_at'] ); error_log( '[CSDT-DEEP] cron complete (parallel), score=' . $report['score'] ); } private static function append_scan_history( string $type, array $report, string $model_used, int $scanned_at ): void { $history = get_option( 'csdt_scan_history', [] ); if ( ! is_array( $history ) ) { $history = []; } array_unshift( $history, [ 'type' => $type, 'score' => $report['score'] ?? null, 'score_label' => $report['score_label'] ?? '', 'summary' => $report['summary'] ?? '', 'critical_count' => count( $report['critical'] ?? [] ), 'high_count' => count( $report['high'] ?? [] ), 'model_used' => $model_used, 'scanned_at' => $scanned_at, ] ); // Keep last 10 across both scan types $history = array_slice( $history, 0, 10 ); update_option( 'csdt_scan_history', $history, false ); } public static function ajax_scan_history(): void { check_ajax_referer( 'csdt_devtools_security_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); return; } wp_send_json_success( get_option( 'csdt_scan_history', [] ) ); } public static function ajax_scan_status(): void { check_ajax_referer( 'csdt_devtools_security_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $type = isset( $_POST['type'] ) ? sanitize_key( $_POST['type'] ) : 'standard'; if ( $type === 'deep' ) { $status_key = 'csdt_deep_scan_status'; $result_key = 'csdt_deep_scan_v1'; } else { $status_key = 'csdt_vuln_scan_status'; $result_key = 'csdt_security_scan_v2'; } $status = get_transient( $status_key ); $result = get_option( $result_key ); if ( ! $status ) { if ( $result ) { wp_send_json_success( [ 'status' => 'complete', 'data' => array_merge( $result, [ 'from_cache' => true ] ) ] ); } else { wp_send_json_success( [ 'status' => 'idle' ] ); } return; } if ( $status['status'] === 'running' ) { wp_send_json_success( [ 'status' => 'running' ] ); return; } if ( $status['status'] === 'complete' && $result ) { wp_send_json_success( [ 'status' => 'complete', 'data' => array_merge( $result, [ 'from_cache' => false ] ) ] ); return; } if ( $status['status'] === 'error' ) { wp_send_json_success( [ 'status' => 'error', 'message' => $status['message'] ?? 'Scan failed.' ] ); return; } wp_send_json_success( [ 'status' => 'idle' ] ); } /* ================================================================== TEST ACCOUNT MANAGER ================================================================== */ private static function get_active_test_accounts(): array { $users = get_users( [ 'meta_key' => 'csdt_test_account', 'meta_value' => '1', 'fields' => [ 'ID', 'user_login' ], ] ); $accounts = []; foreach ( $users as $u ) { $expires_at = (int) get_user_meta( $u->ID, 'csdt_test_expires_at', true ); $single_use = (bool) get_user_meta( $u->ID, 'csdt_test_single_use', true ); $accounts[] = [ 'user_id' => $u->ID, 'username' => $u->user_login, 'expires_at' => $expires_at, 'expires_in' => max( 0, $expires_at - time() ), 'single_use' => $single_use, ]; } return $accounts; } private static function create_test_account( int $ttl = 1800 ): array { $username = 'test-' . wp_generate_password( 8, false, false ); $password = wp_generate_password( 20 ); $email = $username . '@test.local'; $user_id = wp_create_user( $username, $password, $email ); if ( is_wp_error( $user_id ) ) { return [ 'error' => $user_id->get_error_message() ]; } $user = new WP_User( $user_id ); $user->set_role( 'subscriber' ); $expires_at = time() + $ttl; $single_use = get_option( 'csdt_test_account_single_use', '0' ) === '1'; update_user_meta( $user_id, 'csdt_test_account', '1' ); update_user_meta( $user_id, 'csdt_test_expires_at', $expires_at ); update_user_meta( $user_id, 'csdt_test_single_use', $single_use ? '1' : '0' ); [ $app_password, $item ] = WP_Application_Passwords::create_new_application_password( $user_id, [ 'name' => 'playwright-ci' ] ); if ( is_wp_error( $app_password ) ) { wp_delete_user( $user_id ); return [ 'error' => $app_password->get_error_message() ]; } $formatted_pw = implode( ' ', str_split( $app_password, 4 ) ); return [ 'user_id' => $user_id, 'username' => $username, 'app_password' => $formatted_pw, 'rest_url' => rest_url( 'wp/v2/users/me' ), 'expires_at' => $expires_at, 'accounts' => self::get_active_test_accounts(), ]; } public static function ajax_create_test_account(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Forbidden', 403 ); } check_ajax_referer( 'csdt_devtools_login_nonce', 'nonce' ); $ttl = (int) get_option( 'csdt_test_account_ttl', '1800' ); $result = self::create_test_account( $ttl ); if ( isset( $result['error'] ) ) { wp_send_json_error( $result['error'] ); } wp_send_json_success( $result ); } public static function ajax_revoke_test_account(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Forbidden', 403 ); } check_ajax_referer( 'csdt_devtools_login_nonce', 'nonce' ); $user_id = (int) ( $_POST['user_id'] ?? 0 ); if ( ! $user_id ) { wp_send_json_error( 'Missing user_id' ); } if ( get_user_meta( $user_id, 'csdt_test_account', true ) !== '1' ) { wp_send_json_error( 'Not a test account' ); } wp_delete_user( $user_id ); wp_send_json_success( [ 'accounts' => self::get_active_test_accounts() ] ); } public static function ajax_save_test_account_settings(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Forbidden', 403 ); } check_ajax_referer( 'csdt_devtools_login_nonce', 'nonce' ); $enabled = ( $_POST['enabled'] ?? '0' ) === '1' ? '1' : '0'; $ttl = in_array( (string) ( $_POST['ttl'] ?? '1800' ), [ '1800', '3600', '7200', '86400' ], true ) ? (string) $_POST['ttl'] : '1800'; $single_use = ( $_POST['single_use'] ?? '0' ) === '1' ? '1' : '0'; update_option( 'csdt_test_accounts_enabled', $enabled ); update_option( 'csdt_test_account_ttl', $ttl ); update_option( 'csdt_test_account_single_use', $single_use ); if ( $enabled === '1' ) { if ( ! wp_next_scheduled( 'csdt_cleanup_test_accounts' ) ) { wp_schedule_event( time() + 300, 'csdt_every_5min', 'csdt_cleanup_test_accounts' ); } } else { wp_clear_scheduled_hook( 'csdt_cleanup_test_accounts' ); } wp_send_json_success(); } public static function cleanup_expired_test_accounts(): void { $users = get_users( [ 'meta_key' => 'csdt_test_account', 'meta_value' => '1', 'fields' => [ 'ID' ], ] ); $now = time(); foreach ( $users as $u ) { $expires_at = (int) get_user_meta( $u->ID, 'csdt_test_expires_at', true ); if ( $expires_at && $expires_at < $now ) { wp_delete_user( $u->ID ); } } } public static function filter_app_pw_for_user( $available, $user ): bool { if ( get_user_meta( $user->ID, 'csdt_test_account', true ) === '1' ) { return true; } return false; } public static function test_account_after_auth( $user, $app_password ): void { if ( get_user_meta( $user->ID, 'csdt_test_single_use', true ) === '1' ) { wp_delete_user( $user->ID ); } } } CloudScale_DevTools::init();