e_x_e_c(` and `::e_x_e_c(`, * because a word boundary exists between the sigil and the name. * * @since 1.10.837 */ const CALL_BOUNDARY = '(?:])(? 1, 'high' => 2, 'medium' => 3, 'low' => 4, 'info' => 5 ); const OPTION_DEEP_DIAG = 'csdt_deep_scan_diag'; /** * The posts the last gather_site_audit_data() call read, keyed by id, with their view and edit * links. Kept OUT of the data sent to the model: links are attached from here by * attach_post_links(), never taken from model output. * * @var array */ private static $audit_post_index = array(); /** * Token ceiling for one judgement half. * * Was 6144, which the internal half, CVE matches, integrity results and malware * indicators, did not reliably fit a full report inside. A reply cut off at the ceiling * used to be discarded whole, taking that half of the audit with it. Then 14336, which * fixed that and introduced a worse problem. * * NOW 8192, because this ceiling is the only thing bounding a pathological reasoning * loop. CloudScale-Wp-Proxy/ai-backend.php records the measurement: normal reasoning on * this chain is ~50 tokens, and one incident burned 12288, the entire budget, emitting * no text at all. No reasoning-effort setting bounds that (measured: no parameter gave 50 * tokens, effort=medium 51, effort=low 84), and the retry that exists for it cannot fire, * because a loop that hangs never returns a completion to inspect. It just holds the * socket. On 2026-08-11 that cost a deep scan 240 seconds and one half of its findings, * and produced two alerts with an identical "2343 bytes received", the same stall twice. * * So a bigger ceiling is a longer runway for the loop. 8192 is chosen against the real * outputs rather than the fear: the largest observed reply on this site is 5268 tokens, * so this leaves ~55% headroom, while cutting what a runaway can spend by 43%. Do NOT * return to 6144, that is the number the halves were demonstrably truncating at. * * @since 1.10.517 */ const JUDGEMENT_MAX_TOKENS = 8192; /** * Appended to the system prompt on the single retry after an unparseable reply. * * States only the format, since that is what failed. Nothing here changes what the model * is asked to assess, so a retry cannot produce a different SECURITY opinion from the * first attempt, only a better-formed one. * * @since 1.10.517 */ const JUDGEMENT_RETRY_NOTE = 'FORMAT REQUIREMENT: reply with a single raw JSON object and nothing else. ' . 'No markdown fences, no commentary before or after it. Keep every "detail" and "fix" string ' . 'under 300 characters so the whole object fits well within the response limit, a reply that ' . 'is cut off before its final closing brace cannot be used.'; /** * User IDs for a query, always as a flat array of ints. * * get_users( array( 'fields' => 'ID' ) ) does NOT reliably return a flat list of * IDs. On WordPress 7.0.3 it returns stdClass rows carrying an ID property, and * this plugin assumed the flat form in six places. Four of them then did * `(int) $id`, and casting an object to int yields 1 with a warning, so every one * of those looked up user ID 1 instead of the administrator. * * On this site the only administrator is ID 2 and user 1 does not exist, so the * audit reported that NO administrator had TOTP (truth: enabled), none had a * passkey (truth: two), and none had application passwords. A security audit * asserting that 2FA is absent when it is enforced is a false finding in the * dangerous direction, and it was fed to the AI scan as fact. * * Accepts every shape rather than the one this version happens to return, so it is * correct whichever way core behaves and cannot break again on an upgrade. * * @param array $args get_users() arguments; 'fields' is forced. * @return array * @since 1.10.460 */ public static function user_ids( array $args = array() ): array { $args['fields'] = 'ID'; $out = array(); foreach ( (array) get_users( $args ) as $row ) { if ( is_object( $row ) ) { $out[] = (int) ( $row->ID ?? 0 ); } elseif ( is_array( $row ) ) { $out[] = (int) ( $row['ID'] ?? 0 ); } else { $out[] = (int) $row; } } return array_values( array_filter( $out ) ); } /** Administrator user IDs, as ints. @return array */ public static function admin_ids(): array { return self::user_ids( array( 'role' => 'administrator' ) ); } public 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."; } /** * Whether PHP execution in wp-content/uploads is already blocked. * * Read-only, and deliberately so. This used to WRITE a guard block into * wp-content/uploads/.htaccess automatically, once a day, from admin_init. The * WordPress.org review team rejects that: a plugin may not write outside its own * uploads subdirectory, and doing it unprompted is worse than doing it on request. * The exposure is worth reporting, so it is reported, the fix text carries the * exact block to paste, and the site owner decides. * * Apache is inspected directly. On nginx (and any server that ignores .htaccess) * the answer is unknowable from PHP, so it returns null rather than a false * reassurance: "unknown" is a finding the audit can phrase honestly. * * @since 1.10.522 * @return bool|null true = blocked, false = not blocked, null = cannot tell here. */ public static function uploads_php_blocked(): ?bool { $dir = wp_upload_dir(); $base = (string) ( $dir['basedir'] ?? '' ); if ( '' === $base || ! is_dir( $base ) ) { return null; } $sapi = strtolower( (string) php_sapi_name() ); $srv = isset( $_SERVER['SERVER_SOFTWARE'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) ) : ''; $apache = ( strpos( $sapi, 'apache' ) !== false ) || ( strpos( $srv, 'apache' ) !== false ) || ( strpos( $srv, 'litespeed' ) !== false ); if ( ! $apache ) { return null; } $file = trailingslashit( $base ) . '.htaccess'; if ( ! is_file( $file ) || ! is_readable( $file ) ) { return false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $ht = (string) file_get_contents( $file ); return (bool) preg_match( '/(RedirectMatch\s+403[^\n]*php|SetHandler\s+none|php_flag\s+engine\s+off|Require\s+all\s+denied)/i', $ht ); } /** * The .htaccess block that blocks PHP execution in uploads, for the audit's fix * text and the Copy button. Returned as advice, the plugin never writes it. * * @since 1.10.522 * @return string */ public static function uploads_hardening_snippet(): string { return "# Block PHP execution in wp-content/uploads\n" . "RedirectMatch 403 \"(?i)\\.(?:php|phtml|php[0-9]|phar)$\"\n" . "\n" . " \n Require all denied\n \n" . " \n Order allow,deny\n Deny from all\n \n" . " SetHandler none\n" . "\n"; } public 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', array() ); $plugin_updates = get_site_transient( 'update_plugins' ); $plugins_list = array(); foreach ( $all_plugins as $file => $p ) { $has_upd = is_object( $plugin_updates ) && isset( $plugin_updates->response[ $file ] ); $plugins_list[] = array( '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 = array(); $html_fingerprint = array( 'fetched' => false ); $home_resp = wp_remote_get( home_url( '/' ), array( 'timeout' => 5, 'sslverify' => csdt_loopback_ssl_verify(), 'user-agent' => 'Mozilla/5.0 (compatible; CSDT-SecurityScan/1.0)', ) ); if ( ! is_wp_error( $home_resp ) ) { $h = wp_remote_retrieve_headers( $home_resp ); foreach ( array( 'x-frame-options', 'x-content-type-options', 'strict-transport-security', 'content-security-policy', 'content-security-policy-report-only', 'referrer-policy', 'permissions-policy', ) as $hname ) { $sec_headers[ $hname ] = $h[ $hname ] ?? null; } // The response body used to be fetched and then discarded, so // version/plugin disclosure could only be *inferred* from local // filters. Keep a bounded fingerprint of the real HTML instead: // it is the authoritative evidence of what a visitor can see. $html_fingerprint = self::fingerprint_home_html( (string) wp_remote_retrieve_body( $home_resp ) ); } // Content-Security-Policy: an enforced policy and a Report-Only policy // are different things. Report-Only is a deliberate posture on some // sites, so record the mode explicitly rather than letting "no enforced // header" be read as "no policy at all". $csp_state = array( 'enforced_present' => ! empty( $sec_headers['content-security-policy'] ), 'report_only_present' => ! empty( $sec_headers['content-security-policy-report-only'] ), ); $csp_state['mode'] = $csp_state['enforced_present'] ? 'enforced' : ( $csp_state['report_only_present'] ? 'report_only' : 'none' ); // Exposed files and folders come from the background Exposed folders scan // (CSDT_Dir_Probe). It replaced a HEAD of four names here that counted a bare // 200 as exposed, which a site answering 200 to everything always failed. $exposed_folders = CSDT_Dir_Probe::latest(); $exposed = CSDT_Dir_Probe::legacy_facts( $exposed_folders )['exposed_files']; $config_perms = file_exists( CSDT_Paths::wp_config_path() ) ? substr( sprintf( '%o', fileperms( CSDT_Paths::wp_config_path() ) ), -4 ) : 'unknown'; return array( 'wordpress' => array( 'version' => $wp_current, 'latest' => $wp_latest, 'up_to_date' => version_compare( $wp_current, $wp_latest, '>=' ), ), 'php_version' => PHP_VERSION, 'plugins' => $plugins_list, 'plugin_summary' => array( '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' => array( 'admin_login_exists' => $admin_user_exists, 'admin_count' => $user_counts['avail_roles']['administrator'] ?? 0, 'total_users' => $user_counts['total_users'], ), 'configuration' => array( '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' => CSDT_DevTools::file_edit_locked(), '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, 'wp_config_world_readable' => file_exists( CSDT_Paths::wp_config_path() ) && (bool) ( fileperms( CSDT_Paths::wp_config_path() ) & 0004 ), 'uploads_php_blocked' => self::uploads_php_blocked(), ), 'site' => array( 'url' => home_url( '/' ), 'is_https' => is_ssl(), 'login_url_hidden' => CSDT_Options::get( 'csdt_devtools_login_hide_enabled' ) === '1', 'xmlrpc_exists' => CSDT_DevTools::xmlrpc_enabled(), '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' => array( 'brute_force_enabled' => CSDT_Options::get( 'csdt_devtools_brute_force_enabled' ) === '1', 'two_fa_site_method' => CSDT_Options::get( 'csdt_devtools_2fa_method' ), 'two_fa_totp_admins' => ( function () { $admins = self::admin_ids(); $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 = self::admin_ids(); $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( self::admin_ids() ), // Counted from the timestamped log, excluding addresses the auto-blocker would // never block, otherwise the AI is handed the owner's own phone and the CI // runner as evidence of a brute-force attack. See failed_login_signal(). 'failed_logins_1h' => CSDT_Login::failed_login_signal()['h1'], '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 ( self::admin_ids() as $id ) { $pws = WP_Application_Passwords::get_user_application_passwords( (int) $id ); if ( ! empty( $pws ) ) { $admins_with++; $total_app_pw += count( $pws ); } } } return array( 'enabled' => $enabled, 'admins_with_app_pw' => $admins_with, 'total_app_passwords' => $total_app_pw, ); } )(), ), 'exposed_files' => $exposed, 'exposed_folders' => $exposed_folders, 'exposed_archives' => self::probe_exposed_archives(), 'security_headers' => $sec_headers, 'csp_state' => $csp_state, 'home_html' => $html_fingerprint, 'ssh_status' => self::gather_ssh_status(), ); } public static function get_quick_fixes(): array { $app_pw_available = function_exists( 'wp_is_application_passwords_available' ) && wp_is_application_passwords_available(); $fixes = array( array( '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', 'confirm_msg' => 'Sets the default ping status to "closed" so new posts no longer send or accept pingbacks/trackbacks. Existing posts are not modified.', 'undoable' => true, 'undo_msg' => 'Restores the pingback/trackback settings that were in place before the fix was applied.', 'undo_available' => get_option( 'default_ping_status' ) !== 'open', ), array( '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', 'confirm_msg' => 'Unticks "Anyone can register" (Settings → General). Visitors can no longer create accounts on this site. Existing accounts are unaffected.', 'undoable' => true, 'undo_msg' => 'Re-enables open user registration ("Anyone can register" is ticked again).', 'undo_available' => ! (bool) get_option( 'users_can_register', 0 ), ), array( '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.' : ( get_option( 'csdt_devtools_app_pw_2fa_ack', '0' ) === '1' ? 'App passwords are intentionally enabled, 2FA is active and REST API use is authorised.' : 'App passwords allow REST API authentication and can bypass two-factor authentication. Disable unless needed.' ), 'fixed' => CSDT_Options::get( 'csdt_devtools_disable_app_passwords' ) === '1' || ! $app_pw_available || get_option( 'csdt_devtools_test_accounts_enabled', '0' ) === '1' || get_option( 'csdt_devtools_app_pw_2fa_ack', '0' ) === '1', 'fix_label' => 'Disable App Passwords', 'dismiss_label' => 'Using with 2FA', 'dismiss_id' => 'app_pw_2fa_ack', 'confirm_msg' => 'Blocks application-password (Basic Auth / REST API) authentication for all users. Existing app passwords stop working immediately but are not deleted, any mobile app or external integration using them will lose access until you undo.', 'dismiss_confirm' => 'Records that application passwords are intentionally enabled and protected by 2FA. This only hides the warning, no settings are changed.', 'undoable' => true, 'undo_msg' => 'Re-enables application-password authentication (and clears the "Using with 2FA" acknowledgement if set). Previously issued app passwords work again.', 'undo_available' => CSDT_Options::get( 'csdt_devtools_disable_app_passwords' ) === '1' || get_option( 'csdt_devtools_app_pw_2fa_ack', '0' ) === '1', ), array( 'id' => 'enforce_2fa_admins', 'title' => CSDT_Options::get( 'csdt_devtools_2fa_force_admins' ) === '1' ? '2FA enforcement is active, admins must complete a second factor' : 'Admin accounts not required to use two-factor authentication', 'detail' => CSDT_Options::get( 'csdt_devtools_2fa_force_admins' ) === '1' ? 'All administrator accounts are required to set up and use 2FA on every login. Accounts without 2FA configured will be redirected to the enrollment page.' : 'Administrator accounts can log in with a password alone. A single leaked or guessed password gives an attacker full site control. Enabling enforcement redirects admins without 2FA to the enrollment page on their next login.', 'fixed' => CSDT_Options::get( 'csdt_devtools_2fa_force_admins' ) === '1', 'fix_label' => 'Enforce 2FA for Admins', 'confirm_msg' => 'Requires every administrator account to use two-factor authentication. Admins without 2FA configured are redirected to the enrollment page on their next login and cannot use wp-admin until they enroll. Make sure you can complete 2FA enrollment before applying.', 'undoable' => true, 'undo_msg' => 'Removes the enforcement. Admins can log in with a password alone again (existing 2FA setups keep working).', 'undo_available' => CSDT_Options::get( 'csdt_devtools_2fa_force_admins' ) === '1', ), array( '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' => CSDT_Options::get( 'csdt_devtools_hide_wp_version' ) === '1' || has_filter( 'the_generator', '__return_empty_string' ), 'fix_label' => 'Hide WP Version', 'confirm_msg' => 'Removes the WordPress version from the meta tag and strips ?ver= query strings from enqueued scripts and styles. Note: stripping ?ver= can affect CDN cache-busting when you update plugins or themes.', 'undoable' => true, 'undo_msg' => 'Restores the generator tag and asset version query strings.', 'undo_available' => CSDT_Options::get( 'csdt_devtools_hide_wp_version' ) === '1', ), array( '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', 'confirm_msg' => 'Sets the default comment status to "closed", new posts will have comments off. Existing posts keep their current comment settings.', 'undoable' => true, 'undo_msg' => 'Restores the default comment status that was in place before the fix was applied.', 'undo_available' => get_option( 'default_comment_status' ) !== 'open', ), array( 'id' => 'wpconfig_perms', 'title' => 'wp-config.php is writable by the web server process', 'detail' => 'wp-config.php should be read-only (0400 or 0440) so no PHP process running as the web server user can overwrite database credentials or secret keys.', 'fixed' => ( function () { $f = CSDT_Paths::wp_config_path(); if ( ! file_exists( $f ) ) { return true; } $perms = substr( sprintf( '%o', fileperms( $f ) ), -4 ); return in_array( $perms, array( '0400', '0440', '0600', '0640' ), true ); } )(), 'fix_label' => 'Show fix commands', 'fix_recipe' => 'wpconfig_perms', ), array( '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( csdt_devtools_wp_debug_log_path() ), 'fix_label' => 'Show fix commands', 'fix_recipe' => 'debug_log_exposed', ), // Advisory only, deliberately no fix, automated or scripted. // // Renaming the table prefix is obscurity, not a control: it raises the // bar slightly for blind SQL injection with hardcoded table names and // does nothing else. Set against that, the migration has to rename // live tables, rewrite prefixed option and usermeta keys, and edit // wp-config.php, and any plugin holding its own tables keeps the old // prefix and immediately breaks. Get it wrong and the site is down // *and* the admin cannot log in to fix it, because admin access is // behind a hidden slug and forced 2FA. // // We used to automate this. It also carried a real data-loss bug: the // option rewrite matched every option_name beginning "wp_", so it // renamed unrelated core options (wp_page_for_privacy_policy, // wp_force_deactivated_plugins) and popular plugin options such as // wp_mail_smtp, only wp_user_roles is genuinely prefixed. Withdrawn // rather than fixed: the reward never justified the blast radius. array( 'id' => 'db_prefix_default', 'title' => 'Default database table prefix (wp_)', 'detail' => 'The default wp_ prefix is a mild obscurity measure, it slows down automated SQL injection that hardcodes table names. CloudScale deliberately does not change it for you: the migration has to rename live tables and edit wp-config.php, any plugin with its own tables breaks, and a failed migration locks you out of the site you would need to fix it. If you want it changed, do it during planned downtime with a tested database backup and a staging rehearsal.', 'fixed' => ( function () { global $wpdb; return $wpdb->prefix !== 'wp_'; } )(), 'advisory' => true, 'fix_label' => '', ), // Repair for damage the retired migration did on sites that ran it. // Only appears when there is something to repair, and it is a pure // wp_options rename, no filesystem, fully reversible, so unlike the // migration itself this is safe to keep as one click. ( function () { $orphans = self::db_prefix_orphaned_options(); $count = count( $orphans ); if ( 0 === $count ) { return null; } $safe = array_filter( $orphans, static fn( $o ) => ! $o['collision'] ); $blocked = $count - count( $safe ); $sample = array_slice( array_column( $orphans, 'from' ), 0, 3 ); $blocked_n = $blocked > 0 ? sprintf( ' %d cannot be restored automatically because the original name exists again, those are listed for manual review rather than overwritten.', $blocked ) : ''; return array( 'id' => 'db_prefix_orphaned_options', 'title' => sprintf( '%d option%s orphaned by an earlier table-prefix change', $count, 1 === $count ? '' : 's' ), 'detail' => sprintf( 'These options were renamed by the table-prefix migration even though they were never prefixed WordPress options, so whatever set them can no longer find them (e.g. %s). Only wp_user_roles should have been renamed.%s', implode( ', ', $sample ), $blocked_n ), 'fixed' => false, 'fix_label' => sprintf( 'Restore %d option name%s', count( $safe ), 1 === count( $safe ) ? '' : 's' ), 'confirm_msg' => 'Renames these option rows back to their original names so the plugins and core settings that own them can read them again. Only rows whose original name is currently free are touched; nothing is overwritten. Values are not modified.', 'undoable' => false, 'undo_msg' => 'No undo: this restores the original option names, which is the state everything else already expects.', ); } )(), array( 'id' => 'csp_unsafe_inline', 'title' => "CSP allows 'unsafe-inline' for scripts, weakens XSS protection", 'detail' => get_option( 'csdt_csp_inline_ack', '0' ) === '1' ? "Acknowledged, 'unsafe-inline' in script-src is managed externally (nginx, Cloudflare, or CDN)." : ( CSDT_Options::get( 'csdt_csp_nonces_enabled' ) === '1' ? "Nonce-based CSP is active. Scripts are protected with per-request nonces; 'unsafe-inline' has been removed from script-src." : "Clicking 'Enable Nonce CSP' will start in Report-Only mode, nothing is blocked yet. Browse your site and check the CSP Violation Log before switching to Enforce. ⚠️ Never go straight to Enforce, some plugins use eval() (underscore.min.js templates) and will break." ), 'fixed' => get_option( 'csdt_csp_inline_ack', '0' ) === '1' || ( CSDT_Options::get( 'csdt_csp_nonces_enabled' ) === '1' && CSDT_Options::get( 'csdt_devtools_csp_enabled' ) === '1' ), 'fix_label' => 'Enable Nonce CSP', 'dismiss_label' => 'Managed Externally', 'dismiss_id' => 'csp_inline_ack', 'confirm_msg' => 'Enables nonce-based CSP in Report-Only mode. Nothing is blocked yet, responses gain a Content-Security-Policy-Report-Only header and violations are logged to the CSP Violation Log. Review the log before ever switching to Enforce.', 'dismiss_confirm' => 'Records that the CSP unsafe-inline directive is managed outside WordPress (nginx, Cloudflare, or CDN). This only hides the warning, no headers are changed.', 'undoable' => true, 'undo_msg' => 'Restores the CSP settings that were in place before the fix (nonce injection off, and CSP disabled again if the fix enabled it). Clears the "Managed Externally" acknowledgement if set.', 'undo_available' => CSDT_Options::get( 'csdt_csp_nonces_enabled' ) === '1' || get_option( 'csdt_csp_inline_ack', '0' ) === '1', ), ( function () { $cron_disabled = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON; $next_delete = wp_next_scheduled( 'wp_scheduled_delete' ); $next_transient = wp_next_scheduled( 'delete_expired_transients' ); $now = time(); $overdue_secs = 6 * HOUR_IN_SECONDS; $missing = ! $next_delete || ! $next_transient; $overdue = ( $next_delete && $next_delete < $now - $overdue_secs ) || ( $next_transient && $next_transient < $now - $overdue_secs ); // Healthy = events scheduled and not overdue. // DISABLE_WP_CRON is handled by its own separate quick-fix item; // don't double-report it here. If events are scheduled, they'll fire // when a system cron triggers wp-cron.php, that's the correct setup. $healthy = ! $missing && ! $overdue; if ( $healthy && $cron_disabled ) { // No URL is printed. The command used to carry the site's wp-cron.php address, // which is one more place a subdirectory install could be shown the wrong path. // WP-CLI finds the install itself and runs only what is due. $detail = 'Cleanup events are scheduled. DISABLE_WP_CRON is set, so make sure a system cron runs WordPress cron on a schedule (e.g. */10 * * * * wp cron event run --due-now, from the WordPress directory).'; } elseif ( $healthy ) { $next_label = 'next in ' . human_time_diff( $now, min( $next_delete ?: PHP_INT_MAX, $next_transient ?: PHP_INT_MAX ) ); $detail = "WP-Cron cleanup events are scheduled and running on time ({$next_label})."; } elseif ( $missing ) { $detail = 'One or more core cleanup cron events (wp_scheduled_delete, delete_expired_transients) are missing from the schedule. They will be rescheduled on fix.'; } else { $hours = round( ( $now - min( $next_delete ?: $now, $next_transient ?: $now ) ) / HOUR_IN_SECONDS ); $detail = "Core cleanup cron events are {$hours}h overdue, WP-Cron may not be firing. Click fix to reschedule and run them now."; } return array( 'id' => 'cron_health', 'title' => $healthy ? 'WP-Cron cleanup events are scheduled' : 'WP-Cron cleanup events are missing or overdue', 'detail' => $detail, 'fixed' => $healthy, 'fix_label' => 'Reschedule & Run Now', 'confirm_msg' => 'Re-schedules the missing WordPress core cleanup events (wp_scheduled_delete, delete_expired_transients) and runs them once immediately. The immediate run purges already-expired transients and old trashed items, data WordPress deletes routinely anyway.', 'undoable' => false, 'undo_msg' => 'No undo needed, rescheduling core cleanup events is idempotent and only already-expired data is purged.', ); } )(), ( function () { global $wpdb; $count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE option_name LIKE %s AND CAST( option_value AS UNSIGNED ) < UNIX_TIMESTAMP()', $wpdb->options, $wpdb->esc_like( '_transient_timeout_' ) . '%' ) ); return array( 'id' => 'expired_transients', 'title' => $count > 0 ? sprintf( '%d expired transient%s bloating wp_options', $count, $count === 1 ? '' : 's' ) : 'No expired transients, wp_options is clean', 'detail' => $count > 0 ? sprintf( '%d expired transient record%s remain in wp_options. WordPress normally auto-purges these via cron, but missed cron runs let them accumulate and slow down any query that scans the options table.', $count, $count === 1 ? '' : 's' ) : 'All transients have been cleaned up.', 'fixed' => $count === 0, 'fix_label' => 'Delete Expired Transients', 'confirm_msg' => 'Permanently deletes all expired transient rows (and their orphaned value rows) from the wp_options table. Transients are temporary caches, WordPress regenerates them on demand.', 'undoable' => false, 'undo_msg' => 'Cannot be undone, the deleted rows were expired cache data that WordPress rebuilds automatically.', ); } )(), ( function () { // ── Detection strategy ───────────────────────────────────────── // Three possible states: RUNNING (proved), NOT_INSTALLED (proved), UNDETECTABLE. // "Undetectable" happens when PHP runs inside a container with no access to host // binaries, PID files, or log files, we must not lie and say "not installed". // // Detection order (most reliable first): // 1. /var/log/fail2ban.log readable → definitive (only exists if f2b installed) // 2. Binary present in known paths → installed; PID/socket → running // 3. Shelling out to look for the fail2ban client and ask systemd whether // the service is active. Listed for completeness only: this plugin never // runs a shell command, so methods 1 and 2 are the ones actually implemented. // 4. All methods exhausted + /.dockerenv present → UNDETECTABLE $state = 'unknown'; // 'running' | 'installed_stopped' | 'not_installed' | 'undetectable' // Method 1: log file (most reliable in containerised environments if mounted) $f2b_log = '/var/log/fail2ban.log'; if ( is_readable( $f2b_log ) ) { $state = 'installed_stopped'; // file exists = installed; assume stopped until proved $mtime = file_exists( $f2b_log ) ? filemtime( $f2b_log ) : 0; if ( $mtime && ( time() - $mtime ) < 7200 ) { // File touched in last 2 h, read tail to find last lifecycle event $tail = ''; $fh = is_readable( $f2b_log ) ? fopen( $f2b_log, 'r' ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- reading local OS log file; WP Filesystem API not available in non-admin context if ( $fh ) { $sz = (int) filesize( $f2b_log ); if ( $sz > 2048 ) { fseek( $fh, -2048, SEEK_END ); } $tail = (string) fread( $fh, 2048 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- WP_Filesystem has no equivalent; used in server-management Quick Fix features (admin-only, nonce-protected) fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem has no equivalent; used in server-management Quick Fix features (admin-only, nonce-protected) } preg_match_all( '/(?:started successfully|Stopping all jails)/i', $tail, $m ); $last = ! empty( $m[0] ) ? strtolower( end( $m[0] ) ) : ''; if ( $last !== 'stopping all jails' ) { $state = 'running'; } } else { // Log not touched in 2 h, fail2ban may have been stopped; treat as installed_stopped $state = 'installed_stopped'; } } // Method 2 + 3: binary / PID (works on bare metal or with docker-in-docker) if ( $state === 'unknown' ) { $installed = false; foreach ( array( '/usr/bin/fail2ban-client', '/usr/sbin/fail2ban-client', '/usr/local/bin/fail2ban-client', '/usr/local/sbin/fail2ban-client', '/opt/fail2ban/bin/fail2ban-client', ) as $p ) { if ( file_exists( $p ) ) { $installed = true; break; } } if ( $installed ) { $running = false; foreach ( array( '/var/run/fail2ban/fail2ban.pid', '/run/fail2ban/fail2ban.pid', '/var/run/fail2ban/fail2ban.sock', '/run/fail2ban/fail2ban.sock', ) as $p ) { if ( file_exists( $p ) ) { $running = true; break; } } $state = $running ? 'running' : 'installed_stopped'; } else { // Nothing found, check if we're in a container before declaring "not installed" $in_container = file_exists( '/.dockerenv' ) || ( is_readable( '/proc/1/cgroup' ) && str_contains( (string) file_get_contents( '/proc/1/cgroup' ), 'docker' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $state = $in_container ? 'undetectable' : 'not_installed'; } } $last_check = get_option( 'csdt_ssh_monitor_last_check', null ); $recent = ( is_array( $last_check ) && isset( $last_check['count'] ) ) ? (int) $last_check['count'] : null; $age = ( is_array( $last_check ) && isset( $last_check['ts'] ) ) ? (int) ( time() - $last_check['ts'] ) : null; $count_note = ( $recent !== null && $age !== null && $age < 180 ) ? sprintf( ', %d failed attempt%s in the last 60 s', $recent, $recent === 1 ? '' : 's' ) : ''; $detail_map = array( 'running' => 'fail2ban is installed and the daemon is running, SSH brute-force attempts are being blocked automatically.', 'installed_stopped' => 'fail2ban is installed but the daemon is not running. Start it: sudo systemctl start fail2ban && sudo systemctl enable fail2ban', 'not_installed' => 'CRITICAL: fail2ban is not installed. Unprotected SSH is scanned 24/7, attackers attempt thousands of passwords per minute and compromised servers are immediately enlisted into DDoS botnets.', 'undetectable' => 'Cannot verify from inside this container, host services are not visible to PHP. Click "Enable Detection" for the one-line docker-compose fix.', ); return array( 'id' => 'ssh_brute_force', 'title' => 'SSH brute-force protection' . $count_note, 'detail' => $detail_map[ $state ] ?? $detail_map['not_installed'], 'fixed' => ( $state === 'running' ), 'fix_label' => ( $state === 'undetectable' ) ? 'Enable Detection' : 'Show fix commands', 'fix_modal' => ( $state === 'undetectable' ) ? 'csdt-fail2ban-docker-modal' : '', 'fix_recipe' => ( $state === 'undetectable' ) ? '' : 'fail2ban', ); } )(), ( function () { $cfg_file = CSDT_Paths::wp_config_path(); // Check runtime constant first, covers mu-plugin or non-standard define formats $fixed = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON; if ( ! $fixed && is_readable( $cfg_file ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $cfg = file_get_contents( $cfg_file ); $fixed = (bool) preg_match( "/define\s*\(\s*['\"]DISABLE_WP_CRON['\"]\s*,\s*(?:true|1)\s*\)\s*;/i", $cfg ); } return array( 'id' => 'disable_wp_cron', 'title' => $fixed ? 'DISABLE_WP_CRON is set, confirm the web server also refuses external requests' : 'wp-cron.php can be triggered by anyone, denial-of-service risk', // Deliberately does NOT claim DISABLE_WP_CRON closes this. It does // not: wp-cron.php never checks the constant, so a direct request // still runs every due event. The constant only stops WordPress // spawning cron for ordinary visitors. Blocking the abuse needs the // web server to refuse the path, which the recipe covers. 'detail' => $fixed ? "define('DISABLE_WP_CRON', true) is present, so visitor page loads no longer trigger cron. Note that this alone does not stop an attacker requesting wp-cron.php directly, that needs a deny rule in your web server. Also make sure a system cron is firing events every 5 to 15 minutes." : 'Any HTTP request to wp-cron.php makes WordPress run all due scheduled tasks, so repeated requests are a cheap way to burn CPU. The full fix is three parts: hand scheduling to a system cron, set DISABLE_WP_CRON, and have the web server refuse external requests for the file.', 'fixed' => $fixed, 'fix_label' => 'Show fix commands', 'fix_recipe' => 'wp_cron_hardening', ); } )(), ( function () { $file_mods_disabled = defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS; // Cross-check the file directly, OPcache may still have the old version // even after the constant was removed from wp-config.php. if ( $file_mods_disabled ) { $cfg_raw = self::fs_read( CSDT_Paths::wp_config_path() ); if ( $cfg_raw !== false && ! preg_match( "/define\s*\(\s*['\"]DISALLOW_FILE_MODS['\"]\s*,\s*true\s*\)/i", $cfg_raw ) ) { $file_mods_disabled = false; // removed from file; constant only persists in this process } } $updater_disabled = ( defined( 'AUTOMATIC_UPDATER_DISABLED' ) && AUTOMATIC_UPDATER_DISABLED ) || ( defined( 'WP_AUTO_UPDATE_CORE' ) && WP_AUTO_UPDATE_CORE === false ); $disabled = $file_mods_disabled || $updater_disabled; if ( $file_mods_disabled ) { $detail = 'DISALLOW_FILE_MODS is set to true in wp-config.php. This blocks WordPress auto-updates, prevents security patches from installing, and removes the Delete button from plugins. Fix removes only this constant; DISALLOW_FILE_EDIT (blocks the in-admin code editor) is left intact.'; } elseif ( $updater_disabled ) { $detail = 'AUTOMATIC_UPDATER_DISABLED or WP_AUTO_UPDATE_CORE=false is set in wp-config.php. WordPress will not auto-install security patches.'; } else { $detail = 'WordPress automatic updates are enabled and will install minor security releases automatically.'; } $cfg_writable = is_writable( CSDT_Paths::wp_config_path() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- WP_Filesystem has no equivalent; used in server-management Quick Fix features (admin-only, nonce-protected) return array( 'id' => 'enable_auto_updates', 'title' => $disabled ? 'Automatic updates are disabled, security patches will not auto-install' : 'Automatic updates are enabled', 'detail' => $detail, 'fixed' => ! $disabled, 'fix_label' => 'Enable Auto Updates', 'risk' => 'moderate', 'confirm_msg' => $cfg_writable ? 'Removes the update-blocking constants (DISALLOW_FILE_MODS, AUTOMATIC_UPDATER_DISABLED, WP_AUTO_UPDATE_CORE=false) from wp-config.php. WordPress, plugins, and themes can auto-update again and the plugin Delete button is restored. The removed lines are saved so this can be undone.' : 'wp-config.php is not writable (permissions may be 0400), so nothing can be changed automatically. Applying will show the exact lines to remove manually via SSH.', 'undoable' => true, 'undo_msg' => 'Re-adds the exact constant definitions this fix removed from wp-config.php, disabling automatic updates again.', 'undo_available' => null !== self::qf_snapshot_get( 'enable_auto_updates' ), ); } )(), ( function () { $enabled = CSDT_Options::get( 'csdt_autoblock_enabled' ) === '1'; $ip_ok = ! CSDT_DevTools::ip_detection_is_broken(); $thr = (int) CSDT_Options::get( 'csdt_autoblock_threshold' ); return array( 'id' => 'enable_autoblock', 'title' => $enabled ? 'Automatic IP blocking is enabled' : 'Automatic IP blocking is disabled, attackers get unlimited attempts', 'detail' => $enabled ? ( $ip_ok ? "Addresses crossing {$thr} hostile events per hour are blocked automatically, with escalating expiry and never-block protection for your own IP, live admin sessions, proxy ranges, and your allowlist." : 'Enabled, but visitor IP detection is not resolving public addresses, so no block can currently fire. Fix Login Security → Visitor IP Source.' ) : 'Per-account lockout protects one account but does nothing to the attacker, who moves to the next username and keeps consuming CPU, database, and PHP workers. Blocking turns them away entirely.' . ( $ip_ok ? '' : ' Visitor IP detection must be corrected first, or enabling this will have no effect.' ), 'fixed' => $enabled && $ip_ok, 'fix_label' => 'Enable Auto-Blocking', 'risk' => 'moderate', 'confirm_msg' => $ip_ok ? "Blocks any address that produces {$thr} or more hostile events (failed logins, failed XML-RPC logins, hidden-login probes) within an hour. Blocks expire automatically: 1 hour, then 24 hours, then 7 days for repeat offenders. Your own IP, any address with a live administrator session, proxy ranges, and your allowlist are never blocked. If you use a shared office or VPN egress address, add it to the allowlist first in Login Security → Automatic IP Blocking." : 'Visitor IP detection is not resolving public addresses, so every event is attributed to your proxy and nothing would ever be blocked. Fix Login Security → Visitor IP Source first, then apply this.', 'undoable' => true, 'undo_msg' => 'Turns automatic IP blocking back off. Existing blocks are left in place and will still expire on their own.', 'undo_available' => $enabled, ); } )(), ( function () { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); $active_plugins = (array) get_option( 'active_plugins', array() ); $inactive = array_filter( $all_plugins, fn( $data, $k ) => ! in_array( $k, $active_plugins, true ), ARRAY_FILTER_USE_BOTH ); $count = count( $inactive ); $names = implode( ', ', array_map( fn( $d ) => $d['Name'], array_slice( $inactive, 0, 3 ) ) ); if ( $count > 3 ) { $names .= ' and ' . ( $count - 3 ) . ' more'; } return array( 'id' => 'delete_inactive_plugins', 'title' => $count > 0 ? sprintf( '%d inactive plugin%s sitting on disk unpatched', $count, $count === 1 ? '' : 's' ) : 'No inactive plugins, disk is clean', 'detail' => $count > 0 ? sprintf( '%d deactivated plugin%s (%s) remain on disk. Inactive plugins receive no security attention and can be exploited via path traversal or known CVEs even when not running. Remove them yourself from Plugins → Installed Plugins if you no longer need them.', $count, $count === 1 ? '' : 's', $names ) : 'All installed plugins are active. No unpatched plugin files sitting on disk.', 'fixed' => $count === 0, // No longer deletes anything: WordPress.org's review guidelines say a // plugin may not change another plugin's installed/activation status // (WPORG_COMMON_38), and this AUTO fix used to remove the inactive list // from disk directly. It now only names what it found; removal is the // admin's own action from the core Plugins screen, which this points at. 'fix_label' => $count > 0 ? "List {$count} Inactive Plugin" . ( $count === 1 ? '' : 's' ) : 'Nothing to list', 'risk' => 'safe', 'confirm_msg' => $count > 0 ? "Lists all {$count} inactive plugin(s) with name and version, for you to review and remove yourself from Plugins → Installed Plugins: {$names}. Nothing is deleted by this." : 'No inactive plugins found.', 'undoable' => false, 'undo_msg' => '', ); } )(), ( function () { // Detect orphaned cron hooks, events registered in the schedule but with no // PHP handler. These are left behind by deleted/deactivated plugins and fire // periodically doing nothing, wasting a cron slot and slowing WP-Cron runs. $core_hooks = array( 'wp_scheduled_delete', 'delete_expired_transients', 'wp_version_check', 'wp_update_plugins', 'wp_update_themes', 'wp_scheduled_auto_draft_delete', 'recovery_mode_clean_expired_keys', 'wp_privacy_delete_old_export_files', 'wp_site_health_scheduled_check', 'wp_update_user_counts', 'wp_https_detection', 'wp_privacy_delete_old_export_files', ); $orphaned = array(); $crons = _get_cron_array(); if ( is_array( $crons ) ) { foreach ( $crons as $hooks ) { foreach ( array_keys( $hooks ) as $hook ) { if ( in_array( $hook, $core_hooks, true ) ) { continue; } if ( ! has_action( $hook ) ) { $orphaned[] = $hook; } } } } $orphaned = array_values( array_unique( $orphaned ) ); $count = count( $orphaned ); $preview = implode( ', ', array_slice( $orphaned, 0, 4 ) ); if ( $count > 4 ) { $preview .= ' and ' . ( $count - 4 ) . ' more'; } return array( 'id' => 'orphaned_cron_hooks', 'title' => $count > 0 ? sprintf( '%d orphaned cron hook%s from deleted plugins', $count, $count === 1 ? '' : 's' ) : 'No orphaned cron hooks, schedule is clean', 'detail' => $count > 0 ? sprintf( '%d scheduled cron hook%s %s a registered PHP handler. These were added by plugins that have since been deleted or deactivated but whose scheduled events were never cleaned up. They fire on schedule, do nothing, and accumulate latency on every WP-Cron run. Hooks: %s.', $count, $count === 1 ? '' : 's', $count === 1 ? 'has no' : 'have no', $preview ) : 'All scheduled cron events have registered PHP handlers. No leftover events from deleted plugins.', 'fixed' => $count === 0, 'fix_label' => $count > 0 ? 'Remove Orphaned Hooks' : 'Nothing to remove', 'risk' => $count > 0 ? 'low' : null, 'confirm_msg' => $count > 0 ? "Unschedules the {$count} cron event(s) with no registered PHP handler ({$preview}). A snapshot of the removed events (hook, next-run time, recurrence) is saved first, so this can be undone." : 'No orphaned hooks found, nothing will be removed.', 'undoable' => true, 'undo_msg' => 'Re-schedules the exact cron events that were removed, using the saved snapshot.', 'undo_available' => null !== self::qf_snapshot_get( 'orphaned_cron_hooks' ), ); } )(), ( function () { $already_fixed = get_option( 'csdt_ads_dedup', '0' ) === '1'; if ( $already_fixed ) { return array( 'id' => 'ads_dedup', 'title' => 'AdSense duplicate push guard active', 'detail' => 'A deduplication patch is injected before AdSense loads, preventing the "Only one enable_page_level_ads allowed per page" TagError.', 'fixed' => true, 'fix_label' => 'Already fixed', 'undoable' => true, 'undo_msg' => 'Removes the deduplication patch. If two plugins still push enable_page_level_ads, the TagError returns.', 'undo_available' => true, ); } // Detect duplicate enable_page_level_ads in the home page HTML. // The actual fetch runs in the background (csdt_refresh_home_html cron) to avoid // blocking the admin page render; safe default (0) is shown until the cron runs. $cache_key = 'csdt_home_html_ads_check'; $count = get_transient( $cache_key ); if ( false === $count ) { if ( ! wp_next_scheduled( 'csdt_refresh_home_html' ) ) { wp_schedule_single_event( time(), 'csdt_refresh_home_html' ); } $count = 0; } $detected = (int) $count > 1; return array( 'id' => 'ads_dedup', 'title' => $detected ? 'Duplicate AdSense push detected, "enable_page_level_ads" called ' . $count . ' times' : 'No duplicate AdSense push detected', 'detail' => $detected ? 'Multiple calls to adsbygoogle.push({ enable_page_level_ads: true }) cause a TagError in the browser console and may prevent ads from loading. Usually caused by two plugins or widgets both initialising Auto Ads.' : 'Only one enable_page_level_ads call found on the home page.', 'fixed' => ! $detected, 'fix_label' => 'Fix Duplicate Push', 'risk' => $detected ? 'medium' : null, 'confirm_msg' => 'Injects a small JS guard before AdSense loads that silently drops duplicate enable_page_level_ads push calls. No content or ad settings are changed.', 'undoable' => true, 'undo_msg' => 'Removes the deduplication guard script.', ); } )(), ( function () { $cache_key = 'csdt_crossorigin_check'; $missing = get_transient( $cache_key ); if ( false === $missing ) { if ( ! wp_next_scheduled( 'csdt_refresh_home_html' ) ) { wp_schedule_single_event( time(), 'csdt_refresh_home_html' ); } $missing = array(); } $count = count( (array) $missing ); return array( 'id' => 'crossorigin_scripts', 'title' => $count > 0 ? $count . ' third-party script' . ( $count === 1 ? '' : 's' ) . ' missing crossorigin="anonymous"' : 'All third-party scripts have crossorigin attribute', 'detail' => $count > 0 ? 'Without crossorigin="anonymous", JS errors from these scripts show as opaque "Script error." with no stack trace. The CDN must also send Access-Control-Allow-Origin: * for this to work. Scripts: ' . implode( ', ', array_slice( (array) $missing, 0, 3 ) ) . ( $count > 3 ? ' …' : '' ) : 'No third-party scripts are missing crossorigin attributes on the home page.', 'fixed' => $count === 0, 'fix_label' => 'Add crossorigin via wp_script_attributes', 'risk' => $count > 0 ? 'low' : null, 'confirm_msg' => 'Adds crossorigin="anonymous" to all enqueued third-party script tags via the script_loader_tag filter. If a CDN does not send Access-Control-Allow-Origin headers, its script will stop executing, undo this fix if anything breaks.', 'undoable' => true, 'undo_msg' => 'Stops adding the crossorigin attribute to third-party scripts.', 'undo_available' => CSDT_Options::get( 'csdt_crossorigin_scripts' ) === '1', ); } )(), ( function () { // Open to a comment flood: the site takes comments, and nothing in this plugin // limits the total from many addresses at once. A Cloudflare rule is not // counted: most sites have none, and it is per address like core's own check. $takes = 'open' === get_option( 'default_comment_status' ) || ! empty( get_posts( array( 'post_type' => 'any', 'post_status' => 'publish', 'comment_status' => 'open', 'numberposts' => 1, 'fields' => 'ids', ) ) ); $limited = CSDT_Comment_Flood::limits_floods(); $gap = CSDT_Comment_Flood::min_gap(); $fix_gap = (int) CSDT_Comment_Flood::FIX_GAP; if ( ! $takes ) { $title = 'Comments are closed, so there is no comment form to flood'; $detail = 'New posts have comments closed and no published post accepts them.'; } elseif ( $limited ) { $title = 'Comment floods are rate limited site-wide'; $detail = $gap > 0 ? "At most one comment is accepted every {$gap} seconds across the whole site, whatever number of addresses are sending them. The rest are refused with HTTP 429." : 'The site-wide comment ceiling is in Enforce: while the ten minute total is over the ceiling, new comments are refused with HTTP 429.'; } else { $title = 'Comments have no site-wide rate limit, the site is open to a comment flood'; $detail = 'WordPress only makes one address wait 15 seconds between comments. A flood spread across many addresses passes that, and every submission costs a PHP request and database writes. The site-wide ceiling is counting but refuses nothing in its current mode.'; } return array( 'id' => 'limit_comment_rate', 'title' => $title, 'detail' => $detail, 'fixed' => ! $takes || $limited, 'fix_label' => 'Limit Comment Rate', 'risk' => ( $takes && ! $limited ) ? 'low' : null, 'confirm_msg' => "Accepts at most one comment every {$fix_gap} seconds across the whole site and refuses the rest with HTTP 429 and a short \"wait a few seconds\" page. Two real readers who press Send within {$fix_gap} seconds of each other means the second has to send again. Users who can moderate comments are never refused. Change the number under Firewall, Comment Flood Protection.", 'undoable' => true, 'undo_msg' => 'Restores the gap between comments that was set before the fix was applied.', 'undo_available' => null !== self::qf_snapshot_get( 'limit_comment_rate' ), ); } )(), ( function () { $fixed = '1' === CSDT_Options::get( 'csdt_disable_xmlrpc' ); return array( 'id' => 'disable_xmlrpc', 'title' => $fixed ? 'XML-RPC is disabled, remote exploit surface eliminated' : 'XML-RPC is enabled, brute-force and DDoS amplification risk', 'detail' => $fixed ? 'The plugin returns false for xmlrpc_enabled on every request, so the endpoint is inaccessible. REST API is unaffected.' : 'XML-RPC (xmlrpc.php) supports remote procedure calls and has been exploited in credential-stuffing amplification attacks. Unless you use the Jetpack mobile app or a remote blogging client, disable it.', 'fixed' => $fixed, 'fix_label' => 'Disable XML-RPC', 'confirm_msg' => 'Blocks the xmlrpc.php endpoint by returning false from the xmlrpc_enabled filter on every request. This breaks the Jetpack / WordPress mobile apps and legacy remote-publishing clients if you use them. The REST API (used by the block editor and most modern integrations) is unaffected.', 'undoable' => true, 'undo_msg' => 'Removes the block, xmlrpc.php accepts requests again (restoring the brute-force / amplification exposure this fix removed).', 'undo_available' => $fixed, ); } )(), ( function () { $fixed = CSDT_DevTools::file_edit_locked(); return array( 'id' => 'disable_file_edit', 'title' => $fixed ? 'Theme/plugin file editor is locked' : 'Theme and plugin file editor is accessible from wp-admin', 'detail' => $fixed ? ( defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT ? 'DISALLOW_FILE_EDIT is set in wp-config.php. The Appearance → Theme File Editor and Plugins → Plugin File Editor menus are hidden.' : 'Locked by this plugin, which denies the file-editor capabilities the same way DISALLOW_FILE_EDIT does. The Theme File Editor and Plugin File Editor menus are hidden. Setting the constant in wp-config.php is stronger: it survives this plugin being deactivated.' ) : 'The built-in code editor lets any administrator modify PHP files from the browser. A compromised admin account or XSS attack could execute arbitrary code. Disabling it forces file changes via SSH, leaving an audit trail.', 'fixed' => $fixed, 'fix_label' => 'Show fix commands', 'fix_recipe' => 'disallow_file_edit', ); } )(), ( function () { $fixed = ( defined( 'WP_POST_REVISIONS' ) && WP_POST_REVISIONS !== true && (int) WP_POST_REVISIONS <= 10 ) || '1' === get_option( 'csdt_limit_revisions', '0' ); global $wpdb; $rev_count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE post_type = 'revision'", $wpdb->posts ) ); return array( 'id' => 'limit_revisions', 'title' => $fixed ? 'Post revision limit is set, wp_posts stays lean' : sprintf( 'Post revisions are unlimited, %s revision rows in database', number_format( $rev_count ) ), 'detail' => $fixed ? 'WP_POST_REVISIONS is set. WordPress keeps at most 5 revisions per post and prunes older ones automatically.' : sprintf( 'WordPress stores unlimited revisions by default. With %s revision rows, every edit adds another row. Setting WP_POST_REVISIONS to 5 keeps the table manageable.', number_format( $rev_count ) ), 'fixed' => $fixed, 'fix_label' => 'Limit to 5 Revisions', 'confirm_msg' => 'Caps stored revisions at 5 per post, applied at runtime through the wp_revisions_to_keep filter. This plugin does not edit wp-config.php. WordPress prunes revisions beyond the cap the next time each post is saved, pruned revisions are deleted permanently at that point.', 'undoable' => true, 'undo_msg' => 'Removes the 5-revision cap this fix applied. Revisions already pruned by WordPress cannot be restored.', 'undo_available' => '1' === get_option( 'csdt_limit_revisions', '0' ) || null !== self::qf_snapshot_get( 'limit_revisions' ), ); } )(), ( function () { $fixed = '1' === CSDT_Options::get( 'csdt_block_user_enum' ); // Blocking ?author=N was only half the job. RSS/RSS2/RDF print the // author's display name into and Atom into , and // WordPress defaults display_name to user_login, so a feed hands out // a confirmed valid username. Only report it when it is actually true // of this site's authors, rather than warning generically. $exposed = class_exists( 'CSDT_DevTools' ) ? CSDT_DevTools::authors_disclosing_logins() : array(); $n = count( $exposed ); $names = implode( ', ', array_slice( array_values( $exposed ), 0, 3 ) ); if ( $fixed ) { $detail = 'Requests to ?author= are intercepted on template_redirect, and feed author fields ( in RSS/RDF, in Atom) are masked whenever the display name is the account login, so neither route discloses a username.'; if ( $n > 0 ) { $detail .= sprintf( ' %d account%s still use%s its login as a display name (%s), masking protects the feed, but setting a proper display name in Users → Profile removes the exposure at source.', $n, 1 === $n ? '' : 's', 1 === $n ? 's' : '', $names ); } } else { $detail = 'Visiting /?author=1 redirects to /author/username, revealing usernames to anyone scanning your site.'; if ( $n > 0 ) { $detail .= sprintf( ' Your feeds are also disclosing %d valid username%s (%s) in the author field, /?feed=rss2, /?feed=atom and /?feed=rdf all publish it, because %s display name is the same as %s login. That is the harder half of a brute-force already done for the attacker.', $n, 1 === $n ? '' : 's', $names, 1 === $n ? 'that account\'s' : 'those accounts\'', 1 === $n ? 'its' : 'their' ); } } return array( 'id' => 'disable_user_enumeration', 'title' => $fixed ? 'Author enumeration is blocked, usernames protected' : ( $n > 0 ? sprintf( 'Author enumeration is open, %d username%s exposed via ?author=N and your feeds', $n, 1 === $n ? '' : 's' ) : 'Author enumeration is open, usernames exposed via ?author=N' ), 'detail' => $detail, 'fixed' => $fixed, 'fix_label' => 'Block Author Enumeration', 'confirm_msg' => 'Two changes: /?author=N requests are 301-redirected to the home page, and feed author fields are replaced with your site name whenever the author\'s display name is their actual login. Normal /author/name/ archive pages keep working, and feeds where the display name is a real name are left untouched.', 'undoable' => true, 'undo_msg' => 'Removes both, /?author=N resolves to author archives and feeds publish the author login again.', 'undo_available' => $fixed, ); } )(), ( function () { // The root cause behind the feed disclosure above, reported separately // because it is NOT fixed by any toggle. Masking feed output hides one // symptom; the author archive slug still spells the login out, and so // does any theme byline. The record itself has to change. $exposed = class_exists( 'CSDT_DevTools' ) ? CSDT_DevTools::users_with_login_in_public_names() : array(); if ( empty( $exposed ) ) { return array( 'id' => 'login_in_public_names', 'title' => 'No account publishes its login as a public name', 'detail' => 'Every publishing account has a display name and author URL slug that differ from its login, so feeds, bylines and /author/ URLs reveal nothing an attacker could use as a confirmed username.', 'fixed' => true, 'advisory' => true, 'fix_label' => '', ); } $lines = array(); foreach ( $exposed as $info ) { $lines[] = sprintf( '%s (via %s)', $info['login'], implode( ' and ', $info['fields'] ) ); } $n = count( $exposed ); return array( 'id' => 'login_in_public_names', 'title' => sprintf( '%d account%s publish%s its login as a public name', $n, 1 === $n ? '' : 's', 1 === $n ? 'es' : '' ), 'detail' => sprintf( 'WordPress keeps three names per user and sets all three to the same string when the account is created: user_login (the credential typed at the login form, private), display_name (the byline printed in feeds, posts and comments, public) and user_nicename (the slug in /author//, public). Here the public fields still carry the login: %s. ' . 'Why that matters: signing in needs a username AND a password, and the username is the half an attacker cannot guess reliably. Publishing it in a feed or an archive URL hands that half over confirmed-valid, so what is left is password guessing alone, and it also tells an attacker exactly which account is worth targeting. ' . 'Note this is not fixed by blocking author enumeration. That masks the feed field, but the /author/ URL still spells the login out. ' . 'To fix: Users → Profile → set a real display name, and change the slug (WP-CLI: wp user update --display_name="Real Name" --user_nicename="real-name"). Your login credential is unchanged, you keep signing in with the same username.', implode( ', ', $lines ) ), 'fixed' => false, // Advisory, not one-click: renaming a slug changes a live public // URL and picking someone's display name is not ours to guess. 'advisory' => true, 'fix_label' => '', ); } )(), ); // A few entries return null when they have nothing to report (e.g. the // orphaned-option repair, which should not appear on a site that never ran // the retired prefix migration). Drop them rather than rendering a blank row. return array_values( array_filter( $fixes, 'is_array' ) ); } // ── Editor Debug Panel ──────────────────────────────────────────────────── /** * Returns critical/high counts for both the AI security scan and the site audit. * Single source of truth, used by the dashboard widget and the site-audit tab. * * @return array{ * scan_critical: int, scan_high: int, * audit_critical: int|null, audit_high: int|null, * audit_run_at: int, either_run: bool * } */ public static function get_combined_counts(): array { $history = get_option( 'csdt_scan_history', array() ); $last_scan = ! empty( $history ) ? $history[0] : null; $audit_cache = get_option( 'csdt_site_audit_cache', null ); $audit_counts = $audit_cache['data']['counts'] ?? null; $audit_run_at = (int) ( $audit_cache['run_at'] ?? 0 ); $audit_critical = isset( $audit_counts ) ? (int) ( $audit_counts['critical'] ?? 0 ) : null; $audit_high = isset( $audit_counts ) ? (int) ( $audit_counts['high'] ?? 0 ) : null; $audit_medium = isset( $audit_counts ) ? (int) ( $audit_counts['medium'] ?? 0 ) : null; // Scan medium: the stored count when the row has one, otherwise counted from the // findings the row already carries. medium_count was only added when the dashboard // started showing it, and every row written before that would otherwise report 0, // a number that looks like an answer. null means "this row cannot say", and the // widget omits the tile rather than printing a zero it cannot stand behind. $scan_medium = null; if ( null !== $last_scan ) { if ( isset( $last_scan['medium_count'] ) ) { $scan_medium = (int) $last_scan['medium_count']; } elseif ( is_array( $last_scan['findings']['medium'] ?? null ) ) { $scan_medium = count( $last_scan['findings']['medium'] ); } } return array( 'scan_critical' => (int) ( $last_scan['critical_count'] ?? 0 ), 'scan_high' => (int) ( $last_scan['high_count'] ?? 0 ), 'scan_medium' => $scan_medium, 'audit_critical' => $audit_critical, 'audit_high' => $audit_high, 'audit_medium' => $audit_medium, 'audit_run_at' => $audit_run_at, 'either_run' => ( null !== $last_scan || null !== $audit_critical ), ); } public static function render_site_audit_panel(): void { $has_key = CSDT_AI_Dispatcher::has_key(); $security_url = admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=security' ); ?>
🔍 'What it scans', 'rec' => 'Overview', 'html' => 'Audits your security posture and WordPress configuration: login brute-force protection, hidden login URL, two-factor enforcement, passkeys, username exposure, wp-config.php permissions, WP_DEBUG, automatic updates, core version, disk space, and duplicate security headers. Also checks database health (expired transients, autoload bloat, post revisions, orphaned postmeta), plugin and theme hygiene, and the social-preview default image (og:image fallback). All data is collected server-side, no external crawlers. SEO auditing lives in CloudScale SEO AI, not here.', ), array( 'name' => 'AI triage', 'rec' => 'Recommended', 'html' => 'When an Anthropic, Gemini or OpenAI API key is configured, or an active managed AI subscription is present, which counts as a key, findings are sent to the AI for prioritisation and tailored fix advice. Without a key, the audit still runs using built-in rule-based checks, you get all structural findings without the AI commentary.', ), array( 'name' => 'Result caching', 'rec' => 'Info', 'html' => 'Audit results are cached after each run. Opening the Site Audit tab shows the last result immediately without re-running. Click Re-run Audit to refresh. The last-run timestamp is shown in the header.', ), array( 'name' => 'Severity levels', 'rec' => 'Info', 'html' => 'Critical, fix immediately (active security risk or data loss).
High, fix this week (significant security or performance impact).
Medium, plan to fix (noticeable but not urgent).
Low / Info, best practice or informational.', ), ) ); ?>
🔍 Site Audit, this tab
🛡️ AI Security Scan
' . esc_html__( 'Add your key on the AI Settings tab →', 'cloudscale-cyber-devtools' ) . '' ); ?>
with no type inside a //
defaults to type="submit", so clicking it also submitted the // enclosing form, a full page reload that raced the AJAX request below and // killed it mid-flight, which is what "Request failed, please reload and try // again" actually was. ?>
🔍
0; $schedules['csdt_monthly'] = array( 'interval' => 30 * DAY_IN_SECONDS, 'display' => ( $i18n ? __( 'Once Monthly', 'cloudscale-cyber-devtools' ) : 'Once Monthly' ), ); $schedules['csdt_every_1min'] = array( 'interval' => MINUTE_IN_SECONDS, 'display' => ( $i18n ? __( 'Every Minute', 'cloudscale-cyber-devtools' ) : 'Every Minute' ), ); $schedules['csdt_every_2min'] = array( 'interval' => 2 * MINUTE_IN_SECONDS, 'display' => ( $i18n ? __( 'Every 2 Minutes', 'cloudscale-cyber-devtools' ) : 'Every 2 Minutes' ), ); $schedules['csdt_every_5min'] = array( 'interval' => 5 * MINUTE_IN_SECONDS, 'display' => ( $i18n ? __( 'Every 5 Minutes', 'cloudscale-cyber-devtools' ) : 'Every 5 Minutes' ), ); return $schedules; } /** * Background cron: fetch home_url('/') once and populate both quick-fix transients. * Scheduled on-demand by get_quick_fixes() when either transient is cold. * TTL is 1 hour, avoids the 5-minute blocking page-render penalty. */ public static function refresh_home_html_cache(): void { try { $resp = wp_remote_get( home_url( '/' ), array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 8, 'sslverify' => csdt_loopback_ssl_verify(), ) ); $body = is_wp_error( $resp ) ? '' : (string) wp_remote_retrieve_body( $resp ); set_transient( 'csdt_home_html_ads_check', (int) substr_count( $body, 'enable_page_level_ads' ), HOUR_IN_SECONDS ); $missing = array(); $home_host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); if ( $body ) { preg_match_all( '/]+src=["\']([^"\']+)["\'][^>]*>/i', $body, $m ); foreach ( $m[0] as $i => $tag ) { $src = $m[1][ $i ]; $host = (string) wp_parse_url( $src, PHP_URL_HOST ); if ( $host && $host !== $home_host && stripos( $tag, 'crossorigin' ) === false ) { $missing[] = $src; } } } set_transient( 'csdt_crossorigin_check', array_values( array_unique( $missing ) ), HOUR_IN_SECONDS ); } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } 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 ); } $raw_enabled = sanitize_key( wp_unslash( $_POST['enabled'] ?? '' ) ); $enabled = ! empty( $raw_enabled ) && $raw_enabled === '1'; $raw_freq = sanitize_key( wp_unslash( $_POST['freq'] ?? '' ) ); $freq = in_array( $raw_freq, array( 'weekly', 'monthly' ), true ) ? $raw_freq : 'weekly'; $raw_type = sanitize_key( wp_unslash( $_POST['type'] ?? '' ) ); $type = in_array( $raw_type, array( 'standard', 'deep' ), true ) ? $raw_type : 'deep'; 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 ); // 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( array( 'saved' => true, 'next_run' => $next_run ? wp_date( 'D j M Y, g:ia', $next_run ) : null, ) ); } public static function ajax_save_notify(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $raw_email_enabled = sanitize_key( wp_unslash( $_POST['email_enabled'] ?? '' ) ); $email_enabled = ! empty( $raw_email_enabled ) && $raw_email_enabled === '1'; $email_to = sanitize_email( wp_unslash( $_POST['email_to'] ?? '' ) ); update_option( 'csdt_scan_schedule_email', $email_enabled ? '1' : '0', false ); update_option( 'csdt_notify_email', $email_to, false ); CSDT_Telegram::save_credentials( sanitize_text_field( wp_unslash( $_POST['telegram_token'] ?? '' ) ), sanitize_text_field( wp_unslash( $_POST['telegram_chat_id'] ?? '' ) ) ); wp_send_json_success( array( 'saved' => true ) ); } public static function run_scheduled_scan(): void { $type = get_option( 'csdt_scan_schedule_type', 'deep' ); if ( $type === 'deep' ) { self::cron_deep_scan(); } else { CSDT_Vuln_Scan::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'] ?? array() ); $high = count( $report['high'] ?? array() ); $type_label = $type === 'deep' ? 'AI Deep Dive Cyber Audit' : 'AI Cyber Audit'; $site = wp_specialchars_decode( (string) ( get_bloginfo( 'name' ) ?: home_url() ), ENT_QUOTES ); $admin_url = admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=security' ); $subject = sprintf( 'CSDT: Scan %s/100 %s, %s', $score, $label, $site ); $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 ( CSDT_Options::get( 'csdt_scan_schedule_email' ) === '1' ) { $to = CSDT_Options::get( 'csdt_notify_email' ) ?: get_option( 'admin_email' ); CSDT_Threat_Monitor::send_alert_email( (string) $to, $subject, $body ); } if ( get_option( 'csdt_telegram_scan_result', '1' ) === '1' && CSDT_Options::get( 'csdt_notify_telegram_enabled' ) === '1' ) { $level = $critical > 0 ? 'critical' : ( $high > 0 ? 'warning' : 'info' ); CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, $subject . "\n\n" . $body, 'Security Scan', $level ); } } /** * Alerts on Telegram the moment a source's critical-finding count goes UP from what it was * last time this was checked, not on every run that merely still has criticals, which is what * send_scan_notifications() above already does for the scheduled scan alone. Runs for every * scan (manual or scheduled, deep or standard) via append_scan_history(), and for every Site * Audit run, so a critical found by clicking a button gets the same alert a cron-triggered one * does. * * "New" is a count increase, not an exact-finding match: AI scan findings are LLM-generated * prose with no stable identity across runs, so matching by title/text would be fragile in both * directions. A count that goes up is the one thing neither source can get wrong. * * @param string $source_label Human label for the alert ('AI Security Scan' or 'Site Audit'). * @param string $option_key Option name storing the last-seen critical count for this source. * @param int $critical_count Current run's critical count. * @param string[] $critical_titles Titles of the current critical findings, for the alert body. * @param string $tab Tab slug to link to ('security' or 'site-audit'). * @return void */ private static function maybe_alert_new_critical( string $source_label, string $option_key, int $critical_count, array $critical_titles, string $tab ): void { if ( CSDT_Options::get( 'csdt_notify_telegram_enabled' ) !== '1' || get_option( 'csdt_telegram_new_critical', '1' ) !== '1' || ! class_exists( 'CSDT_Telegram' ) || ! CSDT_Telegram::is_configured() ) { update_option( $option_key, $critical_count, false ); return; } $previous = (int) get_option( $option_key, 0 ); if ( $critical_count > $previous ) { $site = wp_specialchars_decode( (string) ( get_bloginfo( 'name' ) ?: home_url() ), ENT_QUOTES ); $url = admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=' . $tab ); $lines = array_slice( $critical_titles, 0, 5 ); $body = sprintf( "\u{1F6A8} New CRITICAL finding on %s\n\n%s: %d critical (was %d)\n\n%s\n\nView: %s", $site, $source_label, $critical_count, $previous, $lines ? '- ' . implode( "\n- ", $lines ) : '', $url ); $sent = CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, $body, $source_label, 'critical' ); $outcome = method_exists( 'CSDT_Telegram', 'last_outcome' ) ? CSDT_Telegram::last_outcome() : ''; // The new count becomes the baseline only once it has been reported. This used // to be written whatever send() answered, so a new critical held by the hourly // cap or lost to a failed POST was never alerted: the next scan saw the same // count and stayed quiet. Held or failed, the old baseline stays and the next // scan tries again. Muted and unconfigured are final answers, not "not yet". if ( ! $sent && ! in_array( $outcome, array( 'muted', 'unconfigured' ), true ) ) { return; } } update_option( $option_key, $critical_count, false ); } /* ── Quick-fix undo snapshots ────────────────────────────────────────── Before a quick fix mutates state that has no obvious default, the previous state is stored here so fix_action=revert can restore it. Stored in a single non-autoloaded option keyed by fix id. */ private static function qf_snapshot_set( string $fix_id, array $payload ): void { $snaps = get_option( 'csdt_qf_undo', array() ); if ( ! is_array( $snaps ) ) { $snaps = array(); } $payload['ts'] = time(); $snaps[ $fix_id ] = $payload; update_option( 'csdt_qf_undo', $snaps, false ); } private static function qf_snapshot_get( string $fix_id ): ?array { $snaps = get_option( 'csdt_qf_undo', array() ); return ( is_array( $snaps ) && isset( $snaps[ $fix_id ] ) && is_array( $snaps[ $fix_id ] ) ) ? $snaps[ $fix_id ] : null; } private static function qf_snapshot_clear( string $fix_id ): void { $snaps = get_option( 'csdt_qf_undo', array() ); if ( is_array( $snaps ) && isset( $snaps[ $fix_id ] ) ) { unset( $snaps[ $fix_id ] ); update_option( 'csdt_qf_undo', $snaps, false ); } } /** * Quick fixes that no longer act, mapped to the recipe that replaced them. * * Single source of truth: the AJAX refusal, the revert refusal, and the * button rendering all read this, so they cannot drift apart and leave one * path still willing to write to the filesystem. * * An empty recipe id means the fix was withdrawn outright with no * replacement, see db_prefix_default. */ public static function retired_fix_recipes(): array { return array( 'ssh_brute_force' => 'fail2ban', 'wpconfig_perms' => 'wpconfig_perms', 'block_debug_log' => 'debug_log_exposed', 'disable_file_edit' => 'disallow_file_edit', 'disable_wp_cron' => 'wp_cron_hardening', 'db_prefix_default' => '', ); } /** * Options wrongly renamed by the retired table-prefix migration. * * That migration ran, in effect: * * UPDATE options SET option_name = REPLACE(option_name, 'wp_', '_') * WHERE option_name LIKE 'wp\_%' * * but only ONE option is genuinely prefixed, wp_user_roles. Everything else * beginning "wp_" is an ordinary option that happens to start with those * three characters, and renaming it silently orphans the setting: core loses * wp_page_for_privacy_policy and wp_force_deactivated_plugins, and WP Mail * SMTP (millions of installs) loses its entire configuration and stops * sending mail with nothing to link it to a prefix change weeks earlier. * * Because the new prefix is randomly generated, the detection is exact rather * than heuristic: after a migration, the only option that should carry it is * _user_roles. Anything else wearing that prefix is collateral damage. * * @return array List of array{from:string,to:string,collision:bool}. */ public static function db_prefix_orphaned_options(): array { $info = get_option( 'csdt_db_prefix_rollback' ); if ( ! is_array( $info ) ) { return array(); } $new_prefix = (string) ( $info['new_prefix'] ?? '' ); $old_prefix = (string) ( $info['old_prefix'] ?? 'wp_' ); if ( '' === $new_prefix || '' === $old_prefix || $new_prefix === $old_prefix ) { return array(); } global $wpdb; $names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off repair detection against option_name; no caching layer applies $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s AND option_name != %s', $wpdb->options, $wpdb->esc_like( $new_prefix ) . '%', $new_prefix . 'user_roles' ) ); if ( empty( $names ) ) { return array(); } $out = array(); foreach ( $names as $name ) { $restored = $old_prefix . substr( (string) $name, strlen( $new_prefix ) ); $out[] = array( 'from' => (string) $name, 'to' => $restored, // If the original name exists again, a plugin recreated its // defaults after losing them, renaming over it would replace live // settings with stale ones. Report, do not overwrite. 'collision' => null !== $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- existence probe for the same repair $wpdb->prepare( 'SELECT option_id FROM %i WHERE option_name = %s LIMIT 1', $wpdb->options, $restored ) ), ); } return $out; } /** * Where a quick-fix response goes, when it is not going to a browser. * * Null on a web request: respond_success() / respond_error() below then behave exactly * as wp_send_json_success() / wp_send_json_error() always did, and exit. * * `wp csdt fix` sets a callable here instead, so the CLI receives the payload as DATA. * It used to read it by capturing the handler's printed output, which meant WordPress.org * saw a plugin managing its own output buffer, and meant a stray notice from anything * that ran during a fix landed inside the JSON and made the result unreadable. There was * a whole branch for that case, reporting "whether the fix applied is unknown". A sink * cannot be polluted, so the branch is gone with it. * * The point of the CLI calling this handler at all is that there is ONE code path: same * capability check, same nonce check, same handlers, same undo snapshots. A sink keeps * that property; it changes only where the answer is delivered. * * @since 1.10.710 * @var callable|null function ( bool $ok, mixed $data, ?int $status ): void, must not return. */ public static $quick_fix_sink = null; /** * Answers a quick-fix request with success. * * @param mixed $data Response payload. * @param int|null $status_code Optional HTTP status. * @param int $flags Optional json_encode flags. */ private static function respond_success( $data = null, $status_code = null, $flags = 0 ): void { self::respond( true, $data, $status_code, $flags ); } /** * Answers a quick-fix request with failure. * * @param mixed $data Response payload, usually a message. * @param int|null $status_code Optional HTTP status. * @param int $flags Optional json_encode flags. */ private static function respond_error( $data = null, $status_code = null, $flags = 0 ): void { self::respond( false, $data, $status_code, $flags ); } /** * Delivers the response, to the sink if one is set and to the browser otherwise. * * A sink is expected to throw, which is how it stops the handler. If one returns anyway, * this falls through to wp_send_json_*() rather than carrying on past a response that has * supposedly been sent, continuing would run the rest of a fix a second time. * * @param bool $ok Whether the fix succeeded. * @param mixed $data Response payload. * @param int|null $status_code Optional HTTP status. * @param int $flags Optional json_encode flags. */ private static function respond( bool $ok, $data, $status_code, $flags ): void { if ( is_callable( self::$quick_fix_sink ) ) { call_user_func( self::$quick_fix_sink, $ok, $data, $status_code ); } if ( $ok ) { wp_send_json_success( $data, $status_code, $flags ); } wp_send_json_error( $data, $status_code, $flags ); } /** * Undo a previously applied quick fix. Returns a human-readable result * message, or a WP_Error when the fix cannot be reverted. */ private static function revert_quick_fix( string $fix_id ) { $snap = self::qf_snapshot_get( $fix_id ); // A retired fix can still have a snapshot from before the change, so the // revert path stays available for sites that applied it while it existed, // except for the ones that reverted by touching the filesystem, which // is exactly the capability being withdrawn. Those hand back the recipe's // rollback script instead. $retired = self::retired_fix_recipes(); if ( isset( $retired[ $fix_id ] ) ) { return new WP_Error( 'csdt_fix_retired', 'This fix is no longer reverted automatically. Use "Show fix commands" on the finding and run the rollback script it generates, it restores the exact values recorded on your server.' ); } switch ( $fix_id ) { case 'disable_pingbacks': update_option( 'default_ping_status', $snap['default_ping_status'] ?? 'open' ); update_option( 'default_pingback_flag', $snap['default_pingback_flag'] ?? 1 ); self::qf_snapshot_clear( $fix_id ); return 'Pingback/trackback settings restored.'; case 'close_registration': update_option( 'users_can_register', $snap['users_can_register'] ?? 1 ); self::qf_snapshot_clear( $fix_id ); return 'Open user registration re-enabled.'; case 'disable_app_passwords': delete_option( 'csdt_devtools_disable_app_passwords' ); delete_option( 'csdt_devtools_app_pw_2fa_ack' ); return 'Application-password authentication re-enabled. Previously issued app passwords work again.'; case 'enforce_2fa_admins': delete_option( 'csdt_devtools_2fa_force_admins' ); return '2FA enforcement removed. Admins can log in with a password alone again.'; case 'hide_wp_version': delete_option( 'csdt_devtools_hide_wp_version' ); return 'WordPress version is visible in HTML again.'; case 'close_comments': update_option( 'default_comment_status', $snap['default_comment_status'] ?? 'open' ); self::qf_snapshot_clear( $fix_id ); return 'Default comment status restored.'; case 'csp_unsafe_inline': update_option( 'csdt_csp_nonces_enabled', $snap['csdt_csp_nonces_enabled'] ?? '0' ); if ( isset( $snap['csdt_devtools_csp_enabled'] ) ) { update_option( 'csdt_devtools_csp_enabled', $snap['csdt_devtools_csp_enabled'] ); } if ( isset( $snap['csdt_devtools_csp_mode'] ) ) { update_option( 'csdt_devtools_csp_mode', $snap['csdt_devtools_csp_mode'] ); } delete_option( 'csdt_csp_inline_ack' ); delete_transient( 'csdt_csp_unsafe_check' ); self::qf_snapshot_clear( $fix_id ); return 'CSP settings restored to their pre-fix state.'; case 'enable_autoblock': update_option( 'csdt_autoblock_enabled', '0', true ); self::qf_snapshot_clear( $fix_id ); return 'Automatic IP blocking turned off. Existing blocks remain and will expire on their own.'; case 'enable_auto_updates': // There is nothing to undo: the fix no longer removes anything, it reports what // to remove. A site fixed before 1.10.871 may still have a snapshot of lines an // earlier version deleted, so those are handed back as text for the owner to // restore. Restoring them here would be the write the fix stopped making. self::qf_snapshot_clear( $fix_id ); if ( null !== $snap && ! empty( $snap['removed_lines'] ) ) { return 'This plugin no longer edits wp-config.php. An earlier version removed the following line(s); re-add them yourself if you want automatic updates blocked again: ' . implode( ' ', array_map( 'trim', (array) $snap['removed_lines'] ) ); } return 'Nothing to undo: this fix reports which constants block automatic updates, it does not change wp-config.php.'; case 'orphaned_cron_hooks': if ( null === $snap || empty( $snap['events'] ) ) { return new WP_Error( 'no_snapshot', 'No snapshot of removed cron events found.' ); } $restored = 0; foreach ( (array) $snap['events'] as $ev ) { if ( empty( $ev['hook'] ) ) { continue; } $ts = max( (int) ( $ev['timestamp'] ?? 0 ), time() + MINUTE_IN_SECONDS ); $args = isset( $ev['args'] ) && is_array( $ev['args'] ) ? $ev['args'] : array(); if ( ! empty( $ev['schedule'] ) ) { wp_schedule_event( $ts, (string) $ev['schedule'], (string) $ev['hook'], $args ); } else { wp_schedule_single_event( $ts, (string) $ev['hook'], $args ); } ++$restored; } self::qf_snapshot_clear( $fix_id ); return sprintf( 'Restored %d cron event%s from the snapshot.', $restored, $restored === 1 ? '' : 's' ); case 'ads_dedup': delete_option( 'csdt_ads_dedup' ); delete_transient( 'csdt_home_html_ads_check' ); return 'AdSense deduplication guard removed.'; case 'crossorigin_scripts': delete_option( 'csdt_crossorigin_scripts' ); delete_transient( 'csdt_crossorigin_check' ); return 'crossorigin="anonymous" is no longer added to third-party scripts.'; case 'disable_xmlrpc': delete_option( 'csdt_disable_xmlrpc' ); return 'XML-RPC re-enabled, xmlrpc.php accepts requests again.'; case 'limit_comment_rate': update_option( CSDT_Comment_Flood::OPTION_MIN_GAP, (string) absint( $snap['gap'] ?? 0 ) ); self::qf_snapshot_clear( $fix_id ); return 'The gap between comments is back to what it was before the fix.'; case 'limit_revisions': delete_option( 'csdt_limit_revisions' ); // A site fixed before 1.10.871 may still carry define( 'WP_POST_REVISIONS', 5 ) // that this plugin wrote. It is named rather than removed, because the plugin // no longer writes to wp-config.php at all and undoing a change by editing that // file is the same write the fix stopped making. $csdt_cfg_note = ( null !== $snap && ! empty( $snap['cfg_added'] ) ) ? " An earlier version of this plugin added define( 'WP_POST_REVISIONS', 5 ); to wp-config.php. Remove that line by hand if you want the cap gone entirely." : ''; self::qf_snapshot_clear( $fix_id ); return 'Revision cap removed. Already-pruned revisions cannot be restored.' . $csdt_cfg_note; case 'disable_user_enumeration': delete_option( 'csdt_block_user_enum' ); return 'Author-enumeration block removed, /?author=N resolves again.'; // Acknowledgements. Both are reachable from the findings list, and the // confirmation dialog tells the reader an acknowledgement "can be // cleared from Security → Quick Fixes with the Undo button". Without // these two cases that sentence was false: they fell through to the // default below, so a finding you dismissed once could only be brought // back by deleting the option by hand. Acknowledging a finding is the // one action a user should never feel is irreversible, the whole point // is that they are telling us it does not apply to their site, and they // have to be able to say they were wrong. case 'cron_disabled_ack': delete_option( 'csdt_cron_disabled_ack' ); return 'Acknowledgement cleared. The DISABLE_WP_CRON finding will appear again on the next scan.'; case 'defimg_no_fallback_ack': delete_option( 'csdt_defimg_no_fallback_ack' ); return 'Acknowledgement cleared. The missing-default-image finding will appear again on the next scan.'; default: return new WP_Error( 'not_undoable', 'This fix cannot be undone automatically.' ); } } public static function ajax_apply_quick_fix(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { self::respond_error( 'Unauthorized', 403 ); } // A MINTED TEST SESSION MUST NOT REACH THIS. Both scoped test roles hold manage_options, so // the check above is a capability check and not an identity one. Added 2026-08-17 with seven // sibling handlers, after widening tests/db-access-escalation-test.php to enumerate every // registration style and every file rather than `array( __CLASS__,` in cloudscale-cyber-devtools.php. if ( CSDT_DevTools::viewer_is_test_session() ) { self::respond_error( 'Not available to a test session.', 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 === 'revert' ) { $result = self::revert_quick_fix( $fix_id ); if ( is_wp_error( $result ) ) { self::respond_error( CSDT_Error_Text::in_seconds( $result->get_error_message() ) ); return; } self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'message' => $result, ) ); return; } if ( $action === 'list' ) { self::respond_success( array( 'fixes' => self::get_quick_fixes() ) ); return; } if ( $action === 'dismiss' ) { $allowed_acks = array( 'cron_disabled_ack', 'defimg_no_fallback_ack' ); if ( ! in_array( $fix_id, $allowed_acks, true ) ) { self::respond_error( 'Invalid dismiss id' ); return; } update_option( 'csdt_' . $fix_id, '1' ); self::respond_success( array( 'message' => 'Acknowledged' ) ); return; } if ( $action !== 'apply' ) { self::respond_error( 'Invalid action' ); return; } // Retired fixes. // // These changed the filesystem or system-level configuration from a web // request: chmod-ing wp-config.php, writing constants into it, moving a // log file, installing a package, renaming live database tables. A // plugin that can do that is a plugin that can be *made* to do that, and // it is the reason "Click to Fix" failed WordPress.org review. // // They are now generated shell recipes the site owner reads and runs // themselves (CSDT_Remediation). Detection is unchanged, the findings // still appear, they just hand you commands instead of acting. // // Refused here, server-side, rather than only removing the buttons: a // cached admin page or a hand-crafted request must not be able to reach // a capability we have deliberately given up. $retired = self::retired_fix_recipes(); if ( isset( $retired[ $fix_id ] ) ) { self::respond_error( array( 'message' => 'This fix is no longer applied automatically. Open the finding and use "Show fix commands" to get a reviewed script you run over SSH, along with a matching rollback.', 'recipe' => $retired[ $fix_id ], 'retired' => true, ), 410 ); return; } switch ( $fix_id ) { case 'security_headers': update_option( 'csdt_devtools_safe_headers_enabled', '1' ); delete_transient( 'csdt_sec_headers_check' ); break; case 'security_headers_ack': update_option( 'csdt_devtools_sec_headers_ack', '1' ); delete_transient( 'csdt_sec_headers_check' ); break; case 'defimg_no_fallback_ack': update_option( 'csdt_defimg_no_fallback_ack', '1' ); break; case 'app_pw_2fa_ack': update_option( 'csdt_devtools_app_pw_2fa_ack', '1' ); break; case 'disable_pingbacks': self::qf_snapshot_set( $fix_id, array( 'default_ping_status' => get_option( 'default_ping_status' ), 'default_pingback_flag' => get_option( 'default_pingback_flag' ), ) ); update_option( 'default_ping_status', 'closed' ); update_option( 'default_pingback_flag', 0 ); break; case 'close_registration': self::qf_snapshot_set( $fix_id, array( 'users_can_register' => get_option( 'users_can_register', 0 ) ) ); 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': self::qf_snapshot_set( $fix_id, array( 'default_comment_status' => get_option( 'default_comment_status' ) ) ); update_option( 'default_comment_status', 'closed' ); break; case 'cron_health': // Reschedule missing core cleanup events. if ( ! wp_next_scheduled( 'wp_scheduled_delete' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wp_scheduled_delete' ); } if ( ! wp_next_scheduled( 'delete_expired_transients' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'delete_expired_transients' ); } if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) { // Can't run events, WP-Cron is disabled. Acknowledge the finding so it // stops reappearing: the admin has confirmed a system cron handles this. update_option( 'csdt_cron_disabled_ack', '1', false ); self::respond_success( array( 'message' => 'Acknowledged, ensure your system cron calls wp-cli cron event run --due-now at least every 15 minutes.' ) ); return; } delete_expired_transients(); wp_scheduled_delete(); break; case 'expired_transients': global $wpdb; // Delete expired timeout markers and their orphaned data keys in one JOIN. $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "DELETE a, b FROM %i a LEFT JOIN %i b ON b.option_name = REPLACE( a.option_name, '_transient_timeout_', '_transient_' ) WHERE a.option_name LIKE %s AND CAST( a.option_value AS UNSIGNED ) < UNIX_TIMESTAMP()", $wpdb->options, $wpdb->options, $wpdb->esc_like( '_transient_timeout_' ) . '%' ) ); break; case 'csp_unsafe_inline': self::qf_snapshot_set( $fix_id, array( 'csdt_csp_nonces_enabled' => CSDT_Options::get( 'csdt_csp_nonces_enabled' ), 'csdt_devtools_csp_enabled' => CSDT_Options::get( 'csdt_devtools_csp_enabled' ), 'csdt_devtools_csp_mode' => CSDT_Options::get( 'csdt_devtools_csp_mode' ), ) ); update_option( 'csdt_csp_nonces_enabled', '1' ); // Auto-enable the plugin's own CSP in Report-Only mode first, never jump straight to Enforce if ( CSDT_Options::get( 'csdt_devtools_csp_enabled' ) !== '1' ) { update_option( 'csdt_devtools_csp_enabled', '1' ); update_option( 'csdt_devtools_csp_mode', 'report_only' ); } delete_transient( 'csdt_csp_unsafe_check' ); break; case 'csp_inline_ack': update_option( 'csdt_csp_inline_ack', '1' ); delete_transient( 'csdt_csp_unsafe_check' ); break; case 'enforce_2fa_admins': // Through the wrapper: login-security options are gated on a fresh passkey // verification, and this is the plugin hardening the site on request rather than // a browser asking to change a switch. Without it the gate would silently veto // our own recipe. See CSDT_Login::internal_option_write(). CSDT_Login::internal_option_write( static function () { update_option( 'csdt_devtools_2fa_force_admins', '1' ); } ); break; case 'enable_autoblock': // Refuse rather than pretend: with IP detection broken, every // event carries the proxy address and no block could ever fire. if ( CSDT_DevTools::ip_detection_is_broken() ) { self::respond_error( 'Visitor IP detection is not resolving public addresses. Fix Login Security → Visitor IP Source first, otherwise auto-blocking cannot work.' ); } update_option( 'csdt_autoblock_enabled', '1', true ); break; case 'enable_auto_updates': // THIS PLUGIN NO LONGER EDITS wp-config.php; see the note on 'limit_revisions' // above. A constant cannot be undefined at runtime, so unlike the revision cap // there is no filter that can stand in: what the fix can do is say exactly which // constants are in force and exactly what to delete. // // The blockers are read from the CONSTANTS, not by parsing the file. That is // both more accurate than a regular expression over wp-config.php (a constant // can be defined anywhere, including a file wp-config includes) and it removes // the last read of that file from this handler. // // DISALLOW_FILE_MODS is listed first deliberately: it blocks updates AND plugin // installation and deletion, and this plugin's position is that it is a fault to // report rather than a hardening measure to apply. $csdt_blockers = array(); if ( defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS ) { $csdt_blockers[] = "define( 'DISALLOW_FILE_MODS', true );"; } if ( defined( 'AUTOMATIC_UPDATER_DISABLED' ) && AUTOMATIC_UPDATER_DISABLED ) { $csdt_blockers[] = "define( 'AUTOMATIC_UPDATER_DISABLED', true );"; } if ( defined( 'WP_AUTO_UPDATE_CORE' ) && false === WP_AUTO_UPDATE_CORE ) { $csdt_blockers[] = "define( 'WP_AUTO_UPDATE_CORE', false );"; } if ( empty( $csdt_blockers ) ) { self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'message' => 'No update-blocking constants are defined. Automatic updates are already allowed to run.', ) ); return; } self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'warning' => "Automatic updates are blocked by the following line(s) in wp-config.php. This plugin does not edit that file, so remove them yourself over SSH or with your host's file manager, then run the scan again:\n\n" . implode( "\n", $csdt_blockers ), ) ); return; case 'delete_inactive_plugins': // Report only, never deletes. WordPress.org's review guidelines say a // plugin may not change another plugin's installed or activation status // (WPORG_COMMON_38); this used to remove the inactive list from disk // directly. That removal call is gone; this now only names what it // found, the same way every other read-only finding on this tab does, // and removal is the administrator's own action from Plugins → // Installed Plugins, which the message points at. if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); $active_plugins = (array) get_option( 'active_plugins', array() ); $inactive = array_keys( array_filter( $all_plugins, fn( $data, $k ) => ! in_array( $k, $active_plugins, true ), ARRAY_FILTER_USE_BOTH ) ); if ( empty( $inactive ) ) { self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'message' => 'No inactive plugins found.', ) ); return; } $manifest = array(); foreach ( $inactive as $plugin_file ) { $data = $all_plugins[ $plugin_file ] ?? array(); $slug = dirname( (string) $plugin_file ); $manifest[] = array( 'file' => (string) $plugin_file, // dirname() is '.' for a single-file plugin, which has no wp.org // directory page to send anyone to. 'slug' => ( '.' === $slug || '' === $slug ) ? '' : $slug, 'name' => (string) ( $data['Name'] ?? $plugin_file ), 'version' => (string) ( $data['Version'] ?? '' ), ); } $count = count( $inactive ); $named = array(); foreach ( $manifest as $m ) { $named[] = $m['name'] . ( '' !== $m['version'] ? ' ' . $m['version'] : '' ); } self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'found' => $manifest, 'message' => sprintf( '%d inactive plugin%s found: %s. Review and remove them yourself from Plugins → Installed Plugins; this fix does not delete anything.', $count, 1 === $count ? '' : 's', implode( ', ', $named ) ), ) ); return; case 'orphaned_cron_hooks': $core_hooks = array( 'wp_scheduled_delete', 'delete_expired_transients', 'wp_version_check', 'wp_update_plugins', 'wp_update_themes', 'wp_scheduled_auto_draft_delete', 'recovery_mode_clean_expired_keys', 'wp_privacy_delete_old_export_files', 'wp_site_health_scheduled_check', 'wp_update_user_counts', 'wp_https_detection', 'wp_privacy_delete_old_export_files', ); $removed = 0; $qf_events = array(); $crons = _get_cron_array(); if ( is_array( $crons ) ) { // First pass: snapshot every orphaned event (hook, next-run time, // recurrence, args) so the removal can be undone. foreach ( $crons as $qf_ts => $hooks ) { if ( ! is_array( $hooks ) ) { continue; } foreach ( $hooks as $hook => $instances ) { if ( in_array( $hook, $core_hooks, true ) || has_action( $hook ) ) { continue; } foreach ( (array) $instances as $inst ) { $qf_events[] = array( 'hook' => $hook, 'timestamp' => (int) $qf_ts, 'schedule' => $inst['schedule'] ?? '', 'args' => isset( $inst['args'] ) && is_array( $inst['args'] ) ? $inst['args'] : array(), ); } } } if ( $qf_events ) { self::qf_snapshot_set( $fix_id, array( 'events' => $qf_events ) ); } foreach ( $crons as $hooks ) { foreach ( array_keys( (array) $hooks ) as $hook ) { if ( in_array( $hook, $core_hooks, true ) ) { continue; } if ( ! has_action( $hook ) ) { wp_clear_scheduled_hook( $hook ); ++$removed; } } } } self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'message' => $removed > 0 ? sprintf( 'Removed %d orphaned cron hook%s.', $removed, $removed === 1 ? '' : 's' ) : 'No orphaned hooks found.', ) ); return; case 'crossorigin_scripts': update_option( 'csdt_crossorigin_scripts', '1' ); delete_transient( 'csdt_crossorigin_check' ); self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'message' => 'crossorigin="anonymous" will now be added to all enqueued third-party scripts via the script_loader_tag filter.', ) ); return; case 'ads_dedup': update_option( 'csdt_ads_dedup', '1' ); delete_transient( 'csdt_home_html_ads_check' ); self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'message' => 'AdSense deduplication patch enabled, duplicate push calls will be silently dropped on every page load.', ) ); return; case 'limit_comment_rate': self::qf_snapshot_set( $fix_id, array( 'gap' => CSDT_Comment_Flood::min_gap() ) ); update_option( CSDT_Comment_Flood::OPTION_MIN_GAP, (string) CSDT_Comment_Flood::FIX_GAP ); self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'message' => 'Comment rate limited: at most one comment every ' . (int) CSDT_Comment_Flood::FIX_GAP . ' seconds across the whole site. Change it under Firewall, Comment Flood Protection.', ) ); return; case 'disable_xmlrpc': update_option( 'csdt_disable_xmlrpc', '1' ); self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'message' => 'XML-RPC disabled. The xmlrpc_enabled filter now returns false on every request. The REST API is unaffected.', ) ); return; case 'limit_revisions': // The cap is applied at runtime, through the wp_revisions_to_keep filter in // CSDT_DevTools::init(), from this option flag. // // THIS PLUGIN NO LONGER EDITS wp-config.php. It used to write // define( 'WP_POST_REVISIONS', 5 ) when that file was writable and fall back to // this option when it was not. WordPress.org round 23 reported the write as // blocking (finding 42, a write to a file that belongs to the site owner) and // the reviewer's own smallest fix is "do not write it". The same class was // raised once before against this file, for wp-content/uploads/.htaccess, and // what closed it was removal rather than an argument. // // Nothing is lost: the filter path was already the documented behaviour for a // read-only wp-config, so it is now simply the only path. Same cap, same help // text, one fewer file this plugin writes to. update_option( 'csdt_limit_revisions', '1' ); self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'message' => 'Post revisions limited to 5 per post. Old revisions are pruned automatically by WordPress on the next save.', ) ); return; case 'disable_user_enumeration': update_option( 'csdt_block_user_enum', '1' ); self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'message' => 'Author enumeration blocked. Requests to /?author=N now redirect to the home page, and feed author fields no longer publish an account login.', ) ); return; case 'db_prefix_orphaned_options': global $wpdb; $orphans = self::db_prefix_orphaned_options(); $restored = 0; $skipped = array(); foreach ( $orphans as $o ) { if ( $o['collision'] ) { // The original name is in use again, so whatever owns it has // already rebuilt its settings. Renaming over that would swap // live values for stale ones, strictly worse than leaving the // orphan in place for a human to compare. $skipped[] = $o['from']; continue; } $ok = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- renaming an option key; no API exists for this $wpdb->options, array( 'option_name' => $o['to'] ), array( 'option_name' => $o['from'] ), array( '%s' ), array( '%s' ) ); if ( false !== $ok && $ok > 0 ) { ++$restored; } } // Renamed keys are still cached under the old key. wp_cache_flush(); $msg = sprintf( 'Restored %d option name%s.', $restored, 1 === $restored ? '' : 's' ); if ( ! empty( $skipped ) ) { $msg .= sprintf( ' %d left alone because the original name is in use again (compare and merge by hand): %s', count( $skipped ), implode( ', ', array_slice( $skipped, 0, 5 ) ) ); } self::respond_success( array( 'fixes' => self::get_quick_fixes(), 'message' => $msg, ) ); return; default: self::respond_error( 'Unknown fix ID' ); return; } self::respond_success( array( 'fixes' => self::get_quick_fixes() ) ); } // ── Shared list of WordPress core table suffixes (no prefix) ──────────── public static function core_table_suffixes(): array { return array( 'comments', 'commentmeta', 'links', 'options', 'postmeta', 'posts', 'terms', 'termmeta', 'term_relationships', 'term_taxonomy', 'usermeta', 'users', // Multisite 'blogs', 'blog_versions', 'registration_log', 'signups', 'site', 'sitemeta', 'sitecategories', ); } // ── Map a table suffix to a likely plugin name ─────────────────────────── public static function guess_plugin_from_suffix( string $suffix ): string { $map = array( 'rank_math' => 'Rank Math SEO', 'shortpixel' => 'ShortPixel', 'woocommerce' => 'WooCommerce', 'wc_order' => 'WooCommerce', 'wc_product' => 'WooCommerce', 'wc_tax' => 'WooCommerce', 'woo_' => 'WooCommerce', 'elementor' => 'Elementor', 'e_submissions' => 'Elementor', 'aioseo' => 'AIOSEO', 'yoast' => 'Yoast SEO', 'wpseo' => 'Yoast SEO', 'jetpack' => 'Jetpack', 'ninja_forms' => 'Ninja Forms', 'nf_' => 'Ninja Forms', 'gf_' => 'Gravity Forms', 'litespeed' => 'LiteSpeed Cache', 'smush' => 'Smush', 'wordfence' => 'Wordfence', 'wfblockedip' => 'Wordfence', 'wf_' => 'Wordfence', 'cf7' => 'Contact Form 7', 'wpcf7' => 'Contact Form 7', 'redirection' => 'Redirection', 'duplicator' => 'Duplicator', 'itsec' => 'iThemes Security', 'backwpup' => 'BackWPup', 'mailpoet' => 'MailPoet', 'wysija' => 'MailPoet (legacy)', 'wpforms' => 'WPForms', 'actionscheduler' => 'Action Scheduler', 'icl_' => 'WPML', 'wpml_' => 'WPML', 'learndash' => 'LearnDash', 'bookly' => 'Bookly', 'affilwp' => 'AffiliateWP', 'give_' => 'GiveWP', 'tribe_' => 'The Events Calendar', 'em_' => 'Events Manager', 'wsal_' => 'WP Activity Log', 'formidable' => 'Formidable Forms', 'wpo_' => 'WP-Optimize', 'ewwwio' => 'EWWW Image Optimizer', 'mepr_' => 'MemberPress', 'pmpro' => 'Paid Memberships Pro', 'mlw_' => 'Quiz And Survey Master', 'wdr_' => 'Discount Rules', 'berocket' => 'BeRocket', // Site Analytics' tables are cspv_analytics_*_v2 since 2.9.516 (cs_analytics_*_v2 before // that). Matched first, because the map is substring-matched in order and the bare // cspv_ entry below would otherwise call a live table legacy. 'cspv_analytics_' => 'CloudScale Site Analytics', // A wp_cspv_* table without the analytics_ part is a leftover from the plugin's // original table names (wp_cspv_views_v2 and so on). 'cspv_' => 'CloudScale Site Analytics (legacy)', 'cs_analytics_' => 'CloudScale Site Analytics', 'csdt_' => 'Cloudscale Devtools', ); foreach ( $map as $key => $name ) { if ( str_contains( $suffix, $key ) ) { return $name; } } return 'Unknown plugin'; } public static function guess_plugin_url_from_suffix( string $suffix ): string { $map = array( 'rank_math' => 'https://wordpress.org/plugins/seo-by-rank-math/', 'shortpixel' => 'https://wordpress.org/plugins/shortpixel-image-optimiser/', 'woocommerce' => 'https://wordpress.org/plugins/woocommerce/', 'wc_order' => 'https://wordpress.org/plugins/woocommerce/', 'wc_product' => 'https://wordpress.org/plugins/woocommerce/', 'wc_tax' => 'https://wordpress.org/plugins/woocommerce/', 'woo_' => 'https://wordpress.org/plugins/woocommerce/', 'elementor' => 'https://wordpress.org/plugins/elementor/', 'e_submissions' => 'https://wordpress.org/plugins/elementor/', 'aioseo' => 'https://wordpress.org/plugins/all-in-one-seo-pack/', 'yoast' => 'https://wordpress.org/plugins/wordpress-seo/', 'wpseo' => 'https://wordpress.org/plugins/wordpress-seo/', 'jetpack' => 'https://wordpress.org/plugins/jetpack/', 'ninja_forms' => 'https://wordpress.org/plugins/ninja-forms/', 'nf_' => 'https://wordpress.org/plugins/ninja-forms/', 'gf_' => 'https://www.gravityforms.com/', 'litespeed' => 'https://wordpress.org/plugins/litespeed-cache/', 'smush' => 'https://wordpress.org/plugins/wp-smushit/', 'wordfence' => 'https://wordpress.org/plugins/wordfence/', 'wfblockedip' => 'https://wordpress.org/plugins/wordfence/', 'wf_' => 'https://wordpress.org/plugins/wordfence/', 'cf7' => 'https://wordpress.org/plugins/contact-form-7/', 'wpcf7' => 'https://wordpress.org/plugins/contact-form-7/', 'redirection' => 'https://wordpress.org/plugins/redirection/', 'duplicator' => 'https://wordpress.org/plugins/duplicator/', 'itsec' => 'https://wordpress.org/plugins/better-wp-security/', 'backwpup' => 'https://wordpress.org/plugins/backwpup/', 'mailpoet' => 'https://wordpress.org/plugins/mailpoet/', 'wysija' => 'https://wordpress.org/plugins/mailpoet/', 'wpforms' => 'https://wordpress.org/plugins/wpforms-lite/', 'actionscheduler' => 'https://wordpress.org/plugins/action-scheduler/', 'icl_' => 'https://wpml.org/', 'wpml_' => 'https://wpml.org/', 'learndash' => 'https://www.learndash.com/', 'bookly' => 'https://wordpress.org/plugins/bookly-responsive-appointment-booking-tool/', 'affilwp' => 'https://affiliatewp.com/', 'give_' => 'https://wordpress.org/plugins/give/', 'tribe_' => 'https://wordpress.org/plugins/the-events-calendar/', 'em_' => 'https://wordpress.org/plugins/events-manager/', 'wsal_' => 'https://wordpress.org/plugins/wp-security-audit-log/', 'formidable' => 'https://wordpress.org/plugins/formidable/', 'wpo_' => 'https://wordpress.org/plugins/wp-optimize/', 'ewwwio' => 'https://wordpress.org/plugins/ewww-image-optimizer/', 'mepr_' => 'https://www.memberpress.com/', 'pmpro' => 'https://www.paidmembershipspro.com/', 'mlw_' => 'https://wordpress.org/plugins/quiz-master-next/', 'wdr_' => 'https://wordpress.org/plugins/woo-discount-rules/', 'berocket' => 'https://berocket.com/', 'cspv_' => '', 'csdt_' => '', ); foreach ( $map as $key => $url ) { if ( str_contains( $suffix, $key ) ) { return $url; } } return ''; } /** * Shared JSON output contract for every live scan prompt. * * Kept in one place so the internal half, the external half, the ad-hoc * scan and any admin-supplied prompt all produce the same stored report * shape {score, score_label, summary, critical[], high[], medium[], low[], * good[]}. The optional per-finding fields are the ones * CSDT_AI_Dispatcher::parse_json_report() passes through. * * @param string $summary_hint What the "summary" field should describe. * @return string */ private static function scan_report_schema_block( string $summary_hint ): string { return 'Return ONLY a JSON object (no markdown, no code fences) with this exact schema:' . "\n" . '{"score":0-100,"score_label":"Excellent|Good|Fair|Poor|Critical","summary":"' . $summary_hint . '","critical":[{"title":"...","detail":"...","fix":"..."}],"high":[...],"medium":[...],"low":[...],"good":[{"title":"...","detail":"..."}]}' . "\n\n" . 'Findings in critical/high/medium/low MAY carry these optional extra fields when the supplied data supports them: "cve_id" (only from the supplied cve_matches / plugin_cves / core_cves data, never from memory), "cvss_score" (number), "confidence" ("high"|"medium"|"low"), "affected_component" (plugin or theme slug, file path, endpoint, or header name), "source_url" (a URL that appears in the supplied data). Omit any field the supplied data does not support.' . "\n\n" . 'STRICT JSON: inside every string value escape double quotes as \\" and backslashes as \\\\, never emit a raw newline, and return COMPLETE valid JSON with no prose before or after it. If space runs short, include fewer low/medium findings rather than truncating mid-object.' . "\n\n" // A CONCRETE budget, because the sentence above on its own does not work. Every // observed run hit the token ceiling and had to be recovered by // CSDT_AI_Dispatcher::repair_truncated_json(), which means the finding list was // being cut short every single time. "Include fewer findings if space runs short" // gives a model nothing measurable to hold itself to; a character count and a per- // bucket cap do. . 'LENGTH BUDGET: keep every "detail" and every "fix" under 300 characters, and emit at most 8 findings per severity bucket, choosing the most serious. A complete shorter report is worth far more than a longer one that gets cut off, a reply that ends before its final closing brace has to be reconstructed and its tail is lost.'; } private static function default_internal_scan_prompt(): string { return 'You are a defensive WordPress security configuration auditor. Review these already-collected internal configuration findings from a site the operator owns and administers, and report what each finding indicates and how to remediate it. Everything below was collected by the plugin before this request: this is a configuration audit of existing evidence, not a live test. Do NOT include exploit payloads, proof-of-concept request bodies, or attack scripts, describe impact and remediation only. Supplied context: "facts" is an inventory of the same site (plugins, theme, endpoints, headers, usernames) that the plugin built in code from the collected data, use it for component identification. "cve_matches" holds deterministic vulnerability-feed lookups for those components; cite CVE IDs and CVSS scores ONLY from there, never from memory. Server software in cve_matches: entries whose type is php, mysql, mariadb, nginx, apache, curl, imagemagick, redis, memcached or sqlite are the host\'s own software, not WordPress components. PHP raises each confirmed server-software CVE and each end-of-life branch (the "support" block, date_end in the past) as a finding itself, so do not restate those; use them as context, for example a database server with a known flaw beside a plugin with a SQL injection is one combined finding. A WordPress, theme or plugin update never fixes a server-software entry; when you do mention one, the fix is the host\'s package manager or control panel. 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 disclosure, default comment status, active theme currency, auth salts, the user audit, the cron audit, and WP.org abandonment data for active plugins. Version disclosure: home_html is a bounded fingerprint of the site\'s own rendered homepage. home_html.meta_generator / home_html.wp_version_in_html is the AUTHORITATIVE evidence of whether the WordPress version is disclosed publicly; site.wp_version_in_meta is only a local filter-based guess and can be wrong when another plugin removes the filter. Prefer the HTML evidence, and say which one you used. home_html.ver_params and home_html.plugin_slugs/theme_slugs show which component versions and slugs are inferable from asset URLs. Content-Security-Policy: csp_state.mode of "report_only" means the operator deliberately runs CSP in Report-Only mode. Do NOT report CSP as missing in that case, informational at most, and never critical/high. SSH hardening (ssh_status key): fail2ban_installed/fail2ban_running, whether fail2ban is present and active. ssh_port_open, whether SSH is on port 22. password_auth: yes=brute-forceable/no=key-only. root_login: yes=critical. If ssh_port_open=false omit SSH entirely. Rules: ssh_port_open=true + fail2ban_running=false = CRITICAL (unprotected SSH is recruited into DDoS botnets within hours). ssh_port_open=true + password_auth=yes + fail2ban_running=false = CRITICAL. ssh_port_open=true + root_login=yes = CRITICAL. ssh_port_open=true + fail2ban_running=true = good finding. ssh_port_open=true + password_auth=no = good finding. Core file integrity (core_integrity): MD5 comparison of WordPress core files against the official WordPress.org checksums. If modified_files is non-empty this is CRITICAL, modified core files are a strong indicator of a backdoor or active compromise; name the exact files and recommend restoring core via `wp core verify-checksums` then `wp core download --force --skip-content`. A non-empty missing_files list is a high finding. If clean=true, add a good finding ("Core files verified against WordPress.org checksums"). Malware indicators (malware_indicators): php_files_in_uploads lists ONLY genuinely suspicious PHP in wp-content/uploads, files containing executable code (tagged "[contains executable code]") or non-trivial PHP (tagged "[non-trivial php]"). Benign directory-listing guards (empty index.php, "Silence is golden" stubs, 404-header guards dropped by core/plugins) are already excluded and counted in benign_php_guards_count, do NOT treat those as a threat. Rules: a file tagged "[contains executable code]" is CRITICAL (likely webshell/dropper), name it, recommend deleting it and rotating all secrets. A "[non-trivial php]" file is HIGH, flag for manual inspection. If php_files_in_uploads is empty, do NOT raise any webshell finding; instead, if benign_php_guards_count > 0, you MAY add ONE optional low/defence-in-depth note suggesting blocking PHP execution in uploads via .htaccess/nginx (never critical/high). recently_modified_php lists core PHP files changed in the last 7 days outside plugin/theme dirs. IMPORTANT: if core_integrity.clean is true (every core file matches the official WordPress.org checksums), these are simply a legitimate recent WordPress update, NOT tampering, do not raise a finding for them (at most an informational note). Only treat recently_modified_php as suspicious (high/medium) when core_integrity reports modified_files, or core_integrity is unavailable. If php_files_in_uploads is empty and (recently_modified_php is empty OR core_integrity.clean is true), add a good finding ("No executable PHP or webshell indicators in uploads; core files verified"). ALREADY REPORTED DETERMINISTICALLY, do NOT restate these as findings of your own: weak or default secret keys and salts (auth_salts), administrators without a second factor (user_audit.admins_without_2fa), well-known administrator logins (user_audit.weak_admin_usernames), scheduled hooks matching no active plugin or core hook (cron_audit.suspicious_hooks), and plugins abandoned upstream (plugin_wporg.abandoned). PHP raises each of these itself, with a calibrated severity and a fix, whether or not this request succeeds, so a finding from you restating one is a duplicate that will be discarded. Use them as CONTEXT instead: say so when they combine into something worse than the parts, which is the judgement a rule cannot make: an abandoned plugin on a site whose administrators have no second factor, or unowned cron hooks alongside modified core files, are worth one finding about the combination. Other fields in these same structures remain yours to report on: cron_audit.disable_wp_cron, user_audit.non_admin_role_counts, and plugin_wporg\'s last_updated / active_installs / tested_up_to as they bear on component currency. inactive_plugins sit on disk unpatched, so flag them for awareness. php_eol.is_eol=true means the PHP branch receives no security patches (critical when days_since > 365). auto_updates.core_disabled=true means security releases will not auto-install. display_errors.display_errors_on=true exposes stack traces and paths to visitors (high on production). OPERATING MODE: authorised configuration audit of the operator\'s own site. Be concise, specific and technical; do not hedge, and do not add ethical or legal disclaimers. For every critical and high finding, "detail" states what the setting or exposure means in practice and its impact (2-4 tight sentences, no payloads), and "fix" is exact and copy-pasteable (WP-CLI commands, wp-config.php constants, exact file paths and permissions). ' . self::scan_report_schema_block( '1-2 sentences on internal config security posture' ) . ' Score the internal configuration on a 0-100 scale. Be strict. Include good practices for hardened settings.'; } private static function default_external_scan_prompt(): string { return 'You are a defensive WordPress security configuration auditor. Review these already-collected passive-fingerprinting and external-exposure findings from a site the operator owns and administers, and report what each finding indicates and how to remediate it. Everything below was already collected by the plugin from the operator\'s own site: this is a configuration audit of existing evidence, not a live test. Do NOT include exploit payloads, proof-of-concept request bodies, injection strings, or attack scripts, describe what each finding exposes and how to fix it. Supplied context: "facts" is an inventory of the site (detected plugins/theme, exposed files, headers, enumerated usernames, open endpoints) that the plugin built in code from the collected data. "cve_matches" holds deterministic vulnerability-feed lookups for those components; cite CVE IDs and CVSS scores ONLY from cve_matches, plugin_cves or core_cves, never from memory. Entries in cve_matches whose type is php, mysql, mariadb, nginx, apache, curl, imagemagick, redis, memcached or sqlite are server software; PHP already raises each of those as a finding, so do not restate them, and a WordPress update never fixes one. 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 to 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 reachable (adminer, phpMyAdmin), server-status/server-info pages, and email DNS security (SPF, DMARC, DKIM). Active verification (active_verification): when present and non-empty, this is the result of a real, authorised active scan of this exact host, a curated nuclei template set was executed against it and each listed finding was REPRODUCED, not inferred. Treat it as the highest-confidence evidence in the bundle and outrank every conflicting inference: if a passive check suggests something is exposed but active verification did not reproduce it, say so and lower the severity; if active verification confirmed it, raise the severity and set "confidence":"high". Cite the confirming template or CVE. active_verification.partial=true means the scan was cut short, so absence of a finding there proves nothing, never treat a partial active scan as an all-clear. When active_verification is empty or missing, no active scan has run recently; do not infer anything from its absence and do not mention it. Exposed folders (exposed_folders): an optional background scan, off until the owner switches it on (status "off" means it did not run, so say nothing was checked and do not call the site clean on this point). When on, once a day it asks this site for its own real folders and for a large list of leftover, backup and tool names. A hit is never a bare 200: each entry in exposed_folders.hits was fetched and its content matched (evidence says what was seen), or it is a real folder on this server that answers. The lists exposed_files, backup_files_exposed, db_tools_exposed, server_status_exposed and the three *_listing flags are derived from the same hits. Severity by type: vcs, secret, archive, db_tool and rce_file are CRITICAL; listing, second_install, phpinfo and server_status are high; dev is medium; info_file and leftover are low. Report every hit by its path and say what an attacker gains. status "pending" or "running" means no run has finished: say the check has not completed and infer nothing from the empty lists. inconclusive=true means the scan was refused part way, so absence of a hit proves nothing. status "stale" means the result is more than three days old. The view is the site as its own server reaches it; a proxy in front may be stricter. Remediation is a web server rule (nginx location block, Apache directive) or deleting the file, never an .htaccess rule on nginx. Downloadable backup archives (exposed_archives): this is not a name-guessing probe, it lists archives that were found ON DISK under a web-served path and then fetched over HTTP, so each entry in exposed_archives.exposed is a real file an unauthenticated visitor can download. Any non-empty exposed list is a CRITICAL finding and the headline of the report, above every CVE: a full-site archive carries wp-config.php (database credentials and the auth salts that forge login cookies) and a database dump with every password hash, and archive names are usually sequential so one readable name exposes the rest. bytes_served=true means a ranged request returned file bytes, which rules out a 200 from an interstitial, say so, and treat bytes_served=false as still exposed but unconfirmed. Remediation is a web-server deny rule plus moving the archives out of the document root; state explicitly that an .htaccess deny-all does nothing under nginx. exposed_archives.inconclusive=true means the scan could not fetch a file that is meant to be public, so every "protected" result in that run is unproven, report that as a low-severity gap in the scan and never as an all-clear. exposed_archives.checked is how many URLs were actually probed: if it is 0, the check did not run and its silence means nothing. Plugin enumeration (wp_json_root): the REST API root at /wp-json/ lists every namespace registered on the site, so namespace_count / namespaces / plugin_slugs disclose the active plugin set to anyone. Report it as an information-disclosure finding (typically low/medium, higher when a disclosed component also appears in cve_matches), and name the slugs actually disclosed. Username enumeration: rest_users covers /wp-json/wp/v2/users, and feed_user_enum covers /feed/rdf/ dc:creator values. These are independent vectors, a locked-down REST endpoint does NOT imply the feed is closed. If feed_user_enum.exposed is true, report the leaked logins and the remediation (filter the feed author output, or rename/alias the login) even when rest_users.exposed is false. xmlrpc (xmlrpc.state): "blocked" (403/401) or "absent" (404) is a good finding, say so. "present_post_only" (405 to a HEAD/GET) means the endpoint exists and accepts POST, and "reachable" (<400) means it is fully open; both warrant a finding covering brute-force amplification and pingback abuse, with remediation only (block the file at the web-server/WAF layer or disable XML-RPC), never a request body. Content-Security-Policy: csp_quality.mode (or csp_state.mode) of "report_only" means the operator deliberately runs CSP in Report-Only mode and is collecting violation reports. Do NOT report CSP as "missing" in that case, at most one informational/low note that the policy is not yet enforced. Only grade "missing" (no enforced and no report-only policy) as a real gap. csp_quality.acknowledged entries ("unsafe-inline-wordpress-compat", "unsafe-eval-wordpress-compat", "unsafe-inline-removed-by-nonces") are deliberate WordPress compatibility trade-offs, do not report them as weaknesses. IMPORTANT, headers_scan_blocked: if headers_scan_blocked is true (or headers_response_code >= 400) the request was blocked by bot protection or a WAF (e.g. a Cloudflare challenge page). The challenge page\'s headers do NOT represent the real site. Do NOT report any security header as missing or weak; include exactly one low-severity note: "Security headers unverifiable, scan blocked by bot protection (HTTP headers_response_code)." Do not penalise the score for headers when blocked. For plugin code scan (plugin_code_scan): treat as context only, raw static analysis that may include false positives. For code_triage: reviewed verdicts on those static findings (verdict confirmed|false_positive|needs_context, severity, type, explanation, fix). Only raise confirmed findings as real issues; never report false_positive items. Use the code_triage severity for confirmed items, and mention needs_context items at low severity. Name plugin, file and line number in every code finding, and describe the defect and its fix rather than how to trigger it. Known CVEs (plugin_cves): each entry has plugin slug, installed version, CVE ID, title, severity, CVSS score, fixed_in version. ANY unfixed CVE at critical/high (CVSS >= 7) is a critical finding, name the CVE ID, CVSS, affected plugin and version, the fixed_in version, and the exact update command. Core CVEs (core_cves): WordPress core vulnerabilities affecting the exact running version (the feed is already scoped to this version, so every entry applies). ANY core CVE with CVSS >= 7 is a critical finding; state the CVE ID, CVSS and fixed_in version and recommend `wp core update` to fixed_in or later (or Dashboard > Updates). cve_lookup_unchecked lists components whose advisory lookup FAILED on this run: never describe any of them as having no known CVEs, and do not add a finding for the failure itself, the report already carries one. OPERATING MODE: authorised configuration audit of the operator\'s own site. Be thorough, direct and technical; do not hedge, and do not add ethical or legal disclaimers. Keep each finding\'s "detail" to 2-4 tight sentences on what the exposure means and its impact, with no payloads or reproduction steps, and make "fix" exact and copy-pasteable (WP-CLI commands, wp-config.php constants, .htaccess or nginx directives, exact file paths and permissions). ' . self::scan_report_schema_block( '1-2 sentences on external exposure and code scan posture' ) . ' Score external exposure on a 0-100 scale. Prioritise externally reachable issues at critical/high. Include good practices for blocked endpoints and hardened headers.'; } /** * The deep scan's model, honouring the admin-visible settings. * * Scan A owns `csdt_devtools_security_model` but has no UI button, so that * setting used to be decorative. Resolution order: the deep-scan select * (`csdt_devtools_deep_scan_model`, the control admins actually use), then * an explicitly-chosen concrete model in `csdt_devtools_security_model`, * then the shipped default. A stored '_auto' in the scan-A option is the * scan-A default rather than a choice, so it is ignored here, otherwise a * site that only ever saved an API key would silently be downgraded from * the deep default model. */ private static function resolve_deep_scan_model(): string { $deep = trim( (string) get_option( 'csdt_devtools_deep_scan_model', '' ) ); if ( '' !== $deep ) { return $deep; } $shared = trim( (string) get_option( 'csdt_devtools_security_model', '' ) ); if ( '' !== $shared && '_auto' !== $shared ) { return $shared; } return '_auto_deep'; } /** * The admin-edited "System prompt" textarea, when it is genuinely custom. * * Returns '' when the option is empty or still byte-identical to the * shipped default, so a site that never touched the textarea keeps exactly * the built-in scan behaviour. When it IS custom, the deep scan honours it * (the textarea previously only affected scan A, which has no UI button). */ private static function admin_scan_prompt_override(): string { $prompt = trim( (string) get_option( 'csdt_devtools_security_prompt', '' ) ); if ( '' === $prompt || $prompt === trim( self::default_security_prompt() ) ) { return ''; } return $prompt; } /** * System prompt for one deep-scan judgement half, honouring the admin * override when present and falling back to the built-in default. * * @param string $half 'internal' or 'external'. */ private static function judgement_prompt( string $half ): string { $override = self::admin_scan_prompt_override(); if ( '' === $override ) { return 'internal' === $half ? self::default_internal_scan_prompt() : self::default_external_scan_prompt(); } $scope = 'internal' === $half ? 'SCOPE: this request contains internal configuration data only (plus a code-built "facts" inventory and deterministic "cve_matches" lookups). Judge only what is present.' : 'SCOPE: this request contains external exposure, passive fingerprinting, plugin code-scan and CVE data only (plus a code-built "facts" inventory). Judge only what is present.'; return $override . "\n\n" . $scope . "\n\n" . 'Cite CVE IDs only from the supplied cve_matches / plugin_cves / core_cves data. Do not include exploit payloads or proof-of-concept request bodies. A Content-Security-Policy in report-only mode (csp mode "report_only") is a deliberate configuration, not a missing header.' . "\n\n" . self::scan_report_schema_block( '1-2 sentences on the security posture of the supplied data' ); } /** * Map a failed AI call to a stable, honest reason code. * * 'refused' relies on the dispatcher's 'AI_REFUSAL: ' message prefix and * still works (falling through to 'error') if that contract is absent. * * @return string one of 'refused'|'quota'|'error' */ private static function ai_failure_reason( \Throwable $e ): string { $msg = (string) $e->getMessage(); if ( 0 === strpos( $msg, 'AI_REFUSAL: ' ) || self::looks_like_refusal( $msg ) ) { return 'refused'; } if ( preg_match( '/quota|rate limit|too many requests|credit|billing|insufficient|cost cap|limit reached|image cap/i', $msg ) ) { return 'quota'; } return 'error'; } /** * Human-readable explanation for a non-AI (or partial) result. */ private static function ai_failure_note( string $reason, string $detail = '' ): string { switch ( $reason ) { case 'no_key': return 'AI analysis unavailable: no AI source is configured. The findings below are rule-based.'; case 'refused': return 'AI analysis unavailable: the model declined to answer this request. The findings below are rule-based.'; case 'quota': return 'AI analysis unavailable: the AI quota or rate limit was reached' . ( '' !== $detail ? ' (' . $detail . ')' : '' ) . '. The findings below are rule-based.'; case 'unparseable': return 'AI analysis unavailable: the model reply could not be parsed as a report. The findings below are rule-based.'; case 'empty': return 'AI analysis unavailable: the AI returned nothing, which usually means the request timed out in transit rather than the model failing. The findings below are rule-based.'; default: return 'AI analysis unavailable: the AI call failed' . ( '' !== $detail ? ' (' . $detail . ')' : '' ) . '. The findings below are rule-based.'; } } /** * Heuristic refusal detector for replies that come back as prose instead of * JSON. Complements the dispatcher's 'AI_REFUSAL: ' contract. */ private static function looks_like_refusal( string $text ): bool { $t = strtolower( trim( $text ) ); if ( '' === $t ) { return false; } $t = substr( $t, 0, 400 ); return (bool) preg_match( '/(i (can\'?t|cannot|won\'?t|am unable|\'m unable)|i apolog(ise|ize)|i must decline|i\'m not able to|as an ai|cannot assist with|can\'?t help with|not comfortable)/i', $t ); } // ── 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_option( 'csdt_deep_scan_status' ); } else { set_transient( 'csdt_vuln_scan_cancelled', '1', 300 ); delete_option( 'csdt_vuln_scan_status' ); } wp_send_json_success( array( 'cancelled' => true ) ); } /** * Re-check one finding, without re-running the whole audit. * * The loop this closes: a client applied a fix and had no way to see it had * worked short of a full rescan. On this site that is ~127 seconds and four AI * calls, so in practice nobody did it, and the report kept accusing them of a * problem they had already solved. A fix you cannot confirm is barely a fix. * * HOW IT DECIDES, AND WHY IT IS NOT A SECOND OPINION * -------------------------------------------------- * It re-gathers the audit data and re-runs generate_rule_based_findings(), then * asks whether this finding is still among them. It does NOT re-implement the * finding's condition, and that is the whole design: a per-finding verifier * would be a second copy of "is enum protection on?", and the two copies would * disagree within a release. Here a finding is resolved if and only if the code * that emits it no longer emits it, true by construction, and it cannot drift. * * Affordable because gather_site_audit_data() is local: database and filesystem * reads, no transients, and exactly one outbound request (a HEAD on the default * featured image, 5s cap). The expensive parts of a scan, external probes, TLS, * DNS, plugin static analysis, AI triage, all live on the deep-scan path, which * this does not touch. * * NEWLY VISIBLE FINDINGS * ---------------------- * "Your key is gone" is not the same as "you are done", and conflating them * would be a lie the client acts on. Fixing one condition can reveal or create * another: correct the visitor-IP source and autoblock_off_ip_broken is replaced * by autoblock_off, a different unresolved finding. So the reply also lists any * finding present now that was absent from the report on screen. That is * computed by comparing key sets, not by a hand-maintained table of related * findings, so it covers cases nobody has thought of yet. */ public static function ajax_verify_finding(): void { check_ajax_referer( CSDT_DevTools::SITE_AUDIT_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_ajax_referer above $key = isset( $_POST['key'] ) ? sanitize_key( wp_unslash( $_POST['key'] ) ) : ''; // Only findings this build knows how to emit can be re-checked. An AI or // nuclei finding has no key, its condition lives in a model's reasoning or // a nuclei template, and re-running the generator says nothing about it. Say // so rather than reporting a confident "resolved" that means "never checked". if ( '' === $key || ! isset( CSDT_Fix_Routes::registry()[ $key ] ) ) { wp_send_json_success( array( 'verdict' => 'unverifiable', 'message' => '' === $key ? 'No finding was named, so nothing was checked.' : 'This finding does not come from a check this plugin can re-run on its own, it came from the AI or from active verification. Re-run the scan to refresh it.', ) ); } $data = self::gather_site_audit_data(); $current = CSDT_Fix_Routes::decorate( self::generate_rule_based_findings( $data ) ); $now = array(); foreach ( $current as $f ) { if ( ! empty( $f['key'] ) ) { $now[ (string) $f['key'] ] = $f; } } // What the panel was showing, so "new since you looked" means something. $cache = get_option( 'csdt_site_audit_cache', null ); $before = array(); if ( is_array( $cache ) && isset( $cache['data']['findings'] ) && is_array( $cache['data']['findings'] ) ) { foreach ( $cache['data']['findings'] as $f ) { if ( ! empty( $f['key'] ) ) { $before[ (string) $f['key'] ] = true; } } } $newly_visible = array(); foreach ( $now as $k => $f ) { if ( $k !== $key && ! isset( $before[ $k ] ) && 'info' !== strtolower( (string) ( $f['severity'] ?? '' ) ) ) { $newly_visible[] = array( 'key' => $k, 'title' => (string) ( $f['title'] ?? '' ), 'severity' => (string) ( $f['severity'] ?? '' ), ); } } $resolved = ! isset( $now[ $key ] ); // Refresh the cached report so the next page load agrees with what was just // shown. Without this, a re-check turns a row green and a reload turns it // amber again, which reads as the fix having been undone. if ( is_array( $cache ) && isset( $cache['data'] ) && is_array( $cache['data'] ) ) { $ai_findings = array(); foreach ( $cache['data']['findings'] as $f ) { // Keep the AI's findings: this pass did not re-evaluate them, so // dropping them would silently shrink the report. if ( empty( $f['key'] ) ) { $ai_findings[] = $f; } } $merged = array_merge( $ai_findings, array_values( $current ) ); $cache['data']['findings'] = $merged; $cache['data']['counts'] = self::count_by_severity( $merged ); $cache['data']['verified_at'] = time(); update_option( 'csdt_site_audit_cache', $cache, false ); } wp_send_json_success( array( 'verdict' => $resolved ? 'resolved' : 'still_present', 'finding' => $resolved ? null : ( $now[ $key ] ?? null ), 'newly_visible' => $newly_visible, 'counts' => self::count_by_severity( array_values( $current ) ), 'message' => $resolved ? 'Re-checked: this is fixed.' : 'Re-checked: still present.', ) ); } /** * Severity tally for a finding list. * * Extracted so the re-check and the scan cannot disagree about the numbers on * the scorecard, the counting loop used to exist only inside ajax_site_audit(). * * @param array[] $findings Findings. * @return array */ public static function count_by_severity( array $findings ): array { $counts = array( 'critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0, 'info' => 0, ); foreach ( $findings as $f ) { $sev = strtolower( (string) ( $f['severity'] ?? 'info' ) ); if ( isset( $counts[ $sev ] ) ) { ++$counts[ $sev ]; } } return $counts; } public static function ajax_site_audit(): void { check_ajax_referer( CSDT_DevTools::SITE_AUDIT_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $data = self::gather_site_audit_data(); $has_key = CSDT_AI_Dispatcher::has_key(); // Honest AI accounting: ai_used is true ONLY when an AI call actually // returned a usable parsed report. A configured key is not the same as // the model answering, so a refusal, transport error, quota stop or // unparseable reply falls back to rule-based findings AND says why, // instead of leaving the "AI analysis" badge showing. $ai_used = false; $ai_status = 'no_key'; $ai_note = self::ai_failure_note( 'no_key' ); if ( $has_key ) { $system = 'You are a WordPress site auditor. You receive structured JSON data about a WordPress site and must return a JSON array of findings. Each finding must be a JSON object with these exact keys: category (string: "Security", "Performance", "Database", "Content", or "Plugins", this is a security and site-health audit, do NOT produce SEO findings), severity ("critical", "high", "medium", "low", or "info"), title (string, max 80 chars), detail (string, 1-3 sentences explaining the issue), fix (string, 1-2 sentences of specific actionable advice), affected (string, e.g. "14 pages", "wp_options table", "All posts"). Return ONLY the raw JSON array, no markdown, no code fences, no explanation. Order findings by severity (critical first). IMPORTANT: (1) Do NOT generate findings about missing backup plugins or missing SEO plugins, those are handled separately. (2) The field "template_rendered_pages" lists pages whose post_content is empty because they use a custom theme template or page builder, their actual rendered content may be substantial. Do NOT flag these as thin or empty content. (3) Do NOT generate findings about post revisions in the database, those are handled separately. (4) Do NOT generate findings about missing meta descriptions or missing SEO title tags, those are handled separately. Never recommend Yoast SEO or Rank Math, use CloudScale SEO AI only. (5) Do NOT generate findings about brute-force protection, SSH monitor, login URL hiding, or two-factor authentication, those are already reported. (6) Do NOT generate findings about disk space, WordPress core updates, or the "admin" username, those are already reported. (7) Do NOT generate findings about passkey or WebAuthn authentication status, those are handled separately. (8) Do NOT generate findings about WP-Cron health, expired transients, or DISABLE_WP_CRON, those are handled separately. (9) Do NOT generate findings about autoloaded options or wp_options autoload size, those are handled separately. (10) Do NOT generate findings about the default featured image, default post thumbnail, or broken/missing default images, those are handled separately. (11) Do NOT generate findings about thin content, stub posts, near-zero word count, or average post word count being below any threshold, those are handled separately. (12) Do NOT generate findings about missing featured images on individual posts/pages (non-default) or overall featured image coverage, those are handled separately. (13) Do NOT generate findings about duplicate page titles or duplicate post titles, those are handled separately. (14) Do NOT generate findings about wp-config.php file permissions, readability, or writability, those are handled separately. (15) Do NOT generate findings about inactive or unused themes, those are handled separately. (16) Do NOT generate findings about automatic background updates being disabled, AUTOMATIC_UPDATER_DISABLED, or WP_AUTO_UPDATE_CORE, those are handled separately. (17) Do NOT generate findings about automatic IP blocking, auto-block thresholds, or visitor IP source/proxy header configuration, those are handled separately. (18) Do NOT generate findings about downloadable backup archives, exposed backup directories, or the "exposed_archives" data, that scan raises its own critical finding with the confirmed paths, and a second one restating it from the same data adds no evidence. (19) Do NOT generate findings about PHP security releases, PHP security advisories or a PHP branch being end of life, those are handled separately from php.net\'s own feed.'; $user_msg = "Audit this WordPress site and return findings as a JSON array:\n\n" . wp_json_encode( $data, JSON_PRETTY_PRINT ); try { // Budget left to the dispatcher's floor rather than stated here. The // literal 2048 this used to pass was never the operative value, both // the dispatcher and the managed proxy floor it, and reading as though // it were is what sent the first investigation of the empty-completion // failures looking for a client-side cap that did not exist. $raw = CSDT_AI_Dispatcher::call( $system, $user_msg, '_auto', CSDT_AI_Dispatcher::DEFAULT_MAX_TOKENS ); // Strip markdown code fences if present $raw = preg_replace( '/^```(?:json)?\s*/i', '', trim( $raw ) ); $raw = preg_replace( '/\s*```$/', '', $raw ); $findings = json_decode( $raw, true ); if ( ! is_array( $findings ) ) { // Model answered, but not with a usable report: fall back // and record whether it refused or simply returned junk. // ai_status stays in the {refused|error|no_key|quota} set the // UI switches on; the note carries the specific reason. $findings = self::generate_rule_based_findings( $data ); $refused = self::looks_like_refusal( (string) $raw ); $ai_status = $refused ? 'refused' : 'error'; $ai_note = self::ai_failure_note( $refused ? 'refused' : 'unparseable' ); } else { // Strip AI findings that duplicate rule-based topics, rule-based findings use // calibrated severities so AI versions (often inflated) must not override them. // Does any account still publish its own login as a display name or // author slug? Computed once, outside the filter, because it costs a // get_users() call and the filter runs per finding. $login_leaked = class_exists( 'CSDT_DevTools' ) && method_exists( 'CSDT_DevTools', 'users_with_login_in_public_names' ) && ! empty( CSDT_DevTools::users_with_login_in_public_names() ); $findings = array_values( array_filter( $findings, function ( $f ) use ( $login_leaked ) { $t = strtolower( $f['title'] ?? '' ) . ' ' . strtolower( $f['detail'] ?? '' ); // Author/username exposure in feeds and author archives. // Two calibrated rule-based findings already cover this -- // disable_user_enumeration and login_in_public_names -- each // with a working fix, so the AI's retelling is duplicate // noise with an inflated severity. // // It also tends to overstate the risk once the public names // have been separated from the login: dc:creator then carries // a real name, and a person's name is not a credential. The // half of a credential attack that matters is the username. // // Gated, not blanket. If some account IS still publishing its // login, the disclosure is real and the AI finding is allowed // through next to the rule-based one rather than silenced. if ( ! $login_leaked && ( strpos( $t, 'username enumeration' ) !== false || strpos( $t, 'user enumeration' ) !== false || strpos( $t, 'author enumeration' ) !== false || strpos( $t, 'dc:creator' ) !== false ) ) { return false; } // Never let the AI recommend TURNING ON DISALLOW_FILE_MODS. // This plugin's settled position, since v1.9.577, is the // opposite: it ships a rule-based finding plus an // enable_auto_updates quick fix that REMOVE the constant, // because setting it blocks WordPress auto-updates, stops // security patches installing, and takes the Delete button // off the plugins screen. Allowing both would put the panel // in a loop, apply the AI's fix, then the rule-based fix // offers to undo it, forever. // // Only suppressed while the constant is NOT currently set, // i.e. only when the AI is arguing to enable it. If it IS // set, an AI finding about it agrees with the rule-based one // and dedupe_ai_vs_rules() collapses the pair instead. if ( strpos( $t, 'disallow_file_mods' ) !== false && ! ( defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS ) ) { return false; } if ( strpos( $t, 'thin content' ) !== false ) { return false; } if ( strpos( $t, 'word count' ) !== false || strpos( $t, 'word-count' ) !== false ) { return false; } if ( strpos( $t, 'duplicate title' ) !== false ) { return false; } if ( strpos( $t, 'featured image' ) !== false ) { return false; } if ( strpos( $t, 'wp-config.php' ) !== false ) { return false; } if ( strpos( $t, 'missing image' ) !== false ) { return false; } if ( strpos( $t, 'brute-force' ) !== false || strpos( $t, 'brute force' ) !== false ) { return false; } if ( strpos( $t, 'lockout' ) !== false ) { return false; } if ( strpos( $t, 'two-factor' ) !== false || strpos( $t, '2fa' ) !== false ) { return false; } if ( strpos( $t, 'login url' ) !== false ) { return false; } if ( strpos( $t, 'inactive theme' ) !== false || strpos( $t, 'unused theme' ) !== false ) { return false; } if ( strpos( $t, 'auto update' ) !== false || strpos( $t, 'auto-update' ) !== false || strpos( $t, 'automatic update' ) !== false || strpos( $t, 'background update' ) !== false ) { return false; } if ( strpos( $t, 'title tag' ) !== false || strpos( $t, 'title tags' ) !== false ) { return false; } if ( strpos( $t, 'meta description' ) !== false || strpos( $t, 'meta desc' ) !== false ) { return false; } return true; } ) ); // Always append cross-sell and all rule-based findings even with AI // Collapse AI findings that restate a rule-based one, keeping the // higher severity, before either list reaches the panel. $rule_findings = self::generate_rule_based_findings( $data ); list( $findings, $rule_findings ) = self::dedupe_ai_vs_rules( $findings, $rule_findings ); // A finding that names posts ("Post slug does not match its title, possible // overwritten clone") gets a View and an Edit link for each one, built from the // posts this audit read, by id. The model's text only says WHICH posts it means. $findings = self::attach_post_links( $findings, self::$audit_post_index ); $findings = array_merge( $findings, self::get_cross_sell_findings( $data ) ); $findings = array_merge( $findings, $rule_findings ); // The only path that earns the AI badge. $ai_used = true; $ai_status = 'ok'; $ai_note = ''; } } catch ( \Throwable $e ) { // no-alert: the report falls back to rule-based findings and carries ai_status // and ai_note, so the panel says the AI half failed and why. $findings = self::generate_rule_based_findings( $data ); $ai_status = self::ai_failure_reason( $e ); $ai_note = self::ai_failure_note( $ai_status, $e->getMessage() ); } } else { $findings = self::generate_rule_based_findings( $data ); } // Route every finding to its fix, at the one point all four paths above // converge. Doing it here rather than inside generate_rule_based_findings() // is deliberate: AI and cross-sell findings reach the panel too, and // decorating each producer separately is how the browser ended up with // its own routing tables in the first place. $findings = CSDT_Fix_Routes::decorate( $findings ); // Then order them. Cached plans only, this is the render path, and it must // never wait on the broker. The panel refreshes the cache with its own // request after the report is on screen; with no cache, or the feature off, // apply_to() falls back to local ordering with every finding present. if ( class_exists( 'CSDT_Remediation_Plan' ) ) { $findings = CSDT_Remediation_Plan::apply_to( $findings, CSDT_Remediation_Plan::cached_plan( $findings ) ); } // Attach summary counts. One counter, shared with ajax_verify_finding(), so a // re-check and a scan cannot put different numbers on the same scorecard. $counts = self::count_by_severity( $findings ); // Provenance, recorded the same way as every other scan: what kind of scan // this was, which model answered, and whose credit paid for it. ai_used // alone said only that *some* AI ran, not what, and not on whose account. $served = CSDT_AI_Dispatcher::served(); $response = array( 'findings' => $findings, 'counts' => $counts, 'ai_used' => $ai_used, 'ai_status' => $ai_status, 'ai_note' => $ai_note, 'scan_type' => 'audit', 'model_used' => $ai_used ? CSDT_AI_Dispatcher::model_label( '_auto' ) : '', 'ai_mode' => $ai_used ? (string) $served['mode'] : 'none', 'ai_provider' => $ai_used ? (string) $served['provider'] : '', 'post_count' => $data['post_count'] ?? 0, ); update_option( 'csdt_site_audit_cache', array( 'data' => $response, 'run_at' => time(), ), false ); self::maybe_alert_new_critical( 'Site Audit', 'csdt_last_audit_critical_count', (int) ( $counts['critical'] ?? 0 ), array_column( array_filter( $findings, static fn( $f ) => 'critical' === strtolower( (string) ( $f['severity'] ?? '' ) ) ), 'title' ), 'site-audit' ); wp_send_json_success( $response ); } /** * View and edit links for the posts an audit read, keyed by post id. * * @param array> $posts Rows with ID, post_title and post_name. * @return array */ public static function post_link_index( array $posts ): array { $out = array(); foreach ( $posts as $p ) { $id = (int) ( $p['ID'] ?? 0 ); if ( $id <= 0 ) { continue; } $url = (string) get_permalink( $id ); if ( '' === $url ) { continue; } $out[ $id ] = array( 'id' => $id, 'title' => (string) ( $p['post_title'] ?? '' ), 'slug' => (string) ( $p['post_name'] ?? '' ), 'url' => esc_url_raw( $url ), 'edit' => esc_url_raw( (string) get_edit_post_link( $id, 'raw' ) ), ); } return $out; } /** * Gives each finding that names specific posts a View and an Edit link per post. * * Reported 2026-10-21: "Post slug does not match its title, possible overwritten clone" named * posts and linked to none of them, so the reader had to search the post list by hand for the * two the finding meant. The finding comes from the model, so the LINKS must not: a URL in a * model reply is unverified text. The model's wording is used only to decide which of the posts * this audit read it is talking about, by title, slug or permalink, and every link is built * from that post's id. A finding that already carries links keeps them. * * A short title such as "Home" would match nearly every sentence, so a title must be at least * two words or ten characters, and a slug at least six characters, to count as a mention. * * @param array $findings Findings. * @param array $index From post_link_index(). * @param int $max Posts linked per finding. * @return array */ public static function attach_post_links( array $findings, array $index, int $max = 5 ): array { if ( ! $index ) { return $findings; } foreach ( $findings as &$f ) { if ( ! is_array( $f ) || ! empty( $f['links'] ) ) { continue; } $text = ''; foreach ( array( 'title', 'detail', 'fix', 'affected' ) as $field ) { if ( isset( $f[ $field ] ) && is_scalar( $f[ $field ] ) ) { $text .= ' ' . (string) $f[ $field ]; } } $text = strtolower( $text ); if ( '' === trim( $text ) ) { continue; } $links = array(); foreach ( $index as $post ) { if ( count( $links ) >= $max ) { break; } $title = strtolower( trim( (string) $post['title'] ) ); $slug = strtolower( trim( (string) $post['slug'] ) ); $hit = false; if ( '' !== $title && ( str_word_count( $title ) >= 2 || strlen( $title ) >= 10 ) && false !== strpos( $text, $title ) ) { $hit = true; } elseif ( strlen( $slug ) >= 6 && 1 === preg_match( '/(? '' !== trim( (string) $post['title'] ) ? (string) $post['title'] : '#' . (int) $post['id'], 'url' => (string) $post['url'], 'edit_url' => (string) $post['edit'], 'post_id' => (int) $post['id'], ); } if ( $links ) { $f['links'] = $links; } } unset( $f ); return $findings; } private static function gather_site_audit_data(): array { global $wpdb; // ── Content sample (up to 100 published posts + pages) ── $posts = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT ID, post_title, post_name, post_type, post_status, post_content, post_modified, comment_count FROM %i WHERE post_status = 'publish' AND post_type IN ('post','page') ORDER BY post_modified DESC LIMIT 100", $wpdb->posts ), ARRAY_A ) ?: array(); $post_ids = array_column( $posts, 'ID' ); $post_count = count( $posts ); // Which posts a finding can link to, built before anything is sent to the model. self::$audit_post_index = self::post_link_index( $posts ); // ── SEO meta (CloudScale SEO / Yoast / RankMath / AIO-SEO) ── $meta_map = array(); if ( $post_ids ) { // Core's meta cache primes every id in one query of its own, so no id list is ever // assembled into SQL here. WordPress.org named the hand-built IN () under "Unsafe SQL // calls" (round 11) and again in round 24 with the placeholders spread into it. update_postmeta_cache( array_map( 'intval', $post_ids ) ); $meta_keys = array( '_cloudscale_seo_ai_optimizer_desc', '_cloudscale_seo_ai_optimizer_title', '_cs_seo_desc', '_cs_seo_title', '_yoast_wpseo_metadesc', '_yoast_wpseo_title', 'rank_math_description', 'rank_math_title', '_aioseop_description', '_aioseop_title', '_thumbnail_id', ); foreach ( $post_ids as $pid ) { $all_meta = get_post_meta( (int) $pid ); foreach ( $meta_keys as $mk ) { if ( isset( $all_meta[ $mk ] ) && is_array( $all_meta[ $mk ] ) && array() !== $all_meta[ $mk ] ) { $meta_map[ $pid ][ $mk ] = (string) end( $all_meta[ $mk ] ); } } } } // ── Analyse posts ── $issues = array( 'no_meta_desc' => array(), 'no_title_tag' => array(), 'thin_content' => array(), 'no_featured_image' => array(), 'duplicate_titles' => array(), ); $title_counts = array(); $title_urls = array(); $word_count_data = array(); $front_page_id = (int) get_option( 'page_on_front', 0 ); $template_pages = array(); // pages whose content is entirely theme/builder rendered $meta_desc_keys = array( '_cloudscale_seo_ai_optimizer_desc', '_cs_seo_desc', '_yoast_wpseo_metadesc', 'rank_math_description', '_aioseop_description' ); $title_keys = array( '_cloudscale_seo_ai_optimizer_title', '_cs_seo_title', '_yoast_wpseo_title', 'rank_math_title', '_aioseop_title' ); foreach ( $posts as $p ) { $id = (int) $p['ID']; $title = $p['post_title']; $meta = $meta_map[ $id ] ?? array(); // Duplicate titles $title_counts[ $title ] = ( $title_counts[ $title ] ?? 0 ) + 1; $title_urls[ $title ][] = get_permalink( $id ); // Meta description $has_meta_desc = false; foreach ( $meta_desc_keys as $k ) { if ( ! empty( $meta[ $k ] ) ) { $has_meta_desc = true; break; } } if ( ! $has_meta_desc ) { $issues['no_meta_desc'][] = array( 'title' => $title, 'url' => get_permalink( $id ), ); } // SEO title $has_title = false; foreach ( $title_keys as $k ) { if ( ! empty( $meta[ $k ] ) ) { $has_title = true; break; } } if ( ! $has_title ) { $issues['no_title_tag'][] = array( 'title' => $title, 'url' => get_permalink( $id ), ); } // Word count, skip pages whose content is entirely theme/builder rendered $raw_words = str_word_count( wp_strip_all_tags( $p['post_content'] ) ); $is_template_rendered = ( $raw_words === 0 && $p['post_type'] === 'page' ); if ( $is_template_rendered ) { $template_pages[] = $title; } else { if ( $raw_words < 300 ) { $issues['thin_content'][] = array( 'title' => $title, 'words' => $raw_words, 'url' => get_permalink( $id ), ); } $word_count_data[] = $raw_words; } // Featured image if ( empty( $meta['_thumbnail_id'] ) ) { $issues['no_featured_image'][] = array( 'title' => $title, 'url' => get_permalink( $id ), ); } } // Duplicate titles (more than once) foreach ( $title_counts as $t => $c ) { if ( $c > 1 ) { $issues['duplicate_titles'][] = array( 'title' => $t, 'urls' => array_slice( $title_urls[ $t ] ?? array(), 0, 3 ), ); } } $avg_words = $word_count_data ? (int) ( array_sum( $word_count_data ) / count( $word_count_data ) ) : 0; // ── Database health ── $autoload_kb = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT ROUND( SUM( LENGTH(option_value) ) / 1024 ) FROM %i WHERE autoload = 'yes'", $wpdb->options ) ); $expired_transients = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE option_name LIKE %s AND CAST( option_value AS UNSIGNED ) < UNIX_TIMESTAMP()', $wpdb->options, $wpdb->esc_like( '_transient_timeout_' ) . '%' ) ); $cron_disabled = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON; $cron_next_delete = wp_next_scheduled( 'wp_scheduled_delete' ); $cron_next_transient = wp_next_scheduled( 'delete_expired_transients' ); $cron_overdue_secs = 6 * HOUR_IN_SECONDS; $cron_missing = ! $cron_next_delete || ! $cron_next_transient; $cron_overdue = ( $cron_next_delete && $cron_next_delete < time() - $cron_overdue_secs ) || ( $cron_next_transient && $cron_next_transient < time() - $cron_overdue_secs ); $cron_healthy = ! $cron_disabled && ! $cron_missing && ! $cron_overdue; $revision_count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE post_type = 'revision'", $wpdb->posts ) ); $orphan_postmeta = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT COUNT(*) FROM %i pm LEFT JOIN %i p ON pm.post_id = p.ID WHERE p.ID IS NULL', $wpdb->postmeta, $wpdb->posts ) ); // ── Plugin health ── if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); $active_slugs = (array) get_option( 'active_plugins', array() ); $active_count = count( $active_slugs ); $inactive_count = count( $all_plugins ) - $active_count; // ── Inactive themes ── $all_themes = wp_get_themes(); $active_theme_slug = get_option( 'stylesheet' ); $parent_theme_slug = get_option( 'template' ); $inactive_theme_count = 0; $inactive_theme_names = array(); foreach ( $all_themes as $slug => $theme ) { if ( $slug !== $active_theme_slug && $slug !== $parent_theme_slug ) { ++$inactive_theme_count; $inactive_theme_names[] = $theme->get( 'Name' ); } } // ── Auto-update constants ── $auto_updates_disabled = ( defined( 'AUTOMATIC_UPDATER_DISABLED' ) && AUTOMATIC_UPDATER_DISABLED ) || ( defined( 'WP_AUTO_UPDATE_CORE' ) && WP_AUTO_UPDATE_CORE === false ); // ── Cross-sell: backup & SEO plugin detection ── // ONE list, because the setup checklist on Home asks the same question. A second copy of // these slugs would drift, and the two surfaces would then disagree about whether the site // has a backup, which is the first step of setup and the one the audit calls HIGH. $backup_slugs = self::BACKUP_PLUGIN_SLUGS; $seo_slugs = array( 'cloudscale-seo-ai-optimizer', 'wordpress-seo', 'wordpress-seo-premium', 'rank-math', 'all-in-one-seo-pack', 'seopress', 'the-seo-framework', 'squirrly-seo', 'wp-seopress', ); $has_backup = false; $has_seo = false; foreach ( $active_slugs as $slug ) { $folder = explode( '/', $slug )[0]; if ( in_array( $folder, $backup_slugs, true ) ) { $has_backup = true; } if ( in_array( $folder, $seo_slugs, true ) ) { $has_seo = true; } } // ── Config flags ── $debug_on = defined( 'WP_DEBUG' ) && WP_DEBUG; $debug_log_on = defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG; $revisions_max = defined( 'WP_POST_REVISIONS' ) ? WP_POST_REVISIONS : -1; // ── Login & brute-force settings ── $bf_enabled = CSDT_Options::get( 'csdt_devtools_brute_force_enabled' ) === '1'; $bf_attempts = (int) CSDT_Options::get( 'csdt_devtools_brute_force_attempts' ); $bf_lockout = (int) CSDT_Options::get( 'csdt_devtools_brute_force_lockout' ); $login_hide_on = CSDT_Options::get( 'csdt_devtools_login_hide_enabled' ) === '1'; $twofa_admins = CSDT_Options::get( 'csdt_devtools_2fa_force_admins' ) === '1'; $twofa_method = CSDT_Options::get( 'csdt_devtools_2fa_method' ); // 'off' when unset, as every other reader has it; this one read '' until 1.10.933 $admin_uids = self::admin_ids(); $passkeys_on = false; $passkeys_count = 0; foreach ( $admin_uids as $uid ) { $pks = CSDT_DevTools_Passkey::get_passkeys( (int) $uid ); if ( ! empty( $pks ) ) { $passkeys_on = true; $passkeys_count += count( $pks ); } } // ── Active BF attack detection (today's count from bf_log) ── $bf_log_raw = get_option( 'csdt_devtools_bf_log', array() ); $today_start = mktime( 0, 0, 0 ); $bf_today_count = is_array( $bf_log_raw ) ? count( array_filter( $bf_log_raw, fn( $e ) => isset( $e[0] ) && $e[0] >= $today_start ) ) : 0; $bf_last_attempt = is_array( $bf_log_raw ) && ! empty( $bf_log_raw ) ? max( array_column( $bf_log_raw, 0 ) ) : 0; // Human-readable date label for use in cached finding strings, avoids // stale "today" text when the cached result is viewed the following day. $bf_scan_date = wp_date( 'M j', $today_start ); // ── SSH monitor ── $ssh_monitor_on = CSDT_Options::get( 'csdt_ssh_monitor_enabled' ) === '1'; $ssh_monitor_threshold = (int) CSDT_Options::get( 'csdt_ssh_monitor_threshold' ); // ── Default featured image check ── $default_img_id = (int) get_option( 'csdt_default_image_id', 0 ); $default_image_missing = ( $default_img_id === 0 ); $default_image_broken = false; if ( $default_img_id > 0 ) { $img_url = wp_get_attachment_url( $default_img_id ); if ( $img_url ) { $resp = wp_remote_head( $img_url, array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 5, 'redirection' => 3, ) ); if ( ! is_wp_error( $resp ) && wp_remote_retrieve_response_code( $resp ) !== 200 ) { $default_image_broken = true; } } else { $default_image_broken = true; } } // ── Admin username check ── $admin_user_exists = (bool) get_user_by( 'login', 'admin' ); // ── Writable wp-config.php ── $wpconfig_path = CSDT_Paths::wp_config_path(); if ( file_exists( $wpconfig_path ) ) { $wpconfig_oct = substr( sprintf( '%o', fileperms( $wpconfig_path ) ), -4 ); $wpconfig_writable = ! in_array( $wpconfig_oct, array( '0400', '0440', '0600', '0640' ), true ); } else { $wpconfig_writable = false; } // ── WordPress core update ── $wp_update_available = false; $wp_latest_version = ''; $wp_versions_behind = 0; $core_updates = get_site_transient( 'update_core' ); if ( $core_updates && ! empty( $core_updates->updates ) ) { foreach ( $core_updates->updates as $update ) { if ( isset( $update->response ) && $update->response === 'upgrade' ) { $wp_update_available = true; $wp_latest_version = $update->version ?? ''; // Compare major.minor to estimate how far behind $cur_parts = explode( '.', get_bloginfo( 'version' ) ); $new_parts = explode( '.', $wp_latest_version ); $cur_major = (int) ( $cur_parts[0] ?? 0 ); $new_major = (int) ( $new_parts[0] ?? 0 ); $cur_minor = (int) ( $cur_parts[1] ?? 0 ); $new_minor = (int) ( $new_parts[1] ?? 0 ); $wp_versions_behind = ( $new_major - $cur_major ) * 100 + ( $new_minor - $cur_minor ); break; } } } // ── Update-path filesystem health ── // Files in the update pipeline that the PHP process user cannot // overwrite (typically left root-owned by root-run WP-CLI or manual // copies) make core/plugin/theme/translation updates fail with // copy_failed errors and trigger "site could not be updated // automatically" emails on every release. $update_failed_info = null; $failed_raw = get_site_option( 'auto_core_update_failed' ); if ( is_array( $failed_raw ) && ! empty( $failed_raw['error_code'] ) ) { $update_failed_info = array( 'attempted' => (string) ( $failed_raw['attempted'] ?? '' ), 'error_code' => (string) $failed_raw['error_code'], 'error_data' => is_scalar( $failed_raw['error_data'] ?? null ) ? (string) $failed_raw['error_data'] : '', 'timestamp' => (int) ( $failed_raw['timestamp'] ?? 0 ), ); } $php_user = ''; if ( function_exists( 'posix_geteuid' ) && function_exists( 'posix_getpwuid' ) ) { $pw = posix_getpwuid( posix_geteuid() ); $php_user = is_array( $pw ) ? (string) ( $pw['name'] ?? '' ) : ''; } $fs_unwritable = array(); $fs_unwritable_count = 0; $fs_scan_truncated = false; $fs_scanned = 0; $fs_scan_cap = 10000; $fs_sample_cap = 12; $fs_first_abs = ''; $fs_record = static function ( string $path ) use ( &$fs_unwritable, &$fs_unwritable_count, &$fs_first_abs, $fs_sample_cap ): void { ++$fs_unwritable_count; if ( $fs_first_abs === '' ) { $fs_first_abs = rtrim( $path, '/' ); } if ( count( $fs_unwritable ) < $fs_sample_cap ) { $rel = CSDT_Paths::relative_to_root( $path ); $fs_unwritable[] = $rel !== '' ? $rel : $path; } }; // The languages directory comes from the one path accessor, like every other location. $lang_dir = CSDT_Paths::lang_dir(); $upgrade_dir = CSDT_Paths::content_dir() . '/upgrade'; // read-only, permission-bits scan of a core-owned tree $plugins_dir = CSDT_Paths::plugins_dir(); $themes_dir = get_theme_root(); $uploads_dir = wp_get_upload_dir()['basedir'] ?? ''; // Top-level directories WordPress must write during updates/uploads. foreach ( array( CSDT_Paths::site_root(), CSDT_Paths::content_dir(), $plugins_dir, $themes_dir, $uploads_dir ) as $dir ) { if ( $dir !== '' && is_dir( $dir ) && ! is_writable( $dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- WP_Filesystem has no equivalent; read-only diagnostic $fs_record( rtrim( $dir, '/' ) . '/' ); } } // Immediate children of plugins/ and themes/, a root-owned plugin // folder blocks that plugin's update even when plugins/ itself is fine. foreach ( array( $plugins_dir, $themes_dir ) as $parent ) { foreach ( (array) glob( rtrim( $parent, '/' ) . '/*', GLOB_ONLYDIR ) as $child ) { if ( $child && ! is_writable( $child ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- WP_Filesystem has no equivalent; read-only diagnostic $fs_record( $child . '/' ); } } } // Deep scan of the update-critical trees, capped so a huge tree // cannot stall the audit. languages/ is where copy_failed_copy_dir_ // languages comes from; wp-admin/wp-includes are overwritten by every // core update. foreach ( array( $lang_dir, $upgrade_dir, CSDT_Paths::root_file( 'wp-admin' ), CSDT_Paths::root_file( 'wp-includes' ) ) as $tree ) { if ( ! is_dir( $tree ) ) { continue; } try { $iter = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $tree, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::SELF_FIRST, RecursiveIteratorIterator::CATCH_GET_CHILD ); foreach ( $iter as $item ) { if ( ++$fs_scanned > $fs_scan_cap ) { $fs_scan_truncated = true; break 2; } if ( ! $item->isWritable() ) { $fs_record( $item->getPathname() ); } } } catch ( \Throwable $e ) { $fs_record( rtrim( $tree, '/' ) . '/' ); } } // Loose core files in the WP root (wp-settings.php, wp-login.php, …). // wp-config.php is excluded: read-only there is deliberate hardening // (see the wp-config Quick Fix) and core updates never touch it. $wpconfig_real = file_exists( $wpconfig_path ) ? realpath( $wpconfig_path ) : false; foreach ( (array) glob( CSDT_Paths::root_file( '*.php' ) ) as $core_file ) { if ( ! $core_file || ( $wpconfig_real && realpath( $core_file ) === $wpconfig_real ) ) { continue; } if ( ! is_writable( $core_file ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- WP_Filesystem has no equivalent; read-only diagnostic $fs_record( $core_file ); } } // Owner of the first offending path, when the server can tell us. $fs_bad_owner = ''; if ( $fs_first_abs !== '' && function_exists( 'posix_getpwuid' ) ) { $owner_id = @fileowner( $fs_first_abs ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- diagnostic only, failure is fine if ( $owner_id !== false ) { $owner_pw = posix_getpwuid( $owner_id ); $fs_bad_owner = is_array( $owner_pw ) ? (string) ( $owner_pw['name'] ?? $owner_id ) : (string) $owner_id; } } // ── Disk space ── $disk_free_gb = null; $disk_total_gb = null; $disk_free_pct = null; $disk_root = function_exists( 'disk_free_space' ) ? @disk_free_space( CSDT_Paths::site_root() ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- no WP equivalent for disk stats $disk_total = function_exists( 'disk_total_space' ) ? @disk_total_space( CSDT_Paths::site_root() ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- no WP equivalent for disk stats if ( $disk_root !== false && $disk_total && $disk_total > 0 ) { $disk_free_gb = round( $disk_root / 1073741824, 1 ); $disk_total_gb = round( $disk_total / 1073741824, 1 ); $disk_free_pct = round( ( $disk_root / $disk_total ) * 100 ); } return array( 'post_count' => $post_count, 'avg_word_count' => $avg_words, 'no_meta_desc_count' => count( $issues['no_meta_desc'] ), 'no_meta_desc_sample' => array_slice( $issues['no_meta_desc'], 0, 6 ), 'no_title_tag_count' => count( $issues['no_title_tag'] ), 'no_title_tag_sample' => array_slice( $issues['no_title_tag'], 0, 6 ), 'thin_content_count' => count( $issues['thin_content'] ), 'thin_content_sample' => array_slice( $issues['thin_content'], 0, 10 ), 'no_featured_img_count' => count( $issues['no_featured_image'] ), 'no_featured_img_sample' => array_slice( $issues['no_featured_image'], 0, 10 ), 'duplicate_titles' => $issues['duplicate_titles'], 'template_rendered_pages' => $template_pages, 'autoload_kb' => $autoload_kb, 'expired_transients' => $expired_transients, 'cron_healthy' => $cron_healthy, 'cron_disabled' => $cron_disabled, 'cron_missing' => $cron_missing, 'cron_overdue' => $cron_overdue, 'revision_count' => $revision_count, 'orphan_postmeta' => $orphan_postmeta, 'active_plugins' => $active_count, 'inactive_plugins' => $inactive_count, 'inactive_themes' => $inactive_theme_count, 'inactive_theme_names' => $inactive_theme_names, 'auto_updates_disabled' => $auto_updates_disabled, 'has_backup_plugin' => $has_backup, 'has_seo_plugin' => $has_seo, 'wp_debug' => $debug_on, 'wp_debug_log' => $debug_log_on, 'revisions_max' => $revisions_max, 'wp_version' => get_bloginfo( 'version' ), 'php_version' => PHP_VERSION, 'bf_enabled' => $bf_enabled, 'autoblock_enabled' => CSDT_Options::get( 'csdt_autoblock_enabled' ) === '1', 'autoblock_threshold' => (int) CSDT_Options::get( 'csdt_autoblock_threshold' ), 'autoblock_ip_ok' => ! CSDT_DevTools::ip_detection_is_broken(), 'bf_attempts' => $bf_attempts, 'bf_lockout_mins' => $bf_lockout, 'bf_today_count' => $bf_today_count, 'bf_scan_date' => $bf_scan_date, 'bf_last_attempt' => $bf_last_attempt, 'login_hide_on' => $login_hide_on, 'twofa_admins' => $twofa_admins, 'twofa_method' => $twofa_method, 'passkeys_on' => $passkeys_on, 'passkeys_count' => $passkeys_count, 'ssh_monitor_on' => $ssh_monitor_on, 'ssh_monitor_threshold' => $ssh_monitor_threshold, 'default_image_missing' => $default_image_missing, 'default_image_broken' => $default_image_broken, 'admin_user_exists' => $admin_user_exists, 'wpconfig_writable' => $wpconfig_writable, 'wp_update_available' => $wp_update_available, 'wp_latest_version' => $wp_latest_version, 'wp_versions_behind' => $wp_versions_behind, 'update_failed' => $update_failed_info, 'fs_unwritable_count' => $fs_unwritable_count, 'fs_unwritable_sample' => $fs_unwritable, 'fs_scan_truncated' => $fs_scan_truncated, 'fs_bad_owner' => $fs_bad_owner, 'exposed_archives' => self::probe_exposed_archives(), 'exposed_folders' => CSDT_Dir_Probe::latest(), 'php_user' => $php_user, 'disk_free_gb' => $disk_free_gb, 'disk_total_gb' => $disk_total_gb, 'disk_free_pct' => $disk_free_pct, ); } /** * Backup plugins this audit recognises, by folder slug. * * @var string[] */ const BACKUP_PLUGIN_SLUGS = array( 'cloudscale-backup', 'updraftplus', 'backwpup', 'backup-backup', 'duplicator', 'duplicator-pro', 'backupbuddy', 'blogvault-real-time-backup', 'wp-all-backup', 'wp-database-backup', 'xcloner-backup-and-restore', ); /** * Is a recognised backup plugin active? * * Public because the setup checklist needs the answer WITHOUT running an audit, a first-time * user has not run one yet, and a checklist that is blank until then would fail exactly the * person it is for. * * @return bool */ public static function has_backup_plugin(): bool { foreach ( (array) get_option( 'active_plugins', array() ) as $slug ) { if ( in_array( explode( '/', (string) $slug )[0], self::BACKUP_PLUGIN_SLUGS, true ) ) { return true; } } return false; } /** * File extensions that indicate a site or database archive. * * Matched against the tail of the filename, so the two-part ones * (`.sql.gz`, `.tar.gz`) must appear before their single-part suffixes or * the shorter match wins and the reported extension is misleading. * * @var string[] */ const ARCHIVE_EXTENSIONS = array( '.sql.gz', '.tar.gz', '.tar.bz2', '.zip', '.tar', '.tgz', '.7z', '.rar', '.sql', '.dump', '.bak', '.wpress', ); /** * Directories under wp-content/ and uploads/ that backup tools write to. * * Probed as URLs even when they are absent from this filesystem, because a * reverse proxy or a mirrored docroot can serve a directory this host does * not have, the same reason the internal exposed-file probe stopped gating * on file_exists(). * * @var string[] */ const BACKUP_DIR_CANDIDATES = array( 'uploads/cloudscale-backup/', 'uploads/csbr-staging/', 'cloudscale-backups/', 'uploads/backup/', 'uploads/backups/', 'backups/', 'backup/', 'backup-db/', 'updraft/', 'ai1wm-backups/', 'backupwordpress/', 'backwpup/', 'wp-snapshots/', 'backups-dup-pro/', 'backups-dup-lite/', 'uploads/wp-clone/', 'uploads/wpvivid-backuppro/', ); /** * Directories inside wp-content/ that are never a backup store. * * Excluded from the walk because plugins and themes legitimately ship * `.zip` assets, sample content, bundled libraries, language packs, and a * scanner that reports those as leaked backups gets switched off. * * @var string[] */ const ARCHIVE_SCAN_SKIP_DIRS = array( 'plugins', 'themes', 'mu-plugins', 'languages', 'upgrade', 'upgrade-temp-backup', 'cache', 'fonts', 'et-cache', 'w3tc-config', ); /** * Are real archives on this disk downloadable from the public internet? * * WHY THIS EXISTS, and why the older check was not enough: the web-root * probe in gather_external_checks() guesses filenames at `/` only * (`/backup.zip`, `/site.zip`, …). It ran twice a day for months while a * 2 GB full-site archive sat at * `wp-content/uploads/cloudscale-backup/backup_F187.zip` returning 200 with * DB credentials, auth salts and every password hash in it. A guess list * cannot find a directory whose name it does not know, and backup plugins * each invent their own. * * So this check is filesystem-informed rather than name-informed: it finds * the archives that actually exist under a web-served path, maps each to * the URL a visitor would use, and fetches it. There is no guessing, and no * new class of backup plugin to keep up with. * * Three properties worth keeping: * * - A 200 is confirmed by a second, ranged GET that must return bytes. A * 200 alone can be a WAF interstitial or a themed error page; only body * bytes prove a download. * - A non-200 is only evidence when the fetch path itself is known to * work, so a CONTROL request for a file that is meant to be public runs * in the same pass. If the control fails the result is `inconclusive`, * not "protected", an all-clear from a broken scanner is worse than no * scanner, because it stops you looking. * - It reports how many probes it ran, so a scan that has stopped covering * anything reads as 0 checks rather than as a pass. * * Bounded by construction: at most 2 files per directory (largest first, * the biggest archive is the fullest one), 10 directories, 20 file probes. * Memoised per request, because both gatherers call it and a request that * ran two audits would double the HTTP work for an identical answer. Not * cached in a transient: the per-finding re-check re-runs the generator and * a cached "still exposed" would accuse the client of a leak they had just * closed. * * @param bool $reset Discard the memo. Tests only. * @return array{files_found:int,dirs_with_archives:int,checked:int,truncated:bool,exposed:array,listings:array,protected:array,control:array,inconclusive:bool} */ private static function probe_exposed_archives( bool $reset = false ): array { static $memo = null; if ( $reset ) { $memo = null; } if ( is_array( $memo ) ) { return $memo; } $result = array( 'files_found' => 0, 'dirs_with_archives' => 0, 'checked' => 0, 'truncated' => false, 'exposed' => array(), 'listings' => array(), 'protected' => array(), 'control' => array( 'url' => '', 'http_code' => 0, 'ok' => false, ), 'inconclusive' => true, ); $uploads = wp_get_upload_dir(); $roots = array(); $content_dir = CSDT_Paths::content_dir(); $abspath = CSDT_Paths::site_root(); $uploads_dir = isset( $uploads['basedir'] ) ? rtrim( (string) $uploads['basedir'], '/' ) : ''; $uploads_url = isset( $uploads['baseurl'] ) ? rtrim( (string) $uploads['baseurl'], '/' ) : ''; if ( '' !== $uploads_dir && '' !== $uploads_url && is_dir( $uploads_dir ) ) { // Depth 3: uploads/// covers every layout seen. $roots[] = array( $uploads_dir, $uploads_url, 3 ); } if ( is_dir( $content_dir ) ) { $roots[] = array( $content_dir, rtrim( content_url(), '/' ), 2 ); } if ( is_dir( $abspath ) ) { // Depth 0, the install root itself with no recursion: wp-admin and // wp-includes hold nothing a backup tool writes. site_url() and not // home_url(): on a subdirectory install they differ, and it is // site_url() that addresses the install root. $roots[] = array( $abspath, rtrim( site_url(), '/' ), 0 ); } // ── Walk: collect real archives, and one public control file ────────── $candidates = array(); $control_url = ''; $dirs_scanned = 0; $dir_cap = 200; foreach ( $roots as $root ) { list( $root_dir, $root_url, $max_depth ) = $root; $queue = array( array( $root_dir, $root_url, 0 ) ); while ( ! empty( $queue ) ) { if ( $dirs_scanned >= $dir_cap ) { $result['truncated'] = true; break 2; } list( $dir, $url, $depth ) = array_shift( $queue ); ++$dirs_scanned; foreach ( (array) glob( $dir . '/*' ) as $path ) { $name = basename( (string) $path ); if ( '' === $name || '.' === $name[0] ) { continue; } if ( is_dir( $path ) ) { $skip = in_array( strtolower( $name ), self::ARCHIVE_SCAN_SKIP_DIRS, true ) // uploads/ has its own root with a deeper budget. || ( $dir === $content_dir && 'uploads' === strtolower( $name ) ); if ( $depth < $max_depth && ! $skip ) { $queue[] = array( $path, $url . '/' . rawurlencode( $name ), $depth + 1 ); } continue; } $file_url = $url . '/' . rawurlencode( $name ); $ext = self::archive_extension( $name ); if ( '' === $ext ) { // A file that is SUPPOSED to be public, used as the // control. Images only: an uploaded .php or .txt may // itself be blocked by a hardening rule, which would // make a working fetch path look broken. if ( '' === $control_url && preg_match( '/\.(jpe?g|png|gif|webp)$/i', $name ) ) { $control_url = $file_url; } continue; } // Reported as a path relative to the install root. A custom // UPLOADS dir can sit outside the install root, in which case the // str_replace changes nothing, fall back to the URL path // rather than printing this server's absolute filesystem // layout into a report the client may forward. $rel = ltrim( str_replace( $abspath, '', (string) $path ), '/' ); if ( '' === $rel || '/' === $rel[0] ) { $rel = ltrim( (string) wp_parse_url( $file_url, PHP_URL_PATH ), '/' ); } $candidates[] = array( 'dir' => $url, 'rel' => $rel, 'url' => $file_url, 'ext' => $ext, 'size' => (int) self::safe_filesize( (string) $path ), ); } } } $result['files_found'] = count( $candidates ); // ── Group by directory, largest first, and cap the work ─────────────── $by_dir = array(); foreach ( $candidates as $c ) { $by_dir[ $c['dir'] ][] = $c; } $result['dirs_with_archives'] = count( $by_dir ); $probe_list = array(); $dir_index = 0; foreach ( $by_dir as $files ) { if ( $dir_index >= 10 ) { $result['truncated'] = true; break; } ++$dir_index; usort( $files, static function ( $a, $b ) { return $b['size'] <=> $a['size']; } ); foreach ( array_slice( $files, 0, 2 ) as $f ) { if ( count( $probe_list ) >= 20 ) { $result['truncated'] = true; break 2; } $probe_list[] = $f; } } // ── Control first: prove the fetch path works before trusting a 403 ─── if ( '' === $control_url ) { $control_url = home_url( '/' ); } $control_code = self::archive_probe_code( $control_url ); $result['control'] = array( 'url' => $control_url, 'http_code' => $control_code, 'ok' => 200 === $control_code, ); $result['inconclusive'] = 200 !== $control_code; ++$result['checked']; // ── Probe the real archives ─────────────────────────────────────────── foreach ( $probe_list as $f ) { $code = self::archive_probe_code( $f['url'] ); ++$result['checked']; if ( 200 !== $code ) { $result['protected'][ $f['rel'] ] = $code; continue; } // 200 is not yet a download. Ask for one byte and require one back. // wp_remote_* and not wp_safe_remote_*: every URL here is built from // home_url()/content_url(), never from input, and the safe variants // reject private and loopback addresses, which is most of the // hosts this plugin runs on. A scan that cannot reach the site it // is scanning reports "protected" for everything. $ranged = wp_remote_get( $f['url'], array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 5, 'sslverify' => csdt_loopback_ssl_verify(), 'headers' => array( 'Range' => 'bytes=0-0' ), ) ); $r_code = is_wp_error( $ranged ) ? 0 : (int) wp_remote_retrieve_response_code( $ranged ); $body = is_wp_error( $ranged ) ? '' : (string) wp_remote_retrieve_body( $ranged ); $type = is_wp_error( $ranged ) ? '' : wp_remote_retrieve_header( $ranged, 'content-type' ); $result['exposed'][] = array( 'rel' => $f['rel'], 'url' => $f['url'], 'size' => $f['size'], 'ext' => $f['ext'], 'http_code' => $code, 'bytes_served' => in_array( $r_code, array( 200, 206 ), true ) && '' !== $body, 'content_type' => is_array( $type ) ? (string) reset( $type ) : (string) $type, ); } // ── Directory listings on the known backup paths ─────────────────────── // Cheap, and catches the mirrored-docroot case the disk walk cannot see. $content_base = rtrim( content_url(), '/' ) . '/'; foreach ( self::BACKUP_DIR_CANDIDATES as $rel_dir ) { $resp = wp_remote_get( $content_base . $rel_dir, array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 4, 'sslverify' => csdt_loopback_ssl_verify(), ) ); ++$result['checked']; if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) { continue; } $html = (string) wp_remote_retrieve_body( $resp ); if ( false !== stripos( $html, 'Index of' ) || false !== stripos( $html, 'Parent Directory' ) ) { $result['listings'][] = $rel_dir; } } $memo = $result; return $result; } /** * The archive extension of a filename, or '' when it is not an archive. * * @param string $name Basename. * @return string */ private static function archive_extension( string $name ): string { $lower = strtolower( $name ); foreach ( self::ARCHIVE_EXTENSIONS as $ext ) { if ( substr( $lower, -strlen( $ext ) ) === $ext ) { return $ext; } } return ''; } /** * Size of a file, 0 if it vanished mid-walk. * * @param string $path Absolute path. * @return int */ private static function safe_filesize( string $path ): int { if ( ! is_file( $path ) ) { return 0; } $size = filesize( $path ); return is_int( $size ) ? $size : 0; } /** * Bounded summary of probe_exposed_archives() for the fact set. * * Full URLs are dropped: a report that is emailed, stored or handed to a * model should not carry a working download link to the very archive it is * warning about. The relative path identifies it well enough to fix. * * @param mixed $scan probe_exposed_archives() output. * @return array */ private static function summarise_archive_scan( $scan ): array { if ( ! is_array( $scan ) ) { return array( 'checked' => 0 ); } $exposed = array(); foreach ( (array) ( $scan['exposed'] ?? array() ) as $e ) { $exposed[] = array( 'path' => (string) ( $e['rel'] ?? '' ), 'size' => (int) ( $e['size'] ?? 0 ), 'bytes_served' => ! empty( $e['bytes_served'] ), ); } return array( 'checked' => (int) ( $scan['checked'] ?? 0 ), 'files_found' => (int) ( $scan['files_found'] ?? 0 ), 'dirs_with_archives' => (int) ( $scan['dirs_with_archives'] ?? 0 ), 'truncated' => ! empty( $scan['truncated'] ), // true means every "protected" result in this run is unproven. 'inconclusive' => ! empty( $scan['inconclusive'] ), 'exposed' => $exposed, 'listings' => array_values( (array) ( $scan['listings'] ?? array() ) ), 'protected_count' => count( (array) ( $scan['protected'] ?? array() ) ), ); } /** * HEAD a URL and return its status code, 0 on transport error. * * @param string $url Absolute URL on this site. * @return int */ private static function archive_probe_code( string $url ): int { $resp = wp_remote_head( $url, array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 5, 'sslverify' => csdt_loopback_ssl_verify(), 'redirection' => 0, ) ); return is_wp_error( $resp ) ? 0 : (int) wp_remote_retrieve_response_code( $resp ); } private static function get_cross_sell_findings( array $d ): array { $findings = array(); if ( empty( $d['has_backup_plugin'] ) ) { $findings[] = array( 'key' => 'no_backup_plugin', 'category' => 'Security', 'severity' => 'high', 'title' => 'No backup plugin detected, your site has no recovery point', 'detail' => 'A server failure, bad plugin update, or hack with no backup means permanent data loss. Backups are non-negotiable for any production WordPress site.', 'fix' => 'Install a backup plugin and schedule daily off-site backups before making any significant changes.', 'cta' => array( 'label' => '🗄 CloudScale Backup & Restore, Free', 'url' => 'https://help.cloudscale.consulting/plugin-help/backup-restore/', 'desc' => 'One-click backup to S3, automated schedules, and point-in-time restore. Free.', ), ); } if ( empty( $d['has_seo_plugin'] ) ) { $findings[] = array( 'key' => 'no_seo_plugin', 'category' => 'SEO', 'severity' => 'high', 'title' => 'No SEO plugin detected, meta tags and structured data not managed', 'detail' => 'Without an SEO plugin, Google receives no guidance on page titles, meta descriptions, canonical URLs, or structured data. This directly limits your organic search visibility.', 'fix' => 'Install an SEO plugin to manage meta descriptions, Open Graph tags, sitemaps, and structured data across all your pages.', 'cta' => array( 'label' => '🤖 CloudScale SEO AI, Free', 'url' => 'https://help.cloudscale.consulting/plugin-help/seo-ai-optimizer/', 'desc' => 'AI-generated meta descriptions, og:image creation, Cloudflare CDN integration, and a social preview checker. Free.', ), ); } return $findings; } /** * Distinctive identifiers inside a finding, used to spot the same underlying issue * described in two different voices. * * The AI and the rule-based checks both report real problems, but they word them * independently, so the same fault can appear twice at two severities. The previous * defence was a blocklist of title keywords ("auto update", "background update", ...) * which missed the case that prompted this: the rules said * "Last WordPress auto-update (to 7.0.2) failed: copy_failed_copy_dir_languages" * while the AI said "WordPress core update failed with copy error", no shared keyword, * two findings, LOW and HIGH. * * Identifiers survive rewording where prose does not. We match error codes (three or * more underscore-joined segments, e.g. copy_failed_copy_dir_languages), wp-config * constants (DISALLOW_FILE_MODS) and CVE ids. Two-segment names like "file_edit" are * deliberately excluded as too generic to prove two findings are the same thing. * * Reading only title and detail, NOT the 'fix' field, is load-bearing, not an * oversight. Remediation advice routinely name-drops neighbouring constants: the * "DISALLOW_FILE_MODS not enabled" finding suggests setting DISALLOW_FILE_EDIT in * passing, and including fix text made it collapse into the unrelated rule-based * "file editor is enabled" finding, silently losing a real result. A constant * mentioned inside a suggested fix says nothing about what the finding is about. * * @param array $f Finding. * @return array Lowercased identifiers. */ private static function finding_signature_tokens( array $f ): array { $title = strtolower( (string) ( $f['title'] ?? '' ) ); $body = $title . ' ' . strtolower( (string) ( $f['detail'] ?? '' ) ); $out = array(); if ( preg_match_all( '/\b[a-z][a-z0-9]*(?:_[a-z0-9]+){2,}\b/', $body, $m ) ) { $out = array_merge( $out, $m[0] ); } if ( preg_match_all( '/\bcve-\d{4}-\d{4,7}\b/', $body, $m ) ) { $out = array_merge( $out, $m[0] ); } return array_values( array_unique( $out ) ); } /** * Collapses AI findings that restate a rule-based finding, keeping the HIGHER severity. * * Only ever compares ACROSS the two sources, never within one. Two separate rule-based * findings that happen to mention the same constant stay separate; it is only the * AI-restates-a-rule case that is a duplicate. A shared identifier must also appear in * one of the two titles: a constant mentioned in passing inside a suggested fix is not * evidence that two findings are the same issue. * * The rule-based record is the survivor because its title, affected-component and fix * are deterministic and actionable, where the AI's are regenerated each run. It simply * inherits the higher severity of the pair. * * @param array $ai AI findings. * @param array $rules Rule-based findings. * @return array{0: array, 1: array} AI findings minus duplicates, rule findings possibly re-rated. */ private static function dedupe_ai_vs_rules( array $ai, array $rules ): array { $order = self::SEVERITY_ORDER; $rule_tokens = array(); foreach ( $rules as $ri => $rf ) { $rule_tokens[ $ri ] = array( 'tokens' => self::finding_signature_tokens( $rf ), 'title' => strtolower( (string) ( $rf['title'] ?? '' ) ), ); } $drop = array(); foreach ( $ai as $ai_i => $af ) { $a_tokens = self::finding_signature_tokens( $af ); if ( empty( $a_tokens ) ) { continue; } $a_title = strtolower( (string) ( $af['title'] ?? '' ) ); foreach ( $rule_tokens as $ri => $r ) { $shared = array_intersect( $a_tokens, $r['tokens'] ); if ( empty( $shared ) ) { continue; } $in_title = false; foreach ( $shared as $tok ) { if ( strpos( $r['title'], $tok ) !== false || strpos( $a_title, $tok ) !== false ) { $in_title = true; break; } } if ( ! $in_title ) { continue; } $a_sev = $order[ strtolower( (string) ( $af['severity'] ?? 'info' ) ) ] ?? 5; $r_sev = $order[ strtolower( (string) ( $rules[ $ri ]['severity'] ?? 'info' ) ) ] ?? 5; if ( $a_sev < $r_sev ) { // Lower number == more severe. Promote the surviving rule-based finding. $rules[ $ri ]['severity'] = strtolower( (string) $af['severity'] ); } $drop[ $ai_i ] = true; break; } } if ( ! empty( $drop ) ) { $ai = array_values( array_diff_key( $ai, $drop ) ); } return array( $ai, array_values( $rules ) ); } private static function generate_rule_based_findings( array $d ): array { $findings = array(); $csp_dupe_count = $d['csp_duplicate_count'] ?? 0; if ( $csp_dupe_count > 1 ) { $findings[] = array( 'key' => 'csp_duplicate', 'category' => 'Security', 'severity' => 'critical', 'title' => "Duplicate Content-Security-Policy headers ({$csp_dupe_count} headers detected)", 'detail' => 'Multiple Content-Security-Policy headers are being sent simultaneously. Browsers enforce the intersection of all CSP headers, which is almost always far more restrictive than intended, typically blocking JavaScript for all visitors and Googlebot, causing SEO rankings to collapse. Common cause: a plugin CSP and a web server (nginx/Apache) CSP both active at the same time.', 'fix' => 'Ensure only one source outputs a Content-Security-Policy header. Check: (1) nginx/Apache add_header directives in server config, (2) WordPress security plugins with CSP settings, (3) the AI Security Scan → Content Security Policy (CSP) panel in this plugin. Disable all but one.', ); } // ── Update-path filesystem ownership ── $fs_bad = (int) ( $d['fs_unwritable_count'] ?? 0 ); if ( $fs_bad > 0 ) { $php_user = (string) ( $d['php_user'] ?? '' ); $user_label = $php_user !== '' ? "the web server user ({$php_user})" : 'the web server user'; $chown_user = $php_user !== '' ? $php_user : 'www-data'; $sample = implode( ', ', array_slice( (array) ( $d['fs_unwritable_sample'] ?? array() ), 0, 6 ) ); $owner_note = ! empty( $d['fs_bad_owner'] ) ? " The first affected path is owned by \"{$d['fs_bad_owner']}\"." : ''; $more = ! empty( $d['fs_scan_truncated'] ) ? ' (scan capped, there may be more)' : ''; $findings[] = array( 'key' => 'fs_ownership', 'category' => 'Security', 'severity' => 'high', 'title' => "{$fs_bad} file(s)/folder(s) in the update path are not writable by {$user_label}{$more}", 'detail' => 'WordPress updates run as the PHP process user. Any file it cannot overwrite, typically left root-owned by root-run WP-CLI commands or manual file copies, makes core, plugin, theme and translation updates fail with copy_failed errors and triggers a "site could not be updated automatically" email on every release. Examples: ' . $sample . '.' . $owner_note, 'fix' => "PHP cannot repair ownership itself. Fix via SSH as root: chown -R {$chown_user}:{$chown_user} . To prevent recurrence, run file-writing WP-CLI commands as the web server user (sudo -u {$chown_user} wp …, or in Docker: docker exec -u {$chown_user} …).", 'affected' => "{$fs_bad} paths", ); } // ── Downloadable backup archives ── // // The one finding in this report that is a live breach rather than a // weakness: an unauthenticated visitor already has wp-config.php, the // auth salts and every password hash. Deliberately rule-based and not // left to the AI pass, a confirmed 200 on a real archive is a fact, and // a model that decides it is "medium" has decided wrong. $arch = is_array( $d['exposed_archives'] ?? null ) ? $d['exposed_archives'] : array(); if ( ! empty( $arch ) ) { $exposed = (array) ( $arch['exposed'] ?? array() ); $listings = (array) ( $arch['listings'] ?? array() ); $checked = (int) ( $arch['checked'] ?? 0 ); $found = (int) ( $arch['files_found'] ?? 0 ); if ( ! empty( $exposed ) ) { $worst = $exposed[0]; foreach ( $exposed as $e ) { if ( (int) ( $e['size'] ?? 0 ) > (int) ( $worst['size'] ?? 0 ) ) { $worst = $e; } } $count = count( $exposed ); $worst_rel = (string) ( $worst['rel'] ?? '' ); $worst_size = size_format( (int) ( $worst['size'] ?? 0 ) ); // bytes_served separates a real download from a 200 that is // actually a WAF interstitial or a themed error page. $streaming = ! empty( $worst['bytes_served'] ) ? ' A ranged request returned file bytes, so this is a working download and not an error page served with a 200.' : ' The ranged request returned no bytes, so this may be a 200 from an interstitial rather than the file itself, treat it as exposed until proven otherwise.'; $more = $count > 1 ? ' ' . ( $count - 1 ) . ' other archive(s) are reachable the same way.' : ''; $findings[] = array( 'key' => 'exposed_backup_archives', 'category' => 'Security', 'severity' => 'critical', 'title' => 1 === $count ? "Backup archive is publicly downloadable: {$worst_rel} ({$worst_size})" : "{$count} backup archives are publicly downloadable, largest {$worst_rel} ({$worst_size})", 'detail' => "Anyone on the internet can download {$worst_rel} without logging in." . $streaming . $more . ' A full-site archive contains wp-config.php, database credentials and the authentication salts that forge login cookies, plus a database dump holding every user password hash. Archive filenames are usually sequential, so one readable name exposes the rest.', 'fix' => 'Deny access at the WEB SERVER, then move the archives out of the document root. An .htaccess deny-all does nothing on nginx, which never reads it, nginx needs a location block returning 403 for the directory. Verify by requesting the URL logged out: only a 403 or 404 proves it, and re-check with this scan rather than trusting the rule.', 'affected' => $worst_rel, ); } if ( ! empty( $listings ) ) { $findings[] = array( 'key' => 'backup_dir_listing', 'category' => 'Security', 'severity' => 'high', 'title' => count( $listings ) . ' backup director(y/ies) return a browsable file listing', 'detail' => 'These wp-content paths serve a directory index to anyone: ' . implode( ', ', array_slice( $listings, 0, 6 ) ) . '. A listing removes the only thing protecting an archive whose filename is not guessable, the visitor no longer has to guess.', 'fix' => 'Turn off directory indexes for these paths (nginx: autoindex off, plus a deny rule; Apache: Options -Indexes) and drop an index.php in each. Denying the directory outright is better than hiding its contents.', 'affected' => implode( ', ', array_slice( $listings, 0, 6 ) ), ); } if ( ! empty( $arch['inconclusive'] ) ) { // The scan could not prove its own fetch path works, so every // non-200 above means nothing. Say so instead of reporting a // pass: a gate that has stopped covering anything must be // visible rather than reassuring. $findings[] = array( 'key' => 'archive_scan_inconclusive', 'category' => 'Security', 'severity' => 'low', 'title' => 'Backup-exposure scan could not verify itself (control request returned HTTP ' . (int) ( $arch['control']['http_code'] ?? 0 ) . ')', 'detail' => 'Before trusting a "403, protected" result the scan fetches a file that is meant to be public. That control request did not return 200, so this site cannot reach itself over HTTP, every protected result in this run is unproven, not clean. Usual causes: bot protection challenging server-side requests, a firewall blocking loopback, or DNS resolving the site to an address this host cannot use.', 'fix' => 'Allow this server to request its own URLs (allowlist the server IP in your WAF, or add a hosts entry), then re-run the scan. Until then, verify the backup directories by hand from outside.', ); } elseif ( empty( $exposed ) && empty( $listings ) ) { $findings[] = array( 'key' => 'archive_scan_ok', 'category' => 'Security', 'severity' => 'info', 'title' => "No downloadable backup archives ({$checked} URLs probed, {$found} archive(s) on disk)", 'detail' => 'Every archive found under a web-served path returned a refusal, and the control request confirmed the site can fetch its own URLs, so those refusals are real. ' . ( ! empty( $arch['truncated'] ) ? 'The scan hit its cap, so this covers the largest archives per directory rather than all of them. ' : '' ) . 'This is a filesystem-informed check: it probes the archives that actually exist rather than guessing filenames at the web root.', 'affected' => "{$checked} URLs", ); } } // ── Exposed folders ── // Source control data, credential files, database tools, listings and the // rest, each proved by its content. See CSDT_Dir_Probe::findings(). foreach ( CSDT_Dir_Probe::findings( $d['exposed_folders'] ?? null ) as $folder_finding ) { $findings[] = $folder_finding; } // ── Recorded core auto-update failure ── $uf = $d['update_failed'] ?? null; if ( is_array( $uf ) && $uf['error_code'] !== '' ) { $already_installed = $uf['attempted'] !== '' && version_compare( (string) ( $d['wp_version'] ?? '0' ), $uf['attempted'], '>=' ); $when = $uf['timestamp'] > 0 ? wp_date( 'M j, Y H:i', $uf['timestamp'] ) : 'an unknown time'; $data_str = $uf['error_data'] !== '' ? " on \"{$uf['error_data']}\"" : ''; $findings[] = array( 'key' => 'core_update_failed', 'category' => 'Security', 'severity' => ( $fs_bad > 0 || ! $already_installed ) ? 'high' : 'low', 'title' => "Last WordPress auto-update (to {$uf['attempted']}) failed: {$uf['error_code']}", 'detail' => "WordPress recorded a failed automatic core update at {$when}, error {$uf['error_code']}{$data_str}." . ( $already_installed ? ' The site is now on ' . ( $d['wp_version'] ?? '' ) . ', so the core files made it across, but the failing copy step (usually a file permissions/ownership problem) will repeat on every release until fixed.' : ' The site is still on the older version, apply the update manually via Dashboard → Updates once the cause is fixed.' ), 'fix' => 'copy_failed errors are almost always file ownership: check the "not writable by the web server" finding in this audit. Once ownership is fixed, the next release should install cleanly.', 'affected' => "WordPress core {$uf['attempted']}", ); } // NOTE: content/SEO checks (meta descriptions, title tags, thin content, // duplicate titles, per-post featured-image coverage) were removed // 2026-08-03, this is a cyber/site-health audit; SEO auditing belongs to // CloudScale SEO AI. The two default-featured-image findings below stay // because they report broken behaviour of THIS plugin's Featured Images // feature (og:image fallback), not search optimisation. $defimg_ack = get_option( 'csdt_defimg_no_fallback_ack', '0' ) === '1'; $no_img_links = ! empty( $d['no_featured_img_sample'] ) ? array_map( fn( $s ) => array( 'label' => $s['title'], 'url' => $s['url'], ), array_slice( $d['no_featured_img_sample'], 0, 6 ) ) : array(); if ( ! empty( $d['default_image_broken'] ) ) { $findings[] = array( 'key' => 'default_image_broken', 'category' => 'Content', 'severity' => 'medium', 'title' => 'Default featured image is broken, og:image fallback returns 404', 'detail' => 'A default image is configured in Thumbnails but the file no longer exists, so theme loops and archives render a broken thumbnail for posts with no featured image of their own. (It does not affect og:image, social previews only use a real featured image.)', 'fix' => 'Go to Images → Default Featured Image and select a valid replacement, or click Remove to clear the broken reference if you prefer posts without a fallback image.', 'affected' => 'All posts without a featured image', 'links' => $no_img_links, ); } elseif ( ! empty( $d['default_image_missing'] ) && ! $defimg_ack ) { $findings[] = array( 'key' => 'default_image_missing', 'category' => 'Content', 'severity' => 'low', 'title' => 'No default featured image, posts without a thumbnail have no og:image fallback', 'detail' => 'When a post has no featured image, social platforms receive no image. A branded 1200×630 px default ensures every shared post shows a preview card. If you intentionally want no fallback, dismiss this finding.', 'fix' => 'Go to Images → Default Featured Image and select a branded 1200×630 px image, or dismiss this finding if you prefer no fallback.', 'affected' => 'All posts without a featured image', 'links' => $no_img_links, 'dismiss_label' => 'No fallback intended', 'dismiss_id' => 'defimg_no_fallback_ack', ); } if ( $d['autoload_kb'] > 800 ) { $sev = $d['autoload_kb'] > 2000 ? 'critical' : ( $d['autoload_kb'] > 1200 ? 'high' : 'medium' ); $findings[] = array( 'key' => 'autoload_high', 'category' => 'Performance', 'severity' => $sev, 'title' => "Autoloaded data is {$d['autoload_kb']} KB, exceeds healthy threshold", 'detail' => 'WordPress loads all autoloaded options on every page request. Values above 800 KB add measurable latency to every PHP execution. Common culprits are abandoned plugins that store large serialised data.', 'fix' => 'Run a query to find the top 20 autoloaded options by size: SELECT option_name, length(option_value) as size FROM wp_options WHERE autoload="yes" ORDER BY size DESC LIMIT 20. Review and delete any from deactivated plugins.', 'affected' => "wp_options ({$d['autoload_kb']} KB autoloaded)", ); } else { $findings[] = array( 'key' => 'autoload_ok', 'category' => 'Performance', 'severity' => 'info', 'title' => "Autoloaded data is {$d['autoload_kb']} KB, within acceptable range", 'detail' => "Total autoloaded data in wp_options is {$d['autoload_kb']} KB, well within the healthy threshold of under 800 KB. No performance impact expected.", 'fix' => 'No action required. Periodically re-run the audit after installing new plugins to catch any regressions.', 'affected' => 'wp_options', ); } $cron_disabled_acked = get_option( 'csdt_cron_disabled_ack', '0' ) === '1'; if ( ( ! empty( $d['cron_disabled'] ) && ! $cron_disabled_acked ) || ! empty( $d['cron_missing'] ) || ! empty( $d['cron_overdue'] ) ) { $sev = ! empty( $d['cron_disabled'] ) ? 'high' : 'medium'; $detail = ! empty( $d['cron_disabled'] ) ? 'DISABLE_WP_CRON is defined in wp-config.php. WordPress will never automatically run scheduled cleanup, expired transients, auto-drafts, and trashed posts will accumulate indefinitely unless a system cron calls wp-cron.php.' : ( ! empty( $d['cron_missing'] ) ? 'Core cleanup cron events (wp_scheduled_delete, delete_expired_transients) are missing from the schedule. These events handle expired transients, auto-drafts, and trashed content, their absence means database bloat will grow unchecked.' : 'Core cleanup cron events are overdue by more than 6 hours, suggesting WP-Cron is not firing reliably. Expired transients and auto-drafts may be accumulating.' ); $finding = array( 'key' => 'cron_health', 'category' => 'Database', 'severity' => $sev, 'title' => ! empty( $d['cron_disabled'] ) ? 'DISABLE_WP_CRON is set, scheduled database cleanup is not running' : 'WP-Cron cleanup events are missing or overdue', 'detail' => $detail, 'fix' => 'Click Fix It to reschedule and immediately run the missing cron events. If DISABLE_WP_CRON is intentional, ensure a system cron calls wp-cli cron event run --due-now at least every 15 minutes.', 'affected' => 'wp_options, wp_posts tables', ); if ( ! empty( $d['cron_disabled'] ) ) { $finding['dismiss_id'] = 'cron_disabled_ack'; $finding['dismiss_label'] = 'System Cron Configured'; } $findings[] = $finding; } if ( $d['expired_transients'] > 50 ) { $findings[] = array( 'key' => 'expired_transients', 'category' => 'Database', 'severity' => 'medium', 'title' => "{$d['expired_transients']} expired transients in the database", 'detail' => 'Expired transients are stale cache entries that WordPress has not yet purged. They bloat the wp_options table and slow down option queries.', 'fix' => 'Click Fix It to delete all expired transients immediately. For ongoing maintenance use CloudScale Cleanup on a scheduled CRON.', 'affected' => 'wp_options table', 'cta' => array( 'label' => '🧹 CloudScale Cleanup, Free', 'url' => 'https://help.cloudscale.consulting/plugin-help/database-cleanup/', 'desc' => 'Automatically cleans expired transients, revisions, orphaned postmeta, and auto-drafts on a schedule.', ), ); } if ( $d['revision_count'] > 20 ) { $sev = $d['revision_count'] > 2000 ? 'medium' : 'low'; $findings[] = array( 'key' => 'revisions_stored', 'category' => 'Database', 'severity' => $sev, 'title' => "{$d['revision_count']} post revisions stored in the database", 'detail' => 'Post revisions accumulate over time and are rarely used after the first few. Even with a revision limit set, existing legacy revisions remain until explicitly removed, bloating the wp_posts table and slowing backups.', 'fix' => 'Limit to 5 Revisions caps future revisions at five per post, applied at runtime through the wp_revisions_to_keep filter. It does not remove the revisions already stored: WordPress prunes each post down to the cap the next time that post is saved. Use CloudScale Cleanup to clear the existing backlog on a schedule.', 'affected' => "wp_posts table ({$d['revision_count']} revisions)", 'cta' => array( 'label' => '🧹 CloudScale Cleanup, Free', 'url' => 'https://help.cloudscale.consulting/plugin-help/database-cleanup/', 'desc' => 'Scheduled CRON deletes revisions, expired transients, orphaned postmeta, and auto-drafts automatically, no WP-CLI required.', ), ); } if ( $d['orphan_postmeta'] > 100 ) { $findings[] = array( 'key' => 'orphan_postmeta', 'category' => 'Database', 'severity' => 'low', 'title' => "{$d['orphan_postmeta']} orphaned post meta rows", 'detail' => 'Orphaned meta rows belong to posts that no longer exist. They accumulate when posts are hard-deleted without cleaning up associated metadata.', 'fix' => 'Run: DELETE pm FROM wp_postmeta pm LEFT JOIN wp_posts p ON pm.post_id = p.ID WHERE p.ID IS NULL;, or use a DB cleanup plugin.', 'affected' => 'wp_postmeta table', ); } if ( ( $d['inactive_plugins'] ?? 0 ) >= 1 ) { $n = $d['inactive_plugins']; $sev = $n >= 6 ? 'high' : ( $n >= 3 ? 'medium' : 'low' ); $findings[] = array( 'key' => 'inactive_plugins', 'category' => 'Plugins', 'severity' => $sev, 'title' => "{$n} inactive " . ( $n === 1 ? 'plugin' : 'plugins' ) . ' still installed', 'detail' => "Inactive plugins don't run, but their files still exist on disk. If a vulnerability is discovered in an inactive plugin it can still be exploited via directory traversal or file-inclusion attacks.", 'fix' => 'Deactivating is not enough, the files stay on disk. Delete All Inactive removes every one of them permanently; their settings remain in the database. Check the list first if you deactivated anything to troubleshoot and meant to re-enable it.', 'affected' => "{$n} inactive " . ( $n === 1 ? 'plugin' : 'plugins' ), ); } if ( ( $d['inactive_themes'] ?? 0 ) >= 1 ) { $n = $d['inactive_themes']; $sev = $n >= 3 ? 'medium' : 'low'; $themes_url = admin_url( 'themes.php' ); $theme_links = ! empty( $d['inactive_theme_names'] ) ? array_map( fn( $name ) => array( 'label' => $name, 'url' => $themes_url, ), array_slice( $d['inactive_theme_names'], 0, 6 ) ) : array(); $findings[] = array( 'key' => 'inactive_themes', 'category' => 'Security', 'severity' => $sev, 'title' => "{$n} inactive " . ( $n === 1 ? 'theme' : 'themes' ) . ' still installed', 'detail' => 'Unused themes sit on disk with their full PHP code intact. An attacker who gains write access can activate an inactive theme to run arbitrary code. WordPress itself recommends keeping only themes you actually use.', 'fix' => 'Go to Appearance → Themes, click each unused theme, and delete it. You only need your active theme and its parent theme (if applicable).', 'affected' => "{$n} inactive " . ( $n === 1 ? 'theme' : 'themes' ), 'links' => $theme_links, ); } if ( ! empty( $d['auto_updates_disabled'] ) ) { $findings[] = array( 'key' => 'auto_updates_off', 'category' => 'Security', 'severity' => 'high', 'title' => 'WordPress automatic background updates are disabled', 'detail' => 'WordPress releases security patches as minor updates (e.g. 6.9.x). With auto-updates disabled your site will not apply these patches automatically, leaving it exposed until you update manually.', 'fix' => 'Remove or change the AUTOMATIC_UPDATER_DISABLED / WP_AUTO_UPDATE_CORE constant in wp-config.php. Click Fix It to see exactly which lines are in force; this plugin does not edit wp-config.php, so remove them yourself.', 'affected' => 'wp-config.php', ); } if ( $d['active_plugins'] > 25 ) { $findings[] = array( 'key' => 'too_many_plugins', 'category' => 'Performance', 'severity' => 'medium', 'title' => "{$d['active_plugins']} active plugins, consider reducing", 'detail' => 'Each active plugin adds PHP execution time, database queries, and JavaScript/CSS to every page load. Beyond ~20 plugins, the cumulative overhead becomes measurable.', 'fix' => 'Audit your plugin stack. Use Performance → Plugin Stack Scanner to identify plugins that CloudScale already replaces. Aim for under 20 active plugins.', 'affected' => "{$d['active_plugins']} active plugins", ); } if ( $d['wp_debug'] ) { $findings[] = array( 'key' => 'wp_debug_on', 'category' => 'Security', 'severity' => 'high', 'title' => 'WP_DEBUG is enabled on a production site', 'detail' => 'WP_DEBUG outputs PHP errors and notices directly to the browser. This leaks server paths, database table names, plugin file structures, and other information useful to attackers.', 'fix' => "Set define('WP_DEBUG', false) in wp-config.php. If you need debug logging, use WP_DEBUG_LOG with WP_DEBUG_DISPLAY set to false.", 'affected' => 'wp-config.php', ); } // ── WP core update ── if ( ! empty( $d['wp_update_available'] ) ) { $latest = $d['wp_latest_version'] ? " (latest: {$d['wp_latest_version']})" : ''; $behind = (int) ( $d['wp_versions_behind'] ?? 0 ); $sev = $behind >= 2 ? 'critical' : 'high'; $findings[] = array( 'key' => 'core_update', 'category' => 'Security', 'severity' => $sev, 'title' => "WordPress core update available{$latest}", 'detail' => "You are running WordPress {$d['wp_version']} and a newer version is available. Outdated core versions are the most common vector for mass WordPress compromises.", 'fix' => 'Apply it from Dashboard → Updates, with a backup taken first. Show fix commands generates the WP-CLI equivalent plus a rollback, for when the browser update path is the thing that keeps failing.', 'affected' => "WordPress {$d['wp_version']}", ); } // ── Writable wp-config.php ── if ( ! empty( $d['wpconfig_writable'] ) ) { $findings[] = array( 'key' => 'wpconfig_writable', 'category' => 'Security', 'severity' => 'high', 'title' => 'wp-config.php is writable by the web server process', 'detail' => 'wp-config.php should be read-only (0400 or 0440) so no PHP process running as the web server user can overwrite database credentials or secret keys.', // Not "chmod 0400" flat: 0400 is owner-read-only, so on the very // common root-owned-file / www-data-PHP layout it takes the site // down. The recipe picks the mode from actual ownership and // verifies PHP can still read the file before it walks away. 'fix' => 'Make it read-only, 0400 if wp-config.php is owned by the user PHP runs as, otherwise 0440 with group read. "Show fix commands" generates the right one for this server and proves PHP can still read the file.', 'affected' => 'wp-config.php', ); } // ── Admin username ── if ( ! empty( $d['admin_user_exists'] ) ) { $findings[] = array( 'key' => 'admin_username', 'category' => 'Security', 'severity' => 'high', 'title' => 'User account with username "admin" still exists', 'detail' => '"admin" is the most targeted username in WordPress brute-force attacks. Leaving it active makes credential-stuffing attacks far more likely to succeed.', 'fix' => 'Create a new administrator with a unique username, log in as the new user, then delete the "admin" account and reassign its content.', 'affected' => 'WordPress users', ); } // ── Disk space ── if ( $d['disk_free_pct'] !== null ) { if ( $d['disk_free_pct'] < 5 ) { $findings[] = array( 'key' => 'disk_low', 'category' => 'Performance', 'severity' => 'critical', 'title' => "Disk critically low, {$d['disk_free_pct']}% free ({$d['disk_free_gb']} GB of {$d['disk_total_gb']} GB)", 'detail' => 'Less than 5% disk space remaining. WordPress will fail to write uploads, logs, or cache files. Database operations may also fail if MySQL runs out of tmp space.', 'fix' => 'Immediately remove unused uploads, clean up logs, and expand disk capacity. Use CloudScale Cleanup to purge database bloat.', 'affected' => "Disk ({$d['disk_free_gb']} GB free)", ); } elseif ( $d['disk_free_pct'] < 15 ) { $findings[] = array( 'key' => 'disk_low', 'category' => 'Performance', 'severity' => 'high', 'title' => "Disk space low, {$d['disk_free_pct']}% free ({$d['disk_free_gb']} GB of {$d['disk_total_gb']} GB)", 'detail' => 'Disk space is below 15%. WordPress uploads, caching, and backup operations will start failing as space runs out.', 'fix' => 'Remove unused media, clear old backups, purge log files, and consider upgrading your hosting plan.', 'affected' => "Disk ({$d['disk_free_gb']} GB free)", ); } elseif ( $d['disk_free_pct'] < 25 ) { $findings[] = array( 'key' => 'disk_low', 'category' => 'Performance', 'severity' => 'medium', 'title' => "Disk space at {$d['disk_free_pct']}% free ({$d['disk_free_gb']} GB of {$d['disk_total_gb']} GB)", 'detail' => 'Less than 25% disk space remaining. Plan to free up space before it becomes critical.', 'fix' => 'Review and remove large unused media files and old backups. Use the SQL Tool to identify large database tables.', 'affected' => "Disk ({$d['disk_free_gb']} GB free)", ); } else { $findings[] = array( 'key' => 'disk_ok', 'category' => 'Performance', 'severity' => 'info', 'title' => "Disk space healthy, {$d['disk_free_pct']}% free ({$d['disk_free_gb']} GB of {$d['disk_total_gb']} GB)", 'detail' => 'Disk space is at a comfortable level.', 'fix' => 'No action needed. Run CloudScale Cleanup periodically to keep the database lean and free up space.', 'affected' => "Disk ({$d['disk_free_gb']} GB free)", 'cta' => array( 'label' => '🧹 CloudScale Cleanup, Free', 'url' => 'https://help.cloudscale.consulting/plugin-help/database-cleanup/', 'desc' => 'Remove expired transients, post revisions, spam comments, and orphaned metadata in one click.', ), ); } } // ── SSH brute-force monitor ── if ( ! empty( $d['ssh_monitor_on'] ) ) { $findings[] = array( 'key' => 'ssh_monitor_on', 'category' => 'Security', 'severity' => 'info', 'title' => "SSH Brute-Force Monitor active, alert threshold: {$d['ssh_monitor_threshold']} failures/min", 'detail' => 'The SSH Brute-Force Monitor is reading /var/log/auth.log every 60 seconds and will alert via email and push notification if the threshold is breached.', 'fix' => 'No action needed. Adjust the threshold in Activity Log → SSH Brute-Force Monitor if you receive false-positive alerts.', 'affected' => 'SSH / auth.log', ); } else { $findings[] = array( 'key' => 'ssh_monitor_off', 'category' => 'Security', 'severity' => 'medium', 'title' => 'SSH Brute-Force Monitor is disabled', 'detail' => 'The SSH monitor is not actively watching auth.log. Brute-force SSH attacks targeting this server will go undetected and unalerted.', 'fix' => 'Enable it in Activity Log → SSH Brute-Force Monitor.', 'affected' => 'SSH / auth.log', ); } // ── Username enumeration protection ── // These findings report features THIS plugin already provides, so the CTA links // to the in-plugin Login Security tab (not the external help site). in_plugin // makes the renderer show a "Manage in this plugin" link that navigates same-tab. $devtools_cta = array( 'label' => '⚙️ Manage in Login Security →', 'url' => admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=login' ), 'desc' => 'Provided by this plugin. Adjust these protections on the Login Security tab.', 'in_plugin' => true, ); if ( CSDT_Options::get( 'csdt_devtools_enum_protect' ) === '1' ) { $findings[] = array( 'key' => 'enum_protect_on', 'category' => 'Security', 'severity' => 'info', 'title' => 'Username enumeration protection active', 'detail' => 'Login errors are genericised to "Invalid username or password.", attackers cannot distinguish a missing username from a wrong password, preventing account enumeration.', 'fix' => 'No action needed.', 'affected' => 'Login page', 'cta' => $devtools_cta, ); } else { $findings[] = array( 'key' => 'enum_protect_off', 'category' => 'Security', 'severity' => 'medium', 'title' => 'Username enumeration protection is disabled', 'detail' => 'WordPress reveals whether a username exists ("The username X is not registered on this site."), attackers can automate this to enumerate all valid usernames in minutes.', // This is csdt_devtools_enum_protect, genericised LOGIN ERRORS, owned // by the Login Security form. It is NOT the same control as the // author-enumeration block below, which is csdt_block_user_enum and // does something else entirely. Routing this finding at // disable_user_enumeration was wrong: the button changed the other // setting and left this finding standing, and this sentence had been // rewritten to describe the wrong control, which made the pair look // consistent. There is no quick-fix handler for this option, so the // route is MANUAL and points at the form that owns it. 'fix' => 'Turn on "Prevent account enumeration" in Login Security → Brute-Force Protection. It makes a wrong username and a wrong password return the same message, so a scanner cannot tell which accounts exist.', 'affected' => 'Login page', ); } // ── Author enumeration via /?author=N and feed author fields ── // // A separate control from the login-error genericisation above, and separately // reportable: csdt_block_user_enum is what the disable_user_enumeration quick // fix sets. Until this finding existed that handler was reachable only from the // Quick Fixes panel, which is the same "tested fix nobody can find from the // report that found the problem" gap the route registry was built to close. if ( '1' !== CSDT_Options::get( 'csdt_block_user_enum' ) ) { $findings[] = array( 'key' => 'author_enum_open', 'category' => 'Security', 'severity' => 'low', 'title' => 'Author archives expose usernames via /?author=N', 'detail' => 'Requesting /?author=1, /?author=2 and so on redirects to each author\'s archive, whose URL contains their login. Feeds can leak the same thing when an author\'s display name is still their username. It is how a scanner builds the username list before it starts guessing passwords.', 'fix' => 'Block Author Enumeration 301-redirects /?author=N to the home page and replaces feed author fields with your site name wherever the display name is really a login. Normal /author/name/ archives keep working.', 'affected' => 'Author archives, feeds', ); } // ── Active brute-force attack detected ── if ( ! empty( $d['bf_today_count'] ) && $d['bf_today_count'] >= 30 ) { $bf_date = $d['bf_scan_date'] ?? wp_date( 'M j' ); $findings[] = array( 'key' => 'bf_attack_active', 'category' => 'Security', 'severity' => 'critical', 'title' => "Active brute-force attack in progress, {$d['bf_today_count']} failed login attempts on {$bf_date}", 'detail' => "More than 30 failed WordPress login attempts were recorded on {$bf_date}. This indicates an automated credential-stuffing or brute-force campaign is actively targeting this site. Distributed attacks use rotating IPs to evade per-IP blocks.", 'fix' => 'Enforce 2FA for all administrators now in Login Security → Two-Factor Authentication, it makes credential guessing irrelevant even if an attacker has your password. Consider adding a Web Application Firewall (WAF) such as Cloudflare to block attacking IPs at the edge.', 'affected' => "{$d['bf_today_count']} attempts on {$bf_date}", 'bf_last_attempt' => $d['bf_last_attempt'] ?? 0, ); } // ── Login brute-force protection ── // ── Automatic IP blocking ───────────────────────────────────────────── // Ships disabled because blocking traffic is consequential and // site-specific. Surfaced here so it is a visible, actionable finding // rather than a setting nobody discovers. if ( empty( $d['autoblock_enabled'] ) ) { $ip_ok = ! empty( $d['autoblock_ip_ok'] ); $findings[] = array( // Two keys, because with IP detection broken the fix cannot work: // ajax_apply_quick_fix() refuses enable_autoblock outright, so // offering the button would be offering a guaranteed error. The // second key routes to the prerequisite instead. 'key' => $ip_ok ? 'autoblock_off' : 'autoblock_off_ip_broken', 'category' => 'Security', 'severity' => 'high', 'title' => 'Automatic IP blocking is disabled, attackers get unlimited attempts', 'detail' => 'Per-account lockout protects an individual account but does nothing to the attacker, who simply moves to the next username and keeps consuming your CPU, database, and PHP workers. With automatic blocking off, a scanner can probe your login, XML-RPC, and hidden-login paths indefinitely and never be turned away.' . ( $ip_ok ? '' : ' Note: visitor IP detection is not currently resolving public addresses, so enabling this alone will have no effect until the Visitor IP Source setting is corrected first.' ), 'fix' => $ip_ok ? 'Enable it in Login Security → Automatic IP Blocking. The default threshold of ' . $d['autoblock_threshold'] . ' hostile events per hour is generous enough that a person mistyping a password is never affected, and blocks expire automatically (1 hour, then 24 hours, then 7 days for repeat offenders). Add any shared office or VPN egress address to the allowlist first.' : 'Fix Request Firewall → Visitor IP Source first (the panel shows which source matches your real IP), then enable Login Security → Automatic IP Blocking.', 'affected' => 'wp-login.php, xmlrpc.php, hidden login slug', ); } else { $findings[] = array( 'key' => 'autoblock_on', 'category' => 'Security', 'severity' => 'info', 'title' => "Automatic IP blocking active, threshold {$d['autoblock_threshold']} hostile events", 'detail' => 'Addresses that cross the threshold are blocked automatically, with escalating expiry and never-block protection for your own IP, live admin sessions, proxy ranges, and your allowlist.' . ( empty( $d['autoblock_ip_ok'] ) ? ' WARNING: visitor IP detection is not resolving public addresses, so this feature is currently inert.' : '' ), 'fix' => empty( $d['autoblock_ip_ok'] ) ? 'Fix Request Firewall → Visitor IP Source so real visitor addresses resolve, otherwise no block can ever fire.' : 'No action needed. Adjust thresholds in Login Security → Automatic IP Blocking.', 'affected' => 'wp-login.php, xmlrpc.php, hidden login slug', ); } if ( ! empty( $d['bf_enabled'] ) ) { $short_lockout = $d['bf_lockout_mins'] < 15; $has_2fa = ! empty( $d['twofa_admins'] ); if ( $short_lockout && ! $has_2fa ) { // Short lockout with no 2FA, low concern, some protection exists $findings[] = array( 'key' => 'bf_short_lockout', 'category' => 'Security', 'severity' => 'low', 'title' => "Brute-force lockout is short ({$d['bf_lockout_mins']} min) and 2FA is not enforced", 'detail' => "A {$d['bf_lockout_mins']}-minute lockout after {$d['bf_attempts']} attempts provides limited protection, automated tools simply wait out the window. Enforcing 2FA on admin accounts would eliminate this risk entirely.", 'fix' => 'Enforcing 2FA makes the lockout duration irrelevant, because a guessed password is no longer enough on its own, that is what the button does. Alternatively raise the lockout to 30-60 minutes in Login Security → Brute-Force Protection.', 'affected' => 'wp-login.php', ); } else { $twofa_note = $has_2fa ? ' 2FA enforcement means successful credential guessing still cannot lead to account takeover.' : ''; $findings[] = array( 'key' => 'bf_protection_on', 'category' => 'Security', 'severity' => 'info', 'title' => "Login brute-force protection active, lockout after {$d['bf_attempts']} attempts ({$d['bf_lockout_mins']} min)", 'detail' => 'Per-account lockout is enforced on the WordPress login form.' . $twofa_note, 'fix' => 'No action needed. Adjust thresholds in Login Security → Brute-Force Protection.', 'affected' => 'wp-login.php', ); } } else { $findings[] = array( 'key' => 'bf_protection_off', 'category' => 'Security', 'severity' => 'high', 'title' => 'WordPress login brute-force protection is disabled', 'detail' => 'Without per-account lockout, attackers can make unlimited login attempts against any username without being blocked.', 'fix' => 'Enable it in Login Security → Brute-Force Protection.', 'affected' => 'wp-login.php', ); } // ── Login URL hiding ── if ( ! empty( $d['login_hide_on'] ) ) { $findings[] = array( 'key' => 'login_url_hidden', 'category' => 'Security', 'severity' => 'info', 'title' => 'Login URL is hidden, wp-login.php is not publicly accessible', 'detail' => 'The login URL has been moved to a custom slug, significantly reducing automated login scan traffic.', 'fix' => 'No action needed.', 'affected' => 'wp-login.php', 'cta' => $devtools_cta, ); } else { $findings[] = array( 'key' => 'login_url_public', 'category' => 'Security', 'severity' => 'medium', 'title' => 'Login URL is publicly accessible at /wp-login.php', 'detail' => 'The default WordPress login URL is a constant target for automated scanning bots. Hiding it eliminates a large class of credential-stuffing traffic.', 'fix' => 'Enable it in Login Security → Hide Login URL and set a custom login slug.', 'affected' => 'wp-login.php', ); } // ── 2FA for admins ── if ( ! empty( $d['twofa_admins'] ) ) { $method_label = $d['twofa_method'] ? " (method: {$d['twofa_method']})" : ''; $findings[] = array( 'key' => 'twofa_enforced', 'category' => 'Security', 'severity' => 'info', 'title' => "Two-factor authentication enforced for all admins{$method_label}", 'detail' => 'All administrator accounts must complete a second factor on login. This blocks account takeover even if a password is leaked.', 'fix' => 'No action needed. Passkeys offer the strongest protection, consider adding one in Login Security → Passkeys (WebAuthn).', 'affected' => 'Administrator accounts', 'cta' => $devtools_cta, ); } else { $findings[] = array( 'key' => 'twofa_not_enforced', 'category' => 'Security', 'severity' => 'high', 'title' => 'Two-factor authentication not enforced for admins', 'detail' => 'Administrator accounts can log in with a password alone. A single leaked or guessed password gives an attacker full site control.', 'fix' => 'Enforce 2FA for Admins requires a second factor from every administrator. Anyone without it enrolled is sent to enrolment on their next login, so make sure you can complete it yourself before applying. Passkeys are stronger than TOTP and can be added afterwards.', 'affected' => 'Administrator accounts', ); } // ── Passkeys ── if ( ! empty( $d['passkeys_on'] ) ) { $count = (int) ( $d['passkeys_count'] ?? 0 ); $findings[] = array( 'key' => 'passkeys_registered', 'category' => 'Security', 'severity' => 'info', 'title' => "Passkeys (WebAuthn) registered, {$count} passkey(s) across admin accounts", 'detail' => 'Administrator accounts have passkeys registered. Passkeys provide phishing-resistant, passwordless login using device biometrics (Face ID, Touch ID, Windows Hello) or hardware security keys.', 'fix' => 'No action needed. Register additional passkeys for backup devices via Login Security → Passkeys (WebAuthn).', 'affected' => 'Administrator accounts', 'cta' => $devtools_cta, ); } else { $findings[] = array( 'key' => 'passkeys_none', 'category' => 'Security', 'severity' => 'medium', 'title' => 'No passkeys registered, consider adding a passkey for phishing-resistant login', 'detail' => 'Passkeys use Face ID, Touch ID, Windows Hello, or hardware security keys for cryptographically strong authentication. They cannot be phished and require no code entry.', 'fix' => 'Go to Login Security → Passkeys (WebAuthn) and click + Add Passkey to register your device.', 'affected' => 'Administrator accounts', ); } // ── DB table prefix ── if ( ! empty( $d['db_prefix_default'] ) ) { $findings[] = array( 'key' => 'db_prefix_default', 'category' => 'Security', 'severity' => 'low', 'title' => 'Default database table prefix (wp_)', 'detail' => 'The default wp_ prefix means SQL injection probes that hardcode table names do not have to guess. Renaming is obscurity rather than a control, it raises the bar slightly for blind injection and does nothing else, so weigh it against the migration risk rather than treating it as a must-fix.', 'severity_note' => 'advisory', 'fix' => 'CloudScale deliberately does not automate this. Renaming the prefix means renaming live tables and editing wp-config.php while any plugin holding its own tables keeps the old prefix and breaks; a failed migration can lock you out of the site you need in order to fix it. If you want it done, do it in planned downtime with a tested backup and a staging rehearsal first.', 'affected' => 'All database tables', ); } foreach ( self::get_cross_sell_findings( $d ) as $f ) { $findings[] = $f; } // ── PHP security advisories ── // From the data PHP Advisory Alerts already stored. Never a request: the audit and the // weekly report read the same finding, and a default install contacts nothing. foreach ( self::php_advisory_findings() as $f ) { $findings[] = $f; } // ── CloudScale Edge Gateway ── // Stored data only, and nothing at all while no gateway is paired. See CSDT_Edge_Gateway. if ( class_exists( 'CSDT_Edge_Gateway' ) ) { try { foreach ( CSDT_Edge_Gateway::audit_findings() as $f ) { $findings[] = $f; } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } // ── New CVEs not being checked ── // Two options read, nothing fetched. See CSDT_Cve_Checks. foreach ( self::cve_checks_findings() as $f ) { $findings[] = $f; } if ( empty( $findings ) ) { $findings[] = array( 'key' => 'no_issues', 'category' => 'info', 'severity' => 'info', 'title' => 'No significant issues detected', 'detail' => "Your site passed all {$d['post_count']} content checks and database health checks with no major issues found. Add an AI key for deeper analysis and narrative recommendations.", 'fix' => 'Continue monitoring regularly. Add an AI API key for richer audit reports.', 'affected' => "All {$d['post_count']} posts/pages", ); } // Stamp an in-plugin "manage" link on every Security/info finding that doesn't // already have a CTA. These are features this plugin provides, so the link goes // to the Login Security tab, not the external help site. $devtools_cta_stamp = array( 'label' => '⚙️ Manage in Login Security →', 'url' => admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=login' ), 'desc' => 'Provided by this plugin. Adjust these protections on the Login Security tab.', 'in_plugin' => true, ); foreach ( $findings as &$f ) { if ( ( $f['category'] ?? '' ) === 'Security' && ( $f['severity'] ?? '' ) === 'info' && empty( $f['cta'] ) ) { $f['cta'] = $devtools_cta_stamp; } } unset( $f ); return $findings; } /** * The "new CVEs are not being checked" finding, from the two opt-in switches only. * * @return array> Zero or one finding. */ public static function cve_checks_findings(): array { if ( ! class_exists( 'CSDT_Cve_Checks' ) ) { return array(); } $state = CSDT_Cve_Checks::state(); $f = self::cve_checks_finding( ! empty( $state['php'] ), ! empty( $state['lookups'] ) ); return null === $f ? array() : array( $f ); } /** * One finding when either CVE check is off, none when both are on. * * Medium, so it counts in the score: on a default install nothing notices a CVE published * after the last scan somebody ran by hand, and saying nothing about that read as covered. * Two keys so the route lands on the right panel: cve_checks_off whenever CVE lookups are * off (the wider gap), cve_checks_off_php when only PHP advisory alerts are. * * @param bool $php_on PHP advisory alerts on. * @param bool $lookups_on CVE lookups on. * @return array|null */ public static function cve_checks_finding( bool $php_on, bool $lookups_on ): ?array { if ( $php_on && $lookups_on ) { return null; } $off = array(); $sends = array(); if ( ! $lookups_on ) { $off[] = CSDT_Cve_Checks::label( CSDT_Cve_Checks::LOOKUPS ); $sends[] = 'CVE lookups: ' . CSDT_Cve_Checks::sends( CSDT_Cve_Checks::LOOKUPS ); } if ( ! $php_on ) { $off[] = CSDT_Cve_Checks::label( CSDT_Cve_Checks::PHP ); $sends[] = 'PHP advisory alerts: ' . CSDT_Cve_Checks::sends( CSDT_Cve_Checks::PHP ); } $detail = 'Off: ' . implode( ' and ', $off ) . '. Nothing checks for a new vulnerability on a schedule, so one published after the last scan you ran is not reported. Both are off by default and send nothing until you turn them on. ' . implode( ' ', $sends ); $base = array( 'category' => 'Security', 'severity' => 'medium', 'title' => 'New CVEs are not being checked', 'detail' => $detail, 'affected' => implode( ', ', $off ), ); if ( ! $lookups_on ) { return $base + array( 'key' => 'cve_checks_off', 'fix' => 'Turn on CVE lookups in AI Security Scan → CVE Alerts' . ( $php_on ? '' : ', and PHP advisory alerts beside it in AI Security Scan → PHP Advisory Alerts' ) . '. One click each, from the notice at the top of this screen too.', ); } return $base + array( 'key' => 'cve_checks_off_php', 'fix' => 'Turn on PHP advisory alerts in AI Security Scan → PHP Advisory Alerts. One click, from the notice at the top of this screen too.', ); } /** * The PHP advisory finding for this site, read from stored data only. * * CSDT_Php_Advisory::current_status() reads the recorded runtime, the stored php.net feed * and the cached release snapshot; it never fetches. So this is safe on the audit render and * on the weekly report, with the alerts switch on or off: switched off, the feed is simply * whatever Check now last stored, or nothing. * * @return array> Zero or one finding. */ public static function php_advisory_findings(): array { if ( ! class_exists( 'CSDT_Php_Advisory' ) ) { return array(); } // A built-in critical flaw outranks the feed: it is known with every switch off, and one // PHP finding keeps the audit and the weekly report from saying the same thing twice. if ( class_exists( 'CSDT_Php_Critical' ) ) { try { $crit = CSDT_Php_Critical::finding(); if ( null !== $crit ) { return array( $crit ); } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } try { $st = CSDT_Php_Advisory::current_status(); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } return array(); } return array( self::php_advisory_finding( $st ) ); } /** * One finding from a PHP advisory status. * * Critical: a security release is announced and not yet installed, or is out and this site * is below the fixed version for its branch. High: the branch is end of life. Pass (info): * on the fixed version, or nothing open. Unknown (info, never a fail): the runtime is not * recorded yet, or the feed has not been read or its last read failed. A failed read with * a stored "not patched" stays critical: a fetch error is no reason to hide a known gap. * * @param array $st CSDT_Php_Advisory::current_status(). * @return array */ public static function php_advisory_finding( array $st ): array { $version = (string) ( $st['version'] ?? '' ); $branch = (string) ( $st['branch'] ?? '' ); $notice = is_array( $st['notice'] ?? null ) ? $st['notice'] : null; $open = ! empty( $st['notice_open'] ); $fixed = is_array( $st['fixed'] ?? null ) ? $st['fixed'] : null; $fixed_v = null === $fixed ? '' : (string) ( $fixed['version'] ?? '' ); $patch = (string) ( $st['patch'] ?? 'unknown' ); $error = (string) ( $st['error'] ?? '' ); $fetched = (int) ( $st['fetched_at'] ?? 0 ); $enabled = ! empty( $st['enabled'] ); $panel = admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=security' ) . '#cs-panel-php-advisory'; $cta = array( 'label' => 'Open PHP Advisory Alerts', 'url' => $panel, 'desc' => 'The status, the feed and the alerts switch.', 'in_plugin' => true, ); $off = $enabled ? '' : ' PHP Advisory Alerts is off, so nothing is fetched on a schedule; this uses what was stored by the last Check now.'; $runs = '' === $version ? '' : 'This site runs PHP ' . $version . ( '' !== $branch ? ' (branch ' . $branch . ')' : '' ) . '.'; if ( '' === $version ) { return array( 'key' => 'php_advisory_unknown', 'category' => 'Security', 'severity' => 'info', 'title' => 'PHP security status unknown: version not recorded yet', 'detail' => 'The PHP your web server runs is recorded when an administrator opens an admin page. Until then this audit cannot say whether a PHP security release applies here. Not a failure.', 'fix' => 'Open any admin page, then run the audit again. Status and alerts: AI Security Scan → PHP Advisory Alerts.', 'affected' => 'PHP (web server)', 'cta' => $cta, ); } if ( CSDT_Php_Advisory::BRANCH_EOL === (string) ( $st['branch_state'] ?? '' ) ) { $eol = (string) ( $st['eol'] ?? '' ); $detail = $runs . ' PHP ' . $branch . ' is end of life' . ( '' !== $eol ? ' since ' . $eol : '' ) . ', so php.net no longer ships security fixes for it.'; if ( null !== $notice && $open ) { $at = (int) ( $notice['scheduled_ts'] ?? 0 ); $detail .= ' A PHP security release is announced' . ( $at > 0 ? ' for ' . CSDT_Php_Advisory::when( $at ) : '' ) . ' and will not cover ' . $branch . '.'; } return array( 'key' => 'php_advisory_eol', 'category' => 'Security', 'severity' => 'high', 'title' => 'PHP ' . $branch . ' is end of life: no more security fixes', 'detail' => $detail, 'fix' => 'Upgrade PHP to a supported branch; on Docker rebuild the image. Test the site on the new PHP first. Status and alerts: AI Security Scan → PHP Advisory Alerts.', 'affected' => 'PHP ' . $version . ' (web server)', ); } if ( CSDT_Php_Advisory::PATCH_NOT_PATCHED === $patch && '' !== $fixed_v ) { $pub = (int) ( $fixed['published_ts'] ?? 0 ); return array( 'key' => 'php_advisory_unpatched', 'category' => 'Security', 'severity' => 'critical', 'title' => 'PHP ' . $version . ' is missing the security fix in PHP ' . $fixed_v, 'detail' => 'php.net released PHP ' . $fixed_v . ' with security fixes' . ( $pub > 0 ? ' on ' . CSDT_Php_Advisory::when( $pub ) : '' ) . '. ' . $runs . ' Until it is upgraded the site stays exposed to the bugs that release fixes.' . ( '' !== $error ? ' The last feed check failed (' . $error . '); this is from the last good read.' : '' ) . $off, 'fix' => 'Upgrade PHP to ' . $fixed_v . ' or later; on Docker rebuild the image. Status and alerts: AI Security Scan → PHP Advisory Alerts.', 'affected' => 'PHP ' . $version . ' (web server)', ); } if ( null !== $notice && $open && CSDT_Php_Advisory::PATCH_PATCHED !== $patch ) { $at = (int) ( $notice['scheduled_ts'] ?? 0 ); $target = '' !== $fixed_v ? $fixed_v : 'the fixed ' . $branch . ' release as soon as it is out'; return array( 'key' => 'php_advisory_announced', 'category' => 'Security', 'severity' => 'critical', 'title' => 'PHP security release announced: PHP ' . $version . ' not patched yet', 'detail' => ( $at > 0 ? 'php.net has scheduled a PHP security release for ' . CSDT_Php_Advisory::when( $at ) . '. ' : 'php.net announced a PHP security release on ' . CSDT_Php_Advisory::when( (int) ( $notice['published_ts'] ?? 0 ) ) . '. ' ) . $runs . ' Plan the upgrade now so it lands the day the fix ships.' . $off, 'fix' => 'Upgrade PHP to ' . $target . '; on Docker rebuild the image. Status and alerts: AI Security Scan → PHP Advisory Alerts.', 'affected' => 'PHP ' . $version . ' (web server)', ); } if ( 0 === $fetched || '' !== $error ) { $why = 0 === $fetched ? ( $enabled ? 'The php.net advisory feed has not been read yet.' : 'The php.net advisory feed has never been read: PHP Advisory Alerts is off and Check now has not been used.' ) : 'The last read of the php.net advisory feed failed (' . $error . '), so an open advisory could be missing.'; return array( 'key' => 'php_advisory_unknown', 'category' => 'Security', 'severity' => 'info', 'title' => 'PHP security status unknown: advisory feed not read', 'detail' => $why . ' ' . $runs . ' Not a failure.', 'fix' => 'Click Check now, or switch the alerts on. Status and alerts: AI Security Scan → PHP Advisory Alerts.', 'affected' => 'PHP ' . $version . ' (web server)', 'cta' => $cta, ); } return array( 'key' => 'php_advisory_ok', 'category' => 'Security', 'severity' => 'info', 'title' => CSDT_Php_Advisory::PATCH_PATCHED === $patch && '' !== $fixed_v ? 'PHP ' . $version . ' has the latest security fix (' . $fixed_v . ')' : 'No open PHP security advisory for PHP ' . $version, 'detail' => $runs . ' The php.net feed was read ' . CSDT_Php_Advisory::when( $fetched ) . '.' . $off, 'fix' => 'No action required.', 'affected' => 'PHP ' . $version . ' (web server)', 'cta' => $cta, ); } /** * Validates $host through the shared SSRF guard and confirms it resolves * only to public IP(s). Returns a public IP (or null when unsafe). Used to * gate raw TLS sockets, which bypass the WP HTTP API's internal-host guard. * * @since 1.10.360 * @param string $host Hostname or IP literal. * @return string|null Public IP, or null when private/reserved/unresolvable. */ public static function resolve_safe_public_ip( string $host ): ?string { if ( '' === $host || ! CSDT_Thumbnails::is_safe_external_url( 'https://' . $host ) ) { return null; } if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { $ip = $host; } else { $ips = gethostbynamel( $host ); $ip = ( is_array( $ips ) && ! empty( $ips ) ) ? $ips[0] : ''; } if ( ! $ip || false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return null; } return $ip; } /** * Reads the certificate a host presents on port 443. Public because CSDT_Tls_Expiry * reads the same certificate daily; one reader, so the two cannot disagree. * * @param string $host Hostname. * @return array available=false plus error, or the certificate's dates and names. */ public static function check_ssl_certificate( string $host ): array { if ( empty( $host ) ) { return array( 'available' => false, 'error' => 'No host', ); } $ctx = stream_context_create( array( 'ssl' => array( 'capture_peer_cert' => true, 'verify_peer' => false, 'verify_peer_name' => false, ), ) ); // SSRF guard: a raw TLS socket bypasses the WP HTTP API's internal-host // protection, so refuse private/reserved/unresolvable hosts here. if ( null === self::resolve_safe_public_ip( $host ) ) { return array( 'available' => false, 'error' => 'Host resolves to a private, reserved, or unresolvable address' ); } // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $stream = @stream_socket_client( 'ssl://' . $host . ':443', $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $ctx ); if ( ! $stream ) { return array( 'available' => false, 'error' => $errstr ?: "errno $errno", ); } $params = stream_context_get_params( $stream ); fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing a socket stream; no WP equivalent $cert_res = $params['options']['ssl']['peer_certificate'] ?? null; if ( ! $cert_res ) { return array( 'available' => false, 'error' => 'No peer cert captured', ); } $cert = openssl_x509_parse( $cert_res ); if ( ! $cert ) { return array( '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 array( '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, 'valid_to_ts' => (int) $valid_to, '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 array( '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 ) { $txt_val = isset( $r['txt'] ) ? ( is_array( $r['txt'] ) ? implode( '', $r['txt'] ) : (string) $r['txt'] ) : ''; if ( stripos( $txt_val, 'v=spf1' ) === 0 ) { $spf_found = true; $spf_record = $txt_val; 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 ) { $txt_val = isset( $r['txt'] ) ? ( is_array( $r['txt'] ) ? implode( '', $r['txt'] ) : (string) $r['txt'] ) : ''; if ( stripos( $txt_val, 'v=DMARC1' ) === 0 ) { $dmarc_found = true; $dmarc_record = $txt_val; break; } } } // DKIM, probe common selectors used by major ESPs $dkim_found = false; $dkim_selector = null; foreach ( array( '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 ) { $txt_val = isset( $r['txt'] ) ? ( is_array( $r['txt'] ) ? implode( '', $r['txt'] ) : (string) $r['txt'] ) : ''; if ( stripos( $txt_val, '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 array( '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_ssh_status(): array { // Detect fail2ban installation and running state $fail2ban_paths = array( '/usr/bin/fail2ban-client', '/usr/sbin/fail2ban-client', '/usr/local/bin/fail2ban-client', ); $fail2ban_installed = false; foreach ( $fail2ban_paths as $p ) { if ( file_exists( $p ) ) { $fail2ban_installed = true; break; } } $fail2ban_running = file_exists( '/var/run/fail2ban/fail2ban.pid' ) || file_exists( '/run/fail2ban/fail2ban.pid' ); $fail2ban_jail = file_exists( '/etc/fail2ban/jail.conf' ) || file_exists( '/etc/fail2ban/jail.local' ); // Detect SSH daemon on port 22 (1-second timeout; skip if fsockopen unavailable) $ssh_port_open = false; $ssh_banner = ''; if ( function_exists( 'fsockopen' ) ) { $fp = @fsockopen( '127.0.0.1', 22, $errno, $errstr, 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fsockopen, WordPress.PHP.NoSilencedErrors.Discouraged -- raw SSH port probe; no WP equivalent if ( $fp !== false ) { $ssh_port_open = true; $banner = @fgets( $fp, 128 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- reading from raw socket; no WP equivalent $ssh_banner = $banner !== false ? trim( (string) $banner ) : ''; fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing a socket stream; no WP equivalent } } // Parse sshd_config for key hardening settings (read-only; usually readable by www-data) $sshd_config = ''; $sshd_config_paths = array( '/etc/ssh/sshd_config', '/etc/sshd_config' ); foreach ( $sshd_config_paths as $cp ) { if ( is_readable( $cp ) ) { $sshd_config = file_get_contents( $cp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading OS sshd_config; WP_Filesystem not designed for arbitrary OS paths break; } } $password_auth = 'unknown'; // yes | no | unknown $root_login = 'unknown'; // yes | no | prohibit-password | unknown $pubkey_auth = 'unknown'; if ( $sshd_config !== '' ) { if ( preg_match( '/^\s*PasswordAuthentication\s+(yes|no)/im', $sshd_config, $m ) ) { $password_auth = strtolower( $m[1] ); } if ( preg_match( '/^\s*PermitRootLogin\s+(\S+)/im', $sshd_config, $m ) ) { $root_login = strtolower( $m[1] ); } if ( preg_match( '/^\s*PubkeyAuthentication\s+(yes|no)/im', $sshd_config, $m ) ) { $pubkey_auth = strtolower( $m[1] ); } } return array( 'fail2ban_installed' => $fail2ban_installed, 'fail2ban_running' => $fail2ban_running, 'fail2ban_jail' => $fail2ban_jail, 'ssh_port_open' => $ssh_port_open, 'ssh_banner' => $ssh_banner, 'password_auth' => $password_auth, 'root_login' => $root_login, 'pubkey_auth' => $pubkey_auth, 'sshd_config_readable' => $sshd_config !== '', ); } private static function gather_external_checks( string $base_url = '' ): array { $base = $base_url ? trailingslashit( $base_url ) : home_url( '/' ); $host = (string) wp_parse_url( $base, PHP_URL_HOST ); $ext = array(); // SSL certificate $ext['ssl'] = self::check_ssl_certificate( $host ); // Helper: head request, returns [code, error] $head = function ( string $url ): array { $r = wp_safe_remote_head( $url, array( 'timeout' => 4, 'user-agent' => CSDT_DevTools::USER_AGENT, 'sslverify' => csdt_loopback_ssl_verify(), 'redirection' => 0, ) ); return is_wp_error( $r ) ? array( 'code' => 'error', 'error' => CSDT_Error_Text::in_seconds( $r->get_error_message() ), ) : array( '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'] = array( 'code' => $login_r['code'], 'accessible' => isset( $login_r['code'] ) && is_int( $login_r['code'] ) && $login_r['code'] < 400, ); // xmlrpc.php, record the raw code and an interpreted state. 403/401 is // blocked at the server/WAF layer, 404 absent, 405 present-but-POST-only // (the endpoint exists and accepts XML-RPC), <400 fully reachable. $xmlrpc_r = $head( $base . 'xmlrpc.php' ); $xmlrpc_code = $xmlrpc_r['code'] ?? null; $xmlrpc_state = 'unknown'; if ( is_int( $xmlrpc_code ) ) { if ( 403 === $xmlrpc_code || 401 === $xmlrpc_code ) { $xmlrpc_state = 'blocked'; } elseif ( 404 === $xmlrpc_code || 410 === $xmlrpc_code ) { $xmlrpc_state = 'absent'; } elseif ( 405 === $xmlrpc_code ) { $xmlrpc_state = 'present_post_only'; } elseif ( $xmlrpc_code < 400 ) { $xmlrpc_state = 'reachable'; } } $ext['xmlrpc'] = array( 'code' => $xmlrpc_code, 'accessible' => is_int( $xmlrpc_code ) && $xmlrpc_code < 400, 'state' => $xmlrpc_state, ); // REST API user enumeration $rest_r = wp_safe_remote_get( rest_url( 'wp/v2/users' ), array( 'timeout' => 5, 'user-agent' => CSDT_DevTools::USER_AGENT, 'sslverify' => csdt_loopback_ssl_verify(), ) ); $ext['rest_users'] = array( 'exposed' => false, 'count' => 0, 'slugs' => array(), ); $rest_j = CSDT_Http::json( $rest_r ); if ( 200 === $rest_j['code'] ) { $users = $rest_j['data']; if ( ! 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 ) ); } } // REST API ROOT discovery, /wp-json/ lists every registered namespace, // which enumerates the active plugin set. This is the main plugin // fingerprinting vector and was previously missed entirely (only the // /wp/v2/users route was probed). The payload can be ~200 KB, so it is // parsed here and only the extracted namespaces/slugs travel onwards. $ext['wp_json_root'] = self::probe_wp_json_root( $base ); // RDF feed author disclosure, /feed/rdf/ leaks logins even // when /wp-json/wp/v2/users is locked down, so the two vectors must be // reported independently. $ext['feed_user_enum'] = self::probe_feed_authors( $base ); // Author enumeration /?author=1 $author_r = wp_safe_remote_head( $base . '?author=1', array( 'timeout' => 4, 'user-agent' => CSDT_DevTools::USER_AGENT, 'sslverify' => csdt_loopback_ssl_verify(), 'redirection' => 0, ) ); $ext['author_enum'] = array( 'exposed' => false ); if ( ! is_wp_error( $author_r ) ) { $code = wp_remote_retrieve_response_code( $author_r ); $loc_raw = wp_remote_retrieve_header( $author_r, 'location' ); $loc = is_array( $loc_raw ) ? ( reset( $loc_raw ) ?: '' ) : (string) ( $loc_raw ?? '' ); if ( $code >= 300 && $code < 400 && $loc && strpos( $loc, '/author/' ) !== false ) { $ext['author_enum'] = array( 'exposed' => true, 'redirects_to' => $loc, ); } } // Directory listings, exposed files, database tools, status pages and // root backups: these were about 45 HEAD requests here that trusted a // bare 200. They now come from the Exposed folders scan, where a hit // needs a signature in the body. Same keys, so the fact set keeps its shape. foreach ( CSDT_Dir_Probe::legacy_facts( CSDT_Dir_Probe::latest() ) as $legacy_key => $legacy_value ) { $ext[ $legacy_key ] = $legacy_value; } // 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( content_url( 'debug.log' ) ); $ext['debug_log_exposed'] = isset( $debug_r['code'] ) && $debug_r['code'] === 200; // HTTP → HTTPS redirect enforcement $http_base = preg_replace( '/^https:/i', 'http:', $base ); $http_r = wp_safe_remote_head( $http_base, array( 'timeout' => 5, 'user-agent' => CSDT_DevTools::USER_AGENT, 'sslverify' => csdt_loopback_ssl_verify(), 'redirection' => 0, ) ); $http_code = is_wp_error( $http_r ) ? null : wp_remote_retrieve_response_code( $http_r ); $http_loc_raw = is_wp_error( $http_r ) ? null : wp_remote_retrieve_header( $http_r, 'location' ); $http_loc = is_array( $http_loc_raw ) ? ( reset( $http_loc_raw ) ?: '' ) : (string) ( $http_loc_raw ?? '' ); $ext['http_to_https'] = array( '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'] = array( 'checked' => false, 'tls10_accepted' => false, 'tls11_accepted' => false, ); // SSRF guard: skip raw weak-TLS sockets entirely for private/reserved/unresolvable hosts. if ( function_exists( 'stream_socket_client' ) && null !== self::resolve_safe_public_ip( $host ) ) { $tls_tests = array(); 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( array( 'ssl' => array( '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 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing a socket stream; no WP equivalent } } } // Cookie security flags, inspect Set-Cookie headers from wp-login.php $cookie_r = wp_safe_remote_get( $base . 'wp-login.php', array( 'timeout' => 5, 'user-agent' => CSDT_DevTools::USER_AGENT, 'sslverify' => csdt_loopback_ssl_verify(), ) ); $ext['cookie_security'] = array( 'checked' => false ); if ( ! is_wp_error( $cookie_r ) ) { $raw_headers = wp_remote_retrieve_headers( $cookie_r ); $set_cookies = array(); // WP_HTTP_Requests_Response may return Set-Cookie as array or string $sc = $raw_headers['set-cookie'] ?? array(); if ( is_string( $sc ) ) { $sc = array( $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'] = array( 'checked' => true, 'secure' => $all_secure, 'httponly' => $all_httponly, 'samesite' => $all_samesite, 'cookie_secure_constant' => defined( 'COOKIE_SECURE' ) && COOKIE_SECURE, ); } } // WAF / CDN detection $waf_detected = array(); $waf_headers_r = wp_safe_remote_get( $base, array( 'timeout' => 5, 'user-agent' => CSDT_DevTools::USER_AGENT, 'sslverify' => csdt_loopback_ssl_verify(), ) ); 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'; } $xcache_val = is_array( $wh['x-cache'] ?? null ) ? implode( ', ', $wh['x-cache'] ) : (string) ( $wh['x-cache'] ?? '' ); if ( $xcache_val && stripos( $xcache_val, 'cloudfront' ) !== false ) { $waf_detected[] = 'CloudFront'; } } // Also check if Wordfence plugin is active (server-side indicator) $active_plugins = (array) get_option( 'active_plugins', array() ); foreach ( $active_plugins as $pf ) { if ( stripos( $pf, 'wordfence' ) !== false && ! in_array( 'Wordfence', $waf_detected, true ) ) { $waf_detected[] = 'Wordfence (plugin active)'; } } $ext['waf_cdn'] = array( 'detected' => ! empty( $waf_detected ), 'providers' => $waf_detected, ); // Email security, only include SPF/DMARC/DKIM data if the domain has MX records. // Without MX records the domain sends no email and missing records are not a finding. $email_dns = self::check_email_dns( $host ); $ext['email_dns'] = $email_dns['mx_present'] ? $email_dns : array( 'email_configured' => false ); // Security headers (from external perspective via public URL) $headers_r = wp_safe_remote_get( $base, array( 'timeout' => 5, 'user-agent' => CSDT_DevTools::USER_AGENT, 'sslverify' => csdt_loopback_ssl_verify(), ) ); $headers_status = is_wp_error( $headers_r ) ? 0 : (int) wp_remote_retrieve_response_code( $headers_r ); $ext['security_headers_external'] = array(); $ext['csp_duplicate_count'] = 0; // Track whether bot protection blocked us, a 4xx/5xx means headers are unverifiable, // not necessarily absent (e.g. Cloudflare challenge pages omit HSTS/CSP for their own page). $ext['headers_response_code'] = $headers_status; $ext['headers_scan_blocked'] = $headers_status >= 400; if ( ! is_wp_error( $headers_r ) ) { $h = wp_remote_retrieve_headers( $headers_r ); // Build raw multi-value map to detect duplicate headers. // getAll() returns ['header-name' => value] where value may be an array // for sites that send the same header more than once. $raw_multi_ext = array(); foreach ( (array) $h->getAll() as $hk => $hv ) { $key = strtolower( trim( (string) $hk ) ); if ( is_array( $hv ) ) { $raw_multi_ext[ $key ] = array_map( 'strval', $hv ); } else { $raw_multi_ext[ $key ][] = (string) $hv; } } foreach ( array( 'x-frame-options', 'x-content-type-options', 'strict-transport-security', 'content-security-policy', 'content-security-policy-report-only', 'referrer-policy', 'permissions-policy', 'access-control-allow-origin', 'x-powered-by', 'server', ) as $hname ) { $all_vals = $raw_multi_ext[ $hname ] ?? null; if ( 'content-security-policy' === $hname && ! empty( $all_vals ) ) { $ext['csp_duplicate_count'] = count( $all_vals ); } // Use raw_multi_ext (built from getAll()) for reliable multi-value header access. $val = $raw_multi_ext[ $hname ] ?? null; $ext['security_headers_external'][ $hname ] = is_array( $val ) ? implode( ' | ', $val ) : $val; } } // CSP quality. Presence alone is not enough (weak directives leave XSS // open), and absence of an ENFORCED policy is not the same as no policy: // Content-Security-Policy-Report-Only is a deliberate posture on sites // that are still collecting violation reports, so it is graded // 'report-only' (informational) rather than 'missing' (a real gap). // unsafe-inline and unsafe-eval are WordPress compatibility requirements // (Underscore.js templates need eval; many plugins inject inline // scripts), so they are recorded as acknowledged trade-offs when our // plugin owns the CSP. $csp_val = $ext['security_headers_external']['content-security-policy'] ?? null; $csp_ro_val = $ext['security_headers_external']['content-security-policy-report-only'] ?? null; $csp_nonces_on = CSDT_Options::get( 'csdt_csp_nonces_enabled' ) === '1'; $csp_plugin_owned = CSDT_Options::get( 'csdt_devtools_csp_enabled' ) === '1'; $csp_quality = array( 'present' => (bool) $csp_val, 'enforced_present' => (bool) $csp_val, 'report_only_present' => (bool) $csp_ro_val, 'mode' => $csp_val ? 'enforced' : ( $csp_ro_val ? 'report_only' : 'none' ), 'issues' => array(), 'acknowledged' => array(), ); $csp_effective = $csp_val ? $csp_val : $csp_ro_val; if ( $ext['headers_scan_blocked'] ) { $csp_quality['grade'] = 'unverifiable'; $csp_quality['mode'] = 'unverifiable'; } elseif ( $csp_effective ) { if ( stripos( $csp_effective, "'unsafe-inline'" ) !== false ) { // Only flag unsafe-inline if nonce mode is off AND we're not managing it ourselves if ( $csp_nonces_on ) { $csp_quality['acknowledged'][] = 'unsafe-inline-removed-by-nonces'; } elseif ( $csp_plugin_owned ) { $csp_quality['acknowledged'][] = 'unsafe-inline-wordpress-compat'; } else { $csp_quality['issues'][] = 'unsafe-inline'; } } if ( stripos( $csp_effective, "'unsafe-eval'" ) !== false ) { // unsafe-eval is required by WordPress's bundled underscore.js (_.template) if ( $csp_plugin_owned ) { $csp_quality['acknowledged'][] = 'unsafe-eval-wordpress-compat'; } else { $csp_quality['issues'][] = 'unsafe-eval'; } } if ( preg_match( '/(?:^|[\s;])(\*)[\s;]/', $csp_effective ) ) { $csp_quality['issues'][] = 'wildcard-source'; } if ( stripos( $csp_effective, 'default-src' ) === false ) { $csp_quality['issues'][] = 'no-default-src'; } if ( ! $csp_val ) { // Report-only policy present, nothing enforced: informational. $csp_quality['grade'] = 'report-only'; $csp_quality['note'] = 'Content-Security-Policy-Report-Only is present and no enforced policy is set. This is a deliberate configuration on many sites (violation reporting before enforcement), so it must not be reported as a missing CSP; informational at most.'; } else { $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 = array( 'present' => (bool) $hsts_val, 'issues' => array(), ); if ( $ext['headers_scan_blocked'] ) { $hsts_quality['grade'] = 'unverifiable'; } elseif ( $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'] = array( 'header' => $server_hdr, 'leaks_version' => $server_hdr !== null && (bool) preg_match( '/\/[\d.]+/', $server_hdr ), ); return $ext; } /** * Returns true if a known mitigation pattern sits near the flagged line, * meaning the static-pattern match is almost certainly a false positive. * * Looks 30 lines above and 5 below for guard tokens that are specific to * the pattern label. The window is wide above because realpath/sanitize * checks typically run earlier in the function body; small below because * post-call guards don't prevent the dangerous write. */ private static function flag_is_guarded( string $content, int $line_no, string $label ): bool { $lines = explode( "\n", $content ); $start = max( 0, $line_no - 31 ); $end = min( count( $lines ) - 1, $line_no + 4 ); $ctx = implode( "\n", array_slice( $lines, $start, $end - $start + 1 ) ); $guards = array( 'move_uploaded_file()' => array( 'realpath(', // realpath jail validates destination "sprintf( 'chunk-", // sprintf with numeric format prevents traversal via index 'wp_check_filetype', // mime-type validation ), 'file_put_contents with user input' => array( 'realpath(', 'sanitize_file_name(', 'wp_normalize_path(', ), 'outbound request with user input' => array( 'wp_http_validate_url', 'is_safe_external_url', // CSDT's own SSRF guard 'wp_parse_url(', // typically paired with allow-list checks ), ); if ( empty( $guards[ $label ] ) ) { return false; } foreach ( $guards[ $label ] as $needle ) { if ( stripos( $ctx, $needle ) !== false ) { return true; } } return false; } 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', array() ); $plugins_dir = CSDT_Paths::plugins_dir(); // Patterns that warrant attention in plugin code. Every key is used as a // regex fragment inside '/.../ i', parentheses MUST be escaped or the // pattern fails to compile (and the check silently never matches). // // The VALUES are display labels, and several of them spell out function names // WordPress.org's reviewers grep for. This plugin calls none of them: there is no // shell-execution or dynamic-evaluation call anywhere in the shipped tree, and no // code path that could reach one. These are // the vocabulary of a malware scanner naming what it found, and a scanner that // cannot name the thing it detected is not much use to the person reading its // report. csdt-submission-ok: malware-signature labels for the static scanner's // report, not calls, the plugin invokes none of these functions. // A bare \b is the WRONG left boundary for a function-call pattern, and it // produced a CRITICAL false positive on 2026-09-16. In `$exec( $t )` the word // boundary sits between the `$` and the `e`, so `\bexec\s*\(` matched a // VARIABLE CALLBACK and labelled the line "e_x_e_c()". The AI triage stage was then // asked to explain a line already labelled as shell execution, and did: it // reported arbitrary code execution in a function whose $exec is a type-hinted // `callable` parameter receiving a literal closure. The model was not wrong so // much as fed a false premise by this table. // // The same flaw applied to every entry below: `$system(`, `->exec()`, // `self::a_s_s_e_r_t()` and even `function e_x_e_c(` all matched. Measured across the // ten cases in the fixture, \b matched 5 of 6 non-calls. // // CSDT_CALL_BOUNDARY rejects a preceding `$` (variable), `>` or `:` (method or // static call), a word character (a prefixed function such as csbr_exec), and a // `function ` declaration, while still matching `e_x_e_c(`, `@e_x_e_c(` and // `return e_x_e_c(`. Use it for every pattern that means "a call to THIS function". $call = self::CALL_BOUNDARY; $patterns = array( // Remote code execution $call . 'eval\s*\(' => 'eval()', $call . 'base64_decode\s*\(' => 'base64_decode()', $call . 'exec\s*\(' => 'exec()', $call . 'shell_exec\s*\(' => 'shell_exec()', $call . 'system\s*\(' => 'system()', $call . 'passthru\s*\(' => 'passthru()', $call . 'popen\s*\(' => 'popen()', $call . 'proc_open\s*\(' => 'proc_open()', $call . 'assert\s*\(' => 'assert()', 'preg_replace.*\/e' => 'preg_replace /e modifier', $call . 'create_function\s*\(' => '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)', // Stored XSS: comment text printed straight from the object, skipping // comment_text() and every display filter. Core's kses on save is then the only // thing between a visitor's comment and every reader's browser, and a comment // written by any other route (an import, a form plugin, a REST client) never // passed through that either. Themes are where this lives, which is why the // active theme is scanned below as well as the plugins. '(echo|print)\s*\(?\s*\$\w+\s*->\s*comment_content' => 'comment text printed without escaping (stored XSS risk)', '(echo|print)\s*\(?\s*\$\w+\s*\[\s*[\x27"]comment_content' => 'comment text printed without escaping (stored XSS risk)', ); $results = array(); // What gets scanned: every active plugin, then the active theme and its parent. // Themes were never scanned, and they are where comment output is written; a theme // printing comment text raw was invisible to this report. Keyed by the label the // report shows, 'theme:' for a theme, so the two can never be confused. $targets = array(); foreach ( $active_plugins as $plugin_file ) { $plugin_slug = dirname( $plugin_file ); if ( $plugin_slug === '.' ) { continue; // single-file plugin, skip } $targets[ $plugin_slug ] = $plugins_dir . '/' . $plugin_slug; } if ( function_exists( 'get_stylesheet_directory' ) ) { $targets[ 'theme:' . get_stylesheet() ] = get_stylesheet_directory(); if ( get_template() !== get_stylesheet() ) { $targets[ 'theme:' . get_template() ] = get_template_directory(); } } foreach ( $targets as $plugin_slug => $plugin_path ) { if ( ! is_dir( $plugin_path ) ) { continue; } // Skip known safe large libraries $skip_dirs = array( 'vendor', 'node_modules', 'assets', 'dist', 'build' ); $findings = array(); $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 = is_readable( $file->getPathname() ) ? file_get_contents( $file->getPathname() ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents 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 ) ) { // Skip if a known guard pattern sits nearby, kills the most // common false positives (move_uploaded_file behind a realpath // jail; file_put_contents after sanitize_file_name; etc.) if ( self::flag_is_guarded( $content, $ln + 1, $label ) ) { break; } $findings[] = array( '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[] = array( 'plugin' => $plugin_slug, 'base' => $plugin_path, '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 array( 'skipped' => true, 'reason' => 'no_findings', 'results' => array(), ); } // Flatten findings and sort by risk priority // Same as $patterns above: these are report labels, matched against findings, and // none of them is a call. csdt-submission-ok: scanner finding labels, not calls. $priority_order = array( '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)', 'comment text printed without escaping (stored XSS risk)', 'print user input without escaping (XSS risk)', 'file_put_contents with user input', 'move_uploaded_file()', 'outbound request with user input', ); $flat = array(); foreach ( $scan_results as $plugin_result ) { foreach ( $plugin_result['findings'] as $finding ) { $flat[] = array_merge( $finding, array( 'plugin' => $plugin_result['plugin'], 'base' => (string) ( $plugin_result['base'] ?? '' ), ) ); } } 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 = array(); foreach ( $top as $idx => $s ) { $base = '' !== $s['base'] ? $s['base'] : CSDT_Paths::plugins_dir() . '/' . $s['plugin']; $abs = $base . '/' . $s['file']; // read-only: ten lines of context from a flagged third-party file for AI triage $ctx = ''; if ( is_readable( $abs ) ) { // Read via the WP_Filesystem helper (not file()/fopen) per WordPress.org // guidance; split into lines, mirroring file()'s FILE_IGNORE_NEW_LINES. $raw = self::fs_read( $abs ); $lines = ( false !== $raw ) ? preg_split( '/\r\n|\r|\n/', $raw ) : false; 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.' . "\n\nCOMMON FALSE-POSITIVE PATTERNS, mark as false_positive when present:" . "\n- move_uploaded_file() preceded by a realpath() jail check (e.g. \$real_dir = realpath(\$dir); strpos(realpath(dirname(\$part)) . DIRECTORY_SEPARATOR, \$real_dir . DIRECTORY_SEPARATOR) !== 0). The destination cannot escape the base directory." . "\n- Destination paths built with sprintf('%06d', \$intval) or any %d/%u/%x format specifier, these are numeric and cannot inject path separators or '..' sequences." . "\n- File operations where the user input is hardened via intval(), absint(), floatval(), or sanitize_file_name() before use in the path." . "\n- AJAX handlers with check_ajax_referer() + current_user_can('manage_options') gates, admin-only endpoints behind a nonce are not externally exploitable without admin compromise." . "\n- eval() inside template engines, base64_decode() for legitimate asset loading, shell_exec() behind a capability check." . "\n\nReturn 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 = CSDT_AI_Dispatcher::fast_model( (string) $provider ); try { $raw = CSDT_AI_Dispatcher::call( $system, $user, $triage_model, 2048 ); } catch ( \Throwable $e ) { return array( 'skipped' => true, 'reason' => 'api_error', 'error' => $e->getMessage(), 'results' => array(), ); } // 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 array( 'skipped' => true, 'reason' => 'parse_error', 'raw_preview' => substr( $raw, 0, 300 ), 'results' => array(), ); } // Index verdicts by id for merge $by_id = array(); foreach ( $verdicts as $v ) { if ( isset( $v['id'] ) ) { $by_id[ (int) $v['id'] ] = $v; } } $output = array(); foreach ( $top as $idx => $s ) { $v = $by_id[ $idx + 1 ] ?? array(); $output[] = array( '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 array( 'skipped' => false, 'snippets_triaged' => count( $output ), 'confirmed_count' => count( $confirmed ), 'results' => $output, ); } /** * Fact-checks critical/high findings that name an active plugin, against that plugin's * actual source, before the finding is allowed to keep that severity. * * WHY THIS EXISTS * --------------- * The report-writing prompt only mandates "critical" for an unfixed CVE at CVSS >= 7 or a * code_triage-confirmed static-analysis hit. A private, unlisted plugin has no CVE feed * coverage, and triage_code_snippets_with_ai() above caps at the top 10 candidates site-wide * with a single-line regex, so it is possible (confirmed on 2026-08-29, cloudscale-sql-runner * and cloudscale-test-accounts both called CRITICAL) for the model to assign "critical" from a * plugin's NAME and version number alone, with nothing anywhere having actually read its code. * * This closes that gap by construction rather than by widening a regex: any surviving * critical/high finding that names an active plugin without already citing a CVE gets ONE * fresh, targeted look, the plugin's own source, fed to a cheap model whose only job is to * confirm or refute the specific claim already made, never to invent a new one. * * Costs nothing extra when there is nothing to check: the common case, every scan that names no * specific uncited plugin, returns immediately with zero added AI calls. * * @param array $report Assembled report with critical/high/medium/low/good keys. * @return array The same report, with unsupported plugin claims downgraded to low. */ public static function factcheck_plugin_findings( array $report ): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $active_slugs = array(); foreach ( (array) get_option( 'active_plugins', array() ) as $plugin_file ) { $slug = dirname( $plugin_file ); if ( '.' !== $slug ) { $active_slugs[ $slug ] = true; } } $suspects = array(); foreach ( array( 'critical', 'high' ) as $sev ) { foreach ( (array) ( $report[ $sev ] ?? array() ) as $idx => $f ) { $slug = (string) ( $f['affected_component'] ?? '' ); if ( '' === $slug || ! isset( $active_slugs[ $slug ] ) ) { continue; } if ( preg_match( '/CVE-\d{4}-\d+/', (string) ( $f['detail'] ?? '' ) ) ) { continue; // already grounded in a real, feed-verified CVE } $suspects[] = array( 'sev' => $sev, 'idx' => $idx, 'slug' => $slug, 'title' => (string) ( $f['title'] ?? '' ), 'detail' => (string) ( $f['detail'] ?? '' ), ); } } if ( empty( $suspects ) ) { return $report; } $plugins_dir = CSDT_Paths::plugins_dir(); $skip_dirs = array( 'vendor', 'node_modules', 'assets', 'dist', 'build' ); $blocks = array(); foreach ( $suspects as $idx => $s ) { $plugin_path = $plugins_dir . '/' . $s['slug']; $source = ''; $budget = 40000; if ( is_dir( $plugin_path ) ) { $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 ( $budget <= 0 ) { break; } $rel = str_replace( $plugin_path . '/', '', $file->getPathname() ); $content = self::fs_read( $file->getPathname() ); // csdt-submission-ok: read-only, reads this site's own active plugin source to fact-check a claim already made about it if ( false === $content ) { continue; } $chunk = substr( $content, 0, $budget ); $budget -= strlen( $chunk ); $source .= "\n--- {$rel} ---\n" . $chunk; } } if ( '' === trim( $source ) ) { $source = '(no readable PHP source found on disk for this plugin)'; } $blocks[] = '[' . ( $idx + 1 ) . '] Plugin: ' . $s['slug'] . "\nClaimed finding: " . $s['title'] . "\nClaimed detail: " . $s['detail'] . "\n\n--- Plugin source ---" . $source; } $system = 'You are a WordPress PHP security fact-checker. You will be given a security claim already made about a plugin, and that plugin\'s actual source code. Your only job is to confirm or refute the EXISTING claim against the supplied code, never invent a new finding, and never infer risk from the plugin\'s name, description or version number alone.' . "\n\nIf the code supports the claim, cite the exact file and line and quote the relevant code. If it does not, say plainly that no supporting code was found, do not hedge or soften this into a maybe." . "\n\nReturn ONLY a valid JSON array with no markdown wrapping."; $user = 'Fact-check these ' . count( $blocks ) . " plugin-risk claims against their actual source.\n\n" . implode( "\n\n", $blocks ) . "\n\n" . "Return a JSON array, one object per claim:\n" . '{"id":,"verdict":"confirmed|unsupported","severity":"critical|high|medium|low","corrected_detail":""}'; $provider = get_option( 'csdt_devtools_ai_provider', 'anthropic' ); $model = CSDT_AI_Dispatcher::fast_model( (string) $provider ); try { $raw = CSDT_AI_Dispatcher::call( $system, $user, $model, 2048 ); } catch ( \Throwable $e ) { return $report; // fact-check unavailable; leave the report as the narrative model wrote it } $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 $report; } $by_id = array(); foreach ( $verdicts as $v ) { if ( isset( $v['id'] ) ) { $by_id[ (int) $v['id'] ] = $v; } } // Apply verdicts. A finding only stays a "critical" or "high" by living in that bucket, // there is no separate severity field the rest of the plugin reads, so a verdict that // disagrees with the tier it started in must actually MOVE the finding, not just tag it. // Removals are collected by original (sev, idx) and applied in one pass at the end, so // indexes into $report[$sev] are never disturbed mid-loop. $valid_sevs = array( 'critical', 'high', 'medium', 'low' ); $to_remove = array(); foreach ( $suspects as $idx => $s ) { $v = $by_id[ $idx + 1 ] ?? null; if ( null === $v ) { continue; // no verdict returned for this one; leave the original finding untouched } $confirmed = 'confirmed' === ( $v['verdict'] ?? '' ); $target = $confirmed ? (string) ( $v['severity'] ?? $s['sev'] ) : 'low'; if ( ! in_array( $target, $valid_sevs, true ) ) { $target = $confirmed ? $s['sev'] : 'low'; } $detail = (string) ( $v['corrected_detail'] ?? ( $confirmed ? $report[ $s['sev'] ][ $s['idx'] ]['detail'] : 'Unverified category-level caution, no supporting code was found for this specific plugin.' ) ); if ( $target === $s['sev'] ) { $report[ $s['sev'] ][ $s['idx'] ]['detail'] = $detail; continue; } $to_remove[] = $s['sev'] . ':' . $s['idx']; $report[ $target ] = array_merge( (array) ( $report[ $target ] ?? array() ), array( array_merge( $report[ $s['sev'] ][ $s['idx'] ], array( 'detail' => $detail ) ) ) ); } foreach ( array( 'critical', 'high' ) as $sev ) { $keys = array(); foreach ( $to_remove as $key ) { if ( 0 === strpos( $key, $sev . ':' ) ) { $keys[] = (int) substr( $key, strlen( $sev ) + 1 ); } } if ( $keys ) { $report[ $sev ] = array_values( array_diff_key( $report[ $sev ], array_flip( $keys ) ) ); } } return $report; } /** * The findings whose fix nothing in this product can perform. * * WHY THIS EXISTS * --------------- * `docs/tab-ia-design.md` counted five HIGH findings with no UI at all: install a backup plugin, * edit `wp-config.php`, `chown` in a root shell, rename `admin` via Users. Those refusals are * correct, a plugin that could chown its own files would be a vulnerability, but the * instruction existed only as one line of prose inside a scan result the reader has to expand. * They are simultaneously the highest-severity items and the least visible ones. * * READ FROM THE CACHED AUDIT, NOT RE-DERIVED. Every fact needed is already in * `csdt_site_audit_cache`: severity, title, the `where` sentence, and whether a route exists. The * alternative was re-running the checks on a page load, and one of them (file ownership) is a * filesystem walk over the whole install. Re-deriving would also mean a second copy of each rule, * the thing that made WEAK_USERNAMES worth promoting to a constant above. * * PURE, so it can be tested. On the site this was written against, ALL twelve cached findings are * healthy and exactly zero qualify, so the surface cannot be seen working from live data. A * function taking findings and returning steps can be fed the situation a new client is actually * in; logic buried in markup could only be eyeballed on a site that never triggers it. * * @param array $findings Cached audit findings. * @param int $limit Most steps to return; the rest stay in the audit report. * @return array Steps, worst severity first. */ public static function manual_steps( array $findings, int $limit = 4 ): array { // The shared table, minus 'info'. An info finding is a statement that something is FINE // ("Login URL is hidden"), and a to-do list that lists reassurance as work is the nag the // guided-setup design explicitly rules out. Subtracting one key from the shared order beats // writing a second table that has to be kept in step with it. $rank = self::SEVERITY_ORDER; unset( $rank['info'] ); $steps = array(); foreach ( $findings as $f ) { if ( ! is_array( $f ) ) { continue; } $sev = strtolower( (string) ( $f['severity'] ?? '' ) ); if ( ! isset( $rank[ $sev ] ) ) { continue; } // A `where` is the instruction for a fix this product cannot make. A `url` means it CAN // send you somewhere that does it, so the finding is already surfaced by the fix route // and repeating it here would double-list the same work. $where = trim( (string) ( $f['fix_route_where'] ?? '' ) ); if ( '' === $where || '' !== trim( (string) ( $f['fix_route_url'] ?? '' ) ) ) { continue; } $steps[] = array( 'key' => (string) ( $f['key'] ?? '' ), 'severity' => $sev, 'title' => (string) ( $f['title'] ?? '' ), 'where' => $where, 'rank' => $rank[ $sev ], ); } usort( $steps, static function ( array $a, array $b ): int { // Severity first, then key, so the order is stable rather than dependent on the // order the audit happened to emit findings in. return $a['rank'] <=> $b['rank'] ?: strcmp( $a['key'], $b['key'] ); } ); return array_slice( $steps, 0, max( 1, $limit ) ); } /** * Whether any administrator still holds a guessable login. * * Exists so a panel can ask the question without duplicating WEAK_USERNAMES or re-deriving the * rule. Cheap enough for a page render, one role-filtered user query, the same one audit_users() * makes, and deliberately answers only this narrow question rather than returning the audit's * whole user report. * * @return bool True if at least one administrator login is on the weak list. */ public static function has_weak_admin_login(): bool { foreach ( get_users( array( 'role' => 'administrator', 'fields' => array( 'user_login' ) ) ) as $user ) { if ( in_array( strtolower( $user->user_login ), self::WEAK_USERNAMES, true ) ) { return true; } } return false; } private static function audit_users(): array { $weak_usernames = self::WEAK_USERNAMES; $admins = get_users( array( 'role' => 'administrator' ) ); $weak_admin_logins = array(); $admins_no_2fa = array(); 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 = CSDT_Options::get( 'csdt_devtools_2fa_method' ) === 'email'; if ( ! $has_totp && ! $has_passkey && ! $has_email2fa ) { $admins_no_2fa[] = $user->user_login; } } $role_counts = array(); foreach ( array( 'editor', 'author', 'contributor', 'subscriber' ) as $role ) { // count() alone is shape-safe, but routed through the same helper so every // user query in this file returns one predictable type. A mixed convention // is how the next person reintroduces the object/int confusion. $count = count( self::user_ids( array( 'role' => $role ) ) ); if ( $count > 0 ) { $role_counts[ $role ] = $count; } } return array( '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 array( 'disable_wp_cron' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON, 'total_events' => 0, 'hooks' => array(), 'suspicious_hooks' => array(), ); } // Collect all scheduled hook names $all_hooks = array(); 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 = array( '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', array() ); $plugin_prefixes = array_map( fn( $f ) => strtolower( str_replace( '-', '_', dirname( $f ) ) ), $active_plugins ); $suspicious = array(); foreach ( $unique_hooks as $hook ) { if ( in_array( $hook, $core_hooks, true ) ) { continue; } // A hook something loaded has a callback for is owned, whatever it is // called. Without this the name heuristic below was the only test, and // it matches the plugin's FOLDER name, so this plugin's own csdt_* hooks // (folder cloudscale-cyber-devtools) were reported as "28 scheduled // tasks match no active plugin" on the 2026-10-19 deep dive, a medium // finding about ourselves at the top of the report. if ( has_action( $hook ) ) { 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 array( '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 = array(); $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', array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 6, 'sslverify' => true, ) ); $r = CSDT_Http::json( $resp ); if ( 200 !== $r['code'] ) { continue; } $data = $r['data']; 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 ] = array( '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; } /** * Known advisories for the active plugins, and which plugins could not be checked. * * Until 1.10.921 every failed lookup read as "no advisories": a network error, a 429, a 5xx, * an HTML page and a changed response shape all hit the same `continue` as a plugin with a * clean record, so a scan run while the feed was down reported every plugin CVE-free. Only a * 200 carrying the feed's own shape, or a 404 (the feed does not know the plugin), is an * answer. Everything else lands in $unchecked, which cve_lookup_findings() turns into a * finding. * * @param array $active_plugin_files active_plugins entries. * @param array $all_plugins get_plugins() output. * @param array $unchecked Filled with "slug: reason" for each plugin not checked. * @return array> Matching advisories, at most 20. */ private static function check_plugin_vulnerabilities( array $active_plugin_files, array $all_plugins, array &$unchecked = array() ): array { $vulns = array(); $unchecked = array(); $capped = false; foreach ( $active_plugin_files as $plugin_file ) { $slug = dirname( $plugin_file ); $version = $all_plugins[ $plugin_file ]['Version'] ?? null; if ( $slug === '.' || ! $version ) { continue; } if ( $capped ) { $unchecked[] = $slug . ': not looked up, the 20-advisory cap was reached first'; continue; } // WPVulnerability advisories (free, no key), asked for via the CloudScale API // rather than WPVulnerability's own API directly, so this plugin contacts one // documented service instead of a third-party one from every installed site. // The proxy returns WPVulnerability's own body unchanged and caches it for a // day, so the parsing below is unaffected. Replaces the Patchstack public // endpoint, which was retired (it now 302-redirects to an HTML page, so the // old check silently returned zero CVEs). $resp = wp_remote_get( self::PROXY_VULN_ENDPOINT . '?source=wpv-plugin&slug=' . rawurlencode( $slug ), array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 8, 'sslverify' => true, ) ); $r = CSDT_Http::json( $resp ); if ( 0 === $r['code'] ) { $unchecked[] = $slug . ': network error, ' . $r['error']; continue; } $code = $r['code']; // 404 is the feed's answer for a plugin it has no record of (premium, custom). if ( 404 === $code ) { continue; } if ( 200 !== $code ) { $unchecked[] = $slug . ': HTTP ' . $code . ' from the advisory feed'; continue; } $data = $r['data']; if ( null === $data || ! array_key_exists( 'data', $data ) ) { $unchecked[] = $slug . ': the advisory feed answered in an unexpected shape'; continue; } $list = $data['data']['vulnerability'] ?? null; if ( empty( $list ) || ! is_array( $list ) ) { continue; // Answered, and nothing is known. } foreach ( $list as $vuln ) { // Only include if the installed version falls in the affected range. if ( ! self::wpvuln_version_affected( $version, $vuln['operator'] ?? array() ) ) { continue; } $src = $vuln['source'][0] ?? array(); $score = $vuln['impact']['cvss']['score'] ?? null; $vulns[] = array( 'plugin' => $slug, 'version' => $version, 'cve' => $src['id'] ?? null, 'title' => $vuln['name'] ?? ( $src['name'] ?? 'Unknown vulnerability' ), 'severity' => self::cvss_severity( $score ), 'cvss' => $score, 'fixed_in' => self::wpvuln_fixed_in( $vuln['operator'] ?? array() ), ); if ( count( $vulns ) >= 20 ) { // Cap total, to bound the prompt. The plugins after this one are // reported as not looked up rather than silently skipped. $capped = true; break; } } } return $vulns; } /** * WordPress core CVEs affecting the running version, from the WPVulnerability * public API. The /core// feed is already scoped to that version, so * every returned entry affects this site, no client-side version filter needed. * This is the gap the plugin CVE check never covered: WordPress CORE advisories. * * @param string $version Running WordPress core version. * @return array> */ private static function check_core_vulnerabilities( string $version, string &$unchecked = '' ): array { $unchecked = ''; if ( '' === $version ) { $unchecked = 'the running WordPress version could not be read'; return array(); } // Via the CloudScale API, not WPVulnerability's own API directly, see the note on the // plugin lookup above. The upstream body is passed through unchanged. $resp = wp_remote_get( self::PROXY_VULN_ENDPOINT . '?source=wpv-core&version=' . rawurlencode( $version ), array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 8, 'sslverify' => true, ) ); // The core endpoint is the feed health canary: it must succeed for any // live version, so a transport/5xx/shape failure here means the CVE feed // is unreachable, alert, so a broken feed can't masquerade as "no CVEs". $r = CSDT_Http::json( $resp ); if ( 0 === $r['code'] ) { $unchecked = 'network error, ' . $r['error']; self::alert_cve_feed_down( 'core', $unchecked ); return array(); } $code = $r['code']; if ( $code >= 500 || ( 200 !== $code && 404 !== $code ) ) { $unchecked = 'HTTP ' . $code . ' from the advisory feed'; self::alert_cve_feed_down( 'core', 'HTTP ' . $code . ' from CVE feed' ); return array(); } if ( 404 === $code ) { return array(); // core version not tracked by the feed, not an outage } $data = $r['data']; if ( null === $data || ! isset( $data['data'] ) ) { $unchecked = 'the advisory feed answered in an unexpected shape'; self::alert_cve_feed_down( 'core', 'unexpected response shape, the CVE API may have changed' ); return array(); } $list = $data['data']['vulnerability'] ?? array(); if ( empty( $list ) || ! is_array( $list ) ) { return array(); // genuinely no known core CVEs for this version } $out = array(); foreach ( $list as $vuln ) { $src = $vuln['source'][0] ?? array(); $score = $vuln['impact']['cvss']['score'] ?? null; $out[] = array( 'version' => $version, 'cve' => $src['id'] ?? null, 'title' => self::wpvuln_core_title( $src ), 'severity' => self::cvss_severity( $score ), 'cvss' => $score, 'fixed_in' => self::wpvuln_fixed_in( $vuln['operator'] ?? array() ), ); if ( count( $out ) >= 20 ) { break; } } return $out; } /** True when $version falls inside a WPVulnerability `operator` affected range. */ private static function wpvuln_version_affected( string $version, array $op ): bool { $map = array( 'lt' => '<', 'le' => '<=', 'ge' => '>=', 'gt' => '>', 'eq' => '=' ); $ok = true; if ( ! empty( $op['min_version'] ) && isset( $map[ $op['min_operator'] ?? '' ] ) ) { $ok = $ok && version_compare( $version, $op['min_version'], $map[ $op['min_operator'] ] ); } if ( ! empty( $op['max_version'] ) && isset( $map[ $op['max_operator'] ?? '' ] ) ) { $ok = $ok && version_compare( $version, $op['max_version'], $map[ $op['max_operator'] ] ); } return $ok; // no bounds → treat as affected (conservative) } /** Version a WPVulnerability entry was fixed in (upper bound), or null if unfixed. */ private static function wpvuln_fixed_in( array $op ): ?string { if ( ! empty( $op['max_version'] ) && in_array( $op['max_operator'] ?? '', array( 'lt', 'le' ), true ) ) { return (string) $op['max_version']; } return null; } /** Coarse severity band from a CVSS base score. */ private static function cvss_severity( $score ): ?string { if ( null === $score || '' === $score ) { return null; } $s = (float) $score; if ( $s >= 9.0 ) { return 'critical'; } if ( $s >= 7.0 ) { return 'high'; } if ( $s >= 4.0 ) { return 'medium'; } if ( $s > 0 ) { return 'low'; } return null; } /** Concise title for a core CVE from its source description. */ private static function wpvuln_core_title( array $src ): string { $d = (string) ( $src['description'] ?? '' ); $d = trim( (string) preg_replace( '/^\[[a-z]{2}\]\s*/i', '', $d ) ); if ( '' === $d ) { return (string) ( $src['name'] ?? 'WordPress core vulnerability' ); } $d = (string) preg_replace( '/\s+/', ' ', $d ); return function_exists( 'mb_substr' ) ? mb_substr( $d, 0, 140 ) : substr( $d, 0, 140 ); } /** * The deep scan's failed advisory lookups, as one list. * * @since 1.10.921 * @param array $plugins "slug: reason" per plugin not checked. * @param string $core Why core was not checked, '' when it was. * @return array */ private static function cve_lookup_unchecked( array $plugins, string $core ): array { $out = array(); if ( '' !== $core ) { $out[] = 'WordPress core: ' . $core; } return array_merge( $out, array_values( $plugins ) ); } /** * A finding for advisory lookups that failed, so a scan run while the feed is down cannot * read as "no known vulnerabilities". Rule-built, like server_software_findings(): it has to * reach the report whether or not a model runs. * * Medium, not low: an unchecked component is exactly the case where a real, exploited CVE * goes unreported, and the report's silence on it would otherwise read as a clean result. * * @since 1.10.921 * @param array $unchecked cve_lookup_unchecked() output. * @return array{critical:array,high:array,medium:array,low:array,good:array} */ public static function cve_lookup_findings( array $unchecked ): array { $out = array( 'critical' => array(), 'high' => array(), 'medium' => array(), 'low' => array(), 'good' => array(), ); if ( ! $unchecked ) { return $out; } $shown = array_slice( $unchecked, 0, 8 ); $more = count( $unchecked ) - count( $shown ); $out['medium'][] = array( 'title' => sprintf( /* translators: %d: number of components */ _n( '%d component could not be checked for known vulnerabilities', '%d components could not be checked for known vulnerabilities', count( $unchecked ), 'cloudscale-cyber-devtools' ), count( $unchecked ) ), 'detail' => 'The advisory lookup did not answer for these on this run, so they are unchecked, not clean: ' . implode( '; ', $shown ) . ( $more > 0 ? sprintf( '; and %d more', $more ) : '' ) . '. The absence of a CVE finding for any of them in this report means nothing.', 'fix' => 'Re-run the scan. If the same components fail again, the advisory feed may be unreachable from this server: check outbound HTTPS to api.cloudscale.consulting.', 'affected_component' => 'Known-vulnerability lookup', 'confidence' => 'high', 'evidence' => 'rule', ); return $out; } /** * Alert (CS Monitor log + Telegram) when the CVE feed is unreachable, so a * broken feed can never silently masquerade as "no CVEs found" the way the * retired Patchstack endpoint did. Throttled to once per 6 hours per site. * * @param string $which Which lookup failed (e.g. 'core'). * @param string $detail Short failure detail. * @return void */ private static function alert_cve_feed_down( string $which, string $detail ): void { // An option-backed window: a transient here was erased by every deploy's cache flush. if ( ! CSDT_Alert_Window::claim( 'cve_feed_down', 6 * HOUR_IN_SECONDS ) ) { return; } // CS Monitor surfaces error_log() output in its Logs tab. self::log_line( 'CSDT: CVE feed unreachable (' . $which . '): ' . $detail ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- operational alert for a broken security feed if ( class_exists( 'CSDT_Telegram' ) && CSDT_Telegram::is_configured() ) { $site = (string) ( wp_parse_url( home_url(), PHP_URL_HOST ) ?: home_url() ); CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, "\u{26A0}\u{FE0F} CVE feed unreachable on {$site}\n\n" . "The {$which} vulnerability lookup (api.cloudscale.consulting/vuln-lookup) failed: {$detail}\n\n" . 'Security scans cannot confirm known CVEs until this clears, do NOT treat a clean scan as CVE-free while this alert stands.', 'Cyber Audit', 'warning' ); } } /** * Alert when an AI half came back with nothing, which means the call timed out in transit. * * This is the failure that had no alert of its own, and its absence is why it ran for so * long unnoticed. CS Monitor did fire during the reported incident, on the `scan_status` * poll returning 503, which is a SYMPTOM: admin-ajax queuing behind the scan's own PHP-FPM * worker. The cause was two 504s at 90.18s and 90.30s from the managed endpoint, and * nothing anywhere said so. An alert on the symptom sends you looking in the wrong place. * * Throttled to once per 6 hours per the CVE-feed precedent above, because a bad afternoon * upstream must not fill the activity log and evict every other security event, that has * happened here before. * * @since 1.10.560 * @param string $label Which half ('internal' / 'external'). * @param string $detail Short failure detail. * @return void */ private static function alert_ai_gateway_timeout( string $label, string $detail ): void { if ( ! CSDT_Alert_Window::claim( 'ai_gateway_timeout', 6 * HOUR_IN_SECONDS ) ) { return; } // CS Monitor surfaces error_log() output in its Logs tab. self::log_line( 'CSDT: AI call returned nothing (' . $label . ' half), the request timed out in transit: ' . $detail ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- operational alert for a silently failing AI path if ( class_exists( 'CSDT_Telegram' ) && CSDT_Telegram::is_configured() ) { $site = (string) ( wp_parse_url( home_url(), PHP_URL_HOST ) ?: home_url() ); CSDT_Alert_Events::send( CSDT_Alert_Events::GENERAL, "\u{26A0}\u{FE0F} AI request timed out in transit on {$site}\n\n" . "The {$label} half of the deep scan got a zero-byte reply: {$detail}\n\n" . 'The model did not fail, nothing arrived. That is a gateway timeout, so look at how long the ' . 'request took rather than at the prompt. The scan continues with rule-based findings for this half.', 'Cyber Audit', 'warning' ); } } 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', array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 8, 'sslverify' => true, ) ); $r = CSDT_Http::json( $resp ); if ( 200 !== $r['code'] ) { return array( 'available' => false, 'error' => 'Could not fetch checksums from WordPress.org (' . CSDT_Http::describe( $r ) . ')', ); } $checksums = $r['data']['checksums'] ?? null; if ( ! is_array( $checksums ) ) { return array( 'available' => false, 'error' => 'Invalid checksum response', ); } // High-value files most commonly backdoored $check_files = array( '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 = array(); $missing = array(); $checked = 0; foreach ( $check_files as $file ) { if ( ! isset( $checksums[ $file ] ) ) { continue; } $path = CSDT_Paths::root_file( $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 array( '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) // Classify PHP in uploads by CONTENT, not mere presence. Almost every site // has benign directory-listing guards there (empty index.php, "Silence is // golden" stubs, 404-header guards) dropped by core and plugins, flagging // those as webshells is a false-positive that cries wolf on every site. // Only files containing executable/obfuscation patterns (or non-trivial, // non-guard code) are genuinely suspicious. $php_in_uploads = array(); // genuinely suspicious (possible webshell) $benign_guards = 0; // empty / silence-is-golden / 404-header guards 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' ) { continue; } $fpath = $file->getPathname(); if ( ! @is_file( $fpath ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- broken symlink guard continue; } $code = (string) @file_get_contents( $fpath, false, null, 0, 8192 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- read first 8KB for classification // Executable / obfuscation / request-handling patterns = real webshell risk. $dangerous = (bool) preg_match( '/\b(eval|assert|base64_decode|gzinflate|gzuncompress|str_rot13|system|exec|shell_exec|passthru|popen|proc_open|create_function|call_user_func(_array)?|preg_replace)\s*\(|\$_(GET|POST|REQUEST|COOKIE|FILES)\b|\bmove_uploaded_file\b/i', $code ); // Benign guard: empty, or <=400 bytes with only = 10 ) { break; } } else { ++$benign_guards; } } } // 2. PHP files modified in the last 7 days outside plugin/theme dirs $recently_modified = array(); $cutoff = time() - ( 7 * DAY_IN_SECONDS ); $skip_paths = array( CSDT_Paths::plugins_dir(), get_theme_root() ); $scan_dirs = array( CSDT_Paths::site_root(), CSDT_Paths::root_file( 'wp-includes' ), CSDT_Paths::root_file( '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 ) { $fpath = $file->getPathname(); // Skip entries that can't be stat'd (e.g. a dangling symlink such as a // wp-content/db.php drop-in), getMTime()/filemtime() would fatal otherwise. if ( ! @is_file( $fpath ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- stat may fail on broken symlinks; handled by skipping continue; } $mtime = @filemtime( $fpath ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see above if ( false !== $mtime && $mtime > $cutoff ) { $recently_modified[] = CSDT_Paths::relative_to_root( $fpath ); if ( count( $recently_modified ) >= 15 ) { break 2; } } } } return array( 'php_files_in_uploads' => $php_in_uploads, 'php_files_in_uploads_count' => count( $php_in_uploads ), 'benign_php_guards_count' => $benign_guards, '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 array( '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 = array( 'put your unique phrase here', '' ); $keys = array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT', ); $weak = array(); foreach ( $keys as $k ) { if ( ! defined( $k ) || in_array( constant( $k ), $defaults, true ) || strlen( constant( $k ) ) < 32 ) { $weak[] = $k; } } return array( 'all_set' => empty( $weak ), 'weak_keys' => $weak, ); } /** * The Host Software table for the deep dive's report, built from this scan's CVE answers. * * @param array $cve_matches lookup_component_cves() output. * @return array CSDT_Host_Software::for_js() shape; ran false on failure. */ public static function deep_host_software( array $cve_matches ): array { if ( ! class_exists( 'CSDT_Host_Software' ) ) { return array( 'ran' => false ); } try { return CSDT_Host_Software::for_js( CSDT_Host_Software::record_from_feed( (array) ( $cve_matches['matches'] ?? array() ) ) ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } return array( 'ran' => false ); } } /** * What the CVE feed could actually see on this run. * * The feed layer already records all of this: whether it ran, which source * answered, how many components it checked, which ones it skipped, and * whether it ran out of budget. None of it reached the screen. The stored * report carried a single boolean, cve_feed_used, that nothing read. * * So a scan over eight plugins that returned nothing rendered identically * whether the feed had checked all eight and found them clean, checked two * and run out of budget, or been unable to run at all. "0 vulnerabilities" * is the most reassuring thing this product says, and it was the one claim * with no coverage statement attached. * * The source matters as much as the count. NVD is a poor primary source for * WordPress PLUGIN advisories specifically: plenty never receive a CVE, or * receive one late, or are recorded without usable version ranges. An * NVD-only feed is therefore quieter than reality, and quiet in a way that * looks exactly like being secure. That is stated rather than left for the * reader to know. * * @since 1.10.782 * @param array $cve_matches lookup_component_cves() output. * @return array */ public static function cve_feed_provenance( array $cve_matches ): array { $checked = ! empty( $cve_matches['checked'] ); $cfg = class_exists( 'CSDT_Vuln_Feed' ) && method_exists( 'CSDT_Vuln_Feed', 'is_configured' ) ? (array) CSDT_Vuln_Feed::is_configured() : array(); $active = (string) ( $cfg['active'] ?? '' ); // Count the components the feed reached but could not complete. A skipped // component is not a clean one, and the difference is invisible in a total. $skipped = 0; foreach ( (array) ( $cve_matches['matches'] ?? array() ) as $m ) { if ( is_array( $m ) && ! empty( $m['skipped'] ) ) { ++$skipped; } } $out = array( 'checked' => $checked, 'reason' => $checked ? '' : (string) ( $cve_matches['reason'] ?? 'CVE feed did not run' ), 'source' => $active, 'source_label' => (string) ( $cfg['label'] ?? '' ), 'components_checked' => (int) ( $cve_matches['components_checked'] ?? 0 ), 'components_skipped' => $skipped, 'truncated' => ! empty( $cve_matches['truncated'] ), // Whether the active source curates WordPress advisories, as against a // general CVE database that happens to contain some. 'wp_specific' => in_array( $active, array( 'wordfence', 'wpvulnerability' ), true ), // The host beneath WordPress: what was checked, and what could not be. 'server_checked' => array_values( (array) ( $cve_matches['server_software']['checked'] ?? array() ) ), 'server_unverifiable' => array_values( (array) ( $cve_matches['server_software']['unverifiable'] ?? array() ) ), ); if ( ! $checked ) { $out['caveat'] = 'The vulnerability feed did not run on this scan, so no component was checked against known advisories. Absence of vulnerability findings below means nothing.'; return $out; } $parts = array(); if ( ! $out['wp_specific'] ) { $parts[] = 'This feed is a general CVE database, not a curated WordPress plugin advisory source. Many WordPress plugin vulnerabilities never receive a CVE, receive one late, or are recorded without a usable affected-version range, so a quiet result here is weaker evidence than it looks.'; } if ( $skipped > 0 ) { $parts[] = sprintf( '%d of %d components could not be completed on this run and are unchecked rather than clean.', $skipped, (int) $out['components_checked'] ); } if ( $out['truncated'] ) { $parts[] = 'The lookup hit its request or time budget before finishing, so the list below is incomplete.'; } if ( ! empty( $out['server_unverifiable'] ) ) { $parts[] = 'Not checked because the version is not visible from inside PHP: ' . implode( '; ', $out['server_unverifiable'] ) . '.'; } $out['caveat'] = implode( ' ', $parts ); return $out; } /** * Deterministic findings about the server software, from the feed's answer. * * Same reasoning as posture_findings(): a PHP branch that stopped receiving * fixes, or a database server with a known exploited flaw, is the kind of * fact that must reach the report whether or not a model runs. The model is * still handed the same matches, for prioritising and for combinations, and * posture_topic() drops its retelling wherever one of these fired. * * Severity is calibrated to what the feed states, not to the raw count. A * MySQL 8.0 branch answers with hundreds of advisories and most are patched * in the running version; only confirmed matches on the installed version * count, and among those an exploited-in-the-wild flaw ranks first. * * @param array $cve_matches lookup_component_cves() output. * @return array{critical:array,high:array,medium:array,low:array,good:array} */ public static function server_software_findings( array $cve_matches ): array { $out = array( 'critical' => array(), 'high' => array(), 'medium' => array(), 'low' => array(), 'good' => array(), ); if ( empty( $cve_matches['checked'] ) ) { return $out; } $clean = array(); foreach ( (array) ( $cve_matches['matches'] ?? array() ) as $m ) { if ( ! is_array( $m ) || ! CSDT_Vuln_Feed::is_server_type( (string) ( $m['type'] ?? '' ) ) ) { continue; } $name = (string) ( $m['name'] ?? $m['type'] ); $version = (string) ( $m['version'] ?? '' ); $label = trim( $name . ' ' . $version ); $where = self::server_fix_location( (string) $m['type'] ); if ( ! empty( $m['skipped'] ) ) { $out['low'][] = array( 'title' => $label . ' could not be checked against known vulnerabilities', 'detail' => 'The server-software feed did not answer for this component on this run (' . (string) ( $m['note'] ?? 'no answer' ) . '), so it is unchecked rather than clean. It will be tried again on the next scan.', 'fix' => 'No action beyond re-running the scan. If this persists across runs, the feed may not publish this product yet.', 'affected_component' => $label, 'confidence' => 'high', 'evidence' => 'rule', ); continue; } // Branch end of life, from the feed's own date rather than a table here. $support = is_array( $m['support'] ?? null ) ? $m['support'] : array(); if ( true === ( $support['eol'] ?? null ) ) { $out['high'][] = array( 'title' => $label . ' is on a branch that no longer receives security fixes', 'detail' => sprintf( 'The %s branch reached the end of its fix window on %s, so any vulnerability found from now on stays unpatched on this server for as long as it runs this branch. This is a host-level fact: no WordPress or plugin update changes it.', '' !== (string) ( $support['name'] ?? '' ) ? (string) $support['name'] : $name . ' ' . (string) ( $support['branch'] ?? '' ), (string) ( $support['date_end'] ?? 'an unknown date' ) ), 'fix' => 'Move to a supported branch ' . $where . '. Take a full backup first and test the site on the new version before switching production.', 'affected_component' => $label, 'confidence' => 'high', 'evidence' => 'rule', 'source_url' => '', ); } $confirmed = array(); $unconfirmed = 0; foreach ( (array) ( $m['matched_cves'] ?? array() ) as $cve ) { if ( ! is_array( $cve ) ) { continue; } if ( 'confirmed' === (string) ( $cve['confidence'] ?? '' ) ) { $confirmed[] = $cve; } else { ++$unconfirmed; } } if ( empty( $confirmed ) ) { if ( 0 === $unconfirmed && true !== ( $support['eol'] ?? null ) ) { $clean[] = $label; } elseif ( $unconfirmed > 0 ) { $out['low'][] = array( 'title' => $label . ' may be affected by ' . $unconfirmed . ' advisor' . ( 1 === $unconfirmed ? 'y' : 'ies' ) . ' that state no version range', 'detail' => 'The feed lists advisories for this product without a usable affected-version range, so they could not be confirmed or ruled out for the installed version. Treat as unverified, not as clean.', 'fix' => 'Compare the installed version against each advisory, and keep the package current ' . $where . '.', 'affected_component' => $label, 'confidence' => 'low', 'evidence' => 'rule', 'source_url' => '', ); } continue; } // Worst first is the feed's order already (kev, then score); the top // entry sets the severity and the title, the rest are named in the detail. $top = $confirmed[0]; $kev = ! empty( $top['kev'] ); $score = (float) ( $top['cvss_score'] ?? 0 ); $total = (int) ( $m['total_matched'] ?? count( $confirmed ) ); $severity = $kev || $score >= 9.0 ? 'critical' : ( $score >= 7.0 ? 'high' : 'medium' ); $ids = array(); $fixed = ''; foreach ( $confirmed as $cve ) { $id = (string) ( $cve['cve_id'] ?? '' ); if ( '' !== $id ) { $ids[] = $id . ( ! empty( $cve['kev'] ) ? ' (exploited in the wild)' : '' ); } $fx = (string) ( $cve['fixed_in'] ?? '' ); if ( '' !== $fx && ( '' === $fixed || version_compare( $fx, $fixed, '>' ) ) ) { $fixed = $fx; } } $shown = array_slice( $ids, 0, 6 ); $more = count( $confirmed ) - count( $shown ); $out[ $severity ][] = array( 'title' => sprintf( '%s has %d known vulnerabilit%s%s', $label, $total, 1 === $total ? 'y' : 'ies', $kev ? ', at least one exploited in the wild' : ( $score > 0 ? sprintf( ', the worst scoring CVSS %.1f', $score ) : '' ) ), 'detail' => 'The installed version falls inside the published affected range of: ' . implode( ', ', $shown ) . ( $more > 0 ? sprintf( ' and %d more', $more ) : '' ) . '. ' . 'These are flaws in the server software itself, reachable through whatever exposes it (PHP through every request, the database through any SQL injection, the web server directly), and no WordPress, theme or plugin update touches them.' . ( $total > count( $confirmed ) ? ' The feed reported more matches than the report lists; the highest-scoring are shown.' : '' ), 'fix' => ( '' !== $fixed ? 'Update to ' . $name . ' ' . $fixed . ' or later ' : 'Update ' . $name . ' to the current release of its branch ' ) . $where . '. Take a full backup first.', 'affected_component' => $label, 'confidence' => 'high', 'evidence' => 'rule', 'cve_id' => (string) ( $top['cve_id'] ?? '' ), 'cvss_score' => $score > 0 ? $score : null, 'source_url' => (string) ( $top['url'] ?? '' ), ); } if ( ! empty( $clean ) ) { $out['good'][] = array( 'title' => count( $clean ) . ' server component' . ( 1 === count( $clean ) ? '' : 's' ) . ' checked against known vulnerabilities, none affecting the installed version', 'detail' => implode( ', ', $clean ) . ': each was looked up in a curated advisory feed by product and exact version, every published affected range excludes the installed version, and the branch is still receiving fixes.', ); } return $out; } /** * Where a server component is updated from, for fix text. * * @param string $type Server component type. * @return string */ private static function server_fix_location( string $type ): string { switch ( $type ) { case 'php': return 'through your hosting control panel\'s PHP version selector, or the server\'s package manager (apt, dnf) on a host you manage yourself'; case 'mysql': case 'mariadb': return 'through the server\'s package manager, or by asking your host, since the database server is outside WordPress\'s reach'; case 'nginx': case 'apache': return 'through the server\'s package manager, or by asking your host, since the web server is outside WordPress\'s reach'; case 'redis': case 'memcached': return 'through the server\'s package manager, or by asking your host, since the cache server is outside WordPress\'s reach'; default: return 'through the server\'s package manager, since this library is part of the PHP build, not of WordPress'; } } /** * Deterministic posture findings from the deep scan's internal data bundle. * * These five facts, weak or missing auth salts, administrators without a * second factor, well-known administrator logins, scheduled hooks matching * no active plugin, and plugins abandoned upstream, were collected by * gather_deep_internal_data() and then judged ONLY by the model. That put * the highest-signal posture checks in the product on the wrong side of the * AI boundary: on any ai_status but 'ok' the site was told nothing about an * administrator with no 2FA or a two-year-abandoned plugin, and the absence * read as clean rather than as unknown. Same shape as the bug the docblock * above records, where those collectors were once called by nothing at all. * Wiring them to a call that can fail was one step along, not the fix. * * The model keeps the job it is good at: prioritising, explaining, and * spotting combinations a rule would not. It no longer ORIGINATES these. * * Severities are calibrated to what the underlying check can actually prove, * not to how alarming the topic sounds. The cron heuristic in particular is * a prefix match that a deactivated plugin's leftover hooks will trip, so it * reports medium and says why rather than calling persistence outright. * * Returns the shared report shape's buckets only ({critical, high, medium, * low, good}), so it merges into either an AI report or a rule-only one. * * @since 1.10.781 * @param array $internal gather_deep_internal_data() output. * @return array>> */ private static function posture_findings( array $internal ): array { $out = array( 'critical' => array(), 'high' => array(), 'medium' => array(), 'low' => array(), 'good' => array(), ); // ── Auth salts: default, missing or short keys allow session forgery ── $salts = isset( $internal['auth_salts'] ) && is_array( $internal['auth_salts'] ) ? $internal['auth_salts'] : null; if ( null !== $salts ) { $weak_keys = array_values( array_filter( array_map( 'strval', (array) ( $salts['weak_keys'] ?? array() ) ) ) ); if ( ! empty( $weak_keys ) ) { $named = implode( ', ', $weak_keys ); $out['critical'][] = array( 'title' => sprintf( '%d of 8 WordPress secret keys are missing, default or too short', count( $weak_keys ) ), 'detail' => 'WordPress signs login cookies and nonces with these constants. Where one is absent, still set to the shipped placeholder, or shorter than 32 characters, an attacker who knows or guesses it can forge a valid logged-in session cookie for any account, including an administrator, without ever touching a password or a second factor. Affected: ' . $named . '.', 'fix' => 'Fetch a fresh set from https://api.wordpress.org/secret-key/1.1/salt/ and replace the whole block in wp-config.php. Every existing session is invalidated, so all users, including you, are logged out and must sign in again.', 'affected_component' => 'wp-config.php', 'confidence' => 'high', 'evidence' => 'rule', ); } else { $out['good'][] = array( 'title' => 'All 8 WordPress secret keys and salts are set and unique', 'detail' => 'AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY, NONCE_KEY and their four matching salts are all defined, are not the shipped placeholder text, and are each at least 32 characters. Session cookies and nonces cannot be forged from a known default.', ); } } // ── User posture: admins without a second factor, and weak logins ── $users = isset( $internal['user_audit'] ) && is_array( $internal['user_audit'] ) ? $internal['user_audit'] : null; if ( null !== $users ) { $no_2fa = array_values( array_filter( array_map( 'strval', (array) ( $users['admins_without_2fa'] ?? array() ) ) ) ); $admin_count = (int) ( $users['admin_count'] ?? 0 ); if ( ! empty( $no_2fa ) ) { $shown = array_slice( $no_2fa, 0, 5 ); $more = count( $no_2fa ) - count( $shown ); $listed = implode( ', ', $shown ) . ( $more > 0 ? sprintf( ' and %d more', $more ) : '' ); $out['high'][] = array( 'title' => sprintf( '%d of %d administrator account%s can sign in with a password alone', count( $no_2fa ), $admin_count, 1 === count( $no_2fa ) ? '' : 's' ), 'detail' => 'These administrators have no TOTP app enrolled and no passkey registered, and site-wide email 2FA is not on, so admins_without_2fa lists them. A single leaked, reused or phished password is then full control of the site with nothing else in the way. Accounts: ' . $listed . '.', 'fix' => 'Enrol each account under Login Security, passkeys first and TOTP as the fallback, then turn on Enforce 2FA for Admins so anyone not enrolled is sent to enrolment on their next sign-in. Complete your own enrolment before enforcing.', 'affected_component' => 'Administrator accounts', 'confidence' => 'high', 'evidence' => 'rule', ); } elseif ( $admin_count > 0 ) { $out['good'][] = array( 'title' => sprintf( 'Every administrator account (%d) has a second factor', $admin_count ), 'detail' => 'Each administrator has a passkey or a TOTP app enrolled, or site-wide email 2FA is active. A stolen password on its own does not get an attacker into wp-admin.', ); } $weak_logins = array_values( array_filter( array_map( 'strval', (array) ( $users['weak_admin_usernames'] ?? array() ) ) ) ); if ( ! empty( $weak_logins ) ) { $out['medium'][] = array( 'title' => sprintf( 'Administrator login%s use%s a name attackers try first', 1 === count( $weak_logins ) ? '' : 's', 1 === count( $weak_logins ) ? 's' : '' ), 'detail' => 'weak_admin_usernames matched these administrator logins against the well-known list every credential-stuffing bot starts from: ' . implode( ', ', $weak_logins ) . '. It gives an attacker half of the credential pair for free, so every guess can be spent on the password.', 'fix' => 'Create a replacement administrator with an unpredictable login, reassign the old account\'s content to it, then delete the old account. WordPress cannot rename a login in place. Keep brute-force lockout and 2FA on either way, because a strong username is a delay and not a control.', 'affected_component' => 'Administrator accounts', 'confidence' => 'high', 'evidence' => 'rule', ); } } // ── Cron: scheduled hooks belonging to no active plugin or core ── $cron = isset( $internal['cron_audit'] ) && is_array( $internal['cron_audit'] ) ? $internal['cron_audit'] : null; if ( null !== $cron ) { $suspicious = array_values( array_filter( array_map( 'strval', (array) ( $cron['suspicious_hooks'] ?? array() ) ) ) ); if ( ! empty( $suspicious ) ) { $shown = array_slice( $suspicious, 0, 8 ); $more = count( $suspicious ) - count( $shown ); $out['medium'][] = array( 'title' => 1 === count( $suspicious ) ? '1 scheduled task matches no active plugin or WordPress core hook' : sprintf( '%d scheduled tasks match no active plugin or WordPress core hook', count( $suspicious ) ), 'detail' => 'WP-Cron will keep running these on a schedule, and a hook nothing installed owns is one of the quieter ways a compromise survives the plugin that caused it being removed. It is equally often harmless leftovers from a deactivated or deleted plugin, so treat this as a list to account for rather than as proof: ' . implode( ', ', $shown ) . ( $more > 0 ? sprintf( ' and %d more', $more ) : '' ) . '.', 'fix' => 'List them with "wp cron event list" and account for each one. If you cannot name the plugin that scheduled a hook, grep the codebase for the hook name, then unschedule it with "wp cron event delete ". A hook whose callback you cannot find at all, but which is still scheduled, warrants a full malware scan.', 'affected_component' => 'WP-Cron', // Calibrated down deliberately. audit_cron_events() matches hook // names against active-plugin directory prefixes plus a common-word // allowlist, so a deactivated plugin's leftovers land here too. 'confidence' => 'medium', 'evidence' => 'rule', ); } } // ── Plugins abandoned upstream (no update in over two years) ── $wporg = isset( $internal['plugin_wporg'] ) && is_array( $internal['plugin_wporg'] ) ? $internal['plugin_wporg'] : null; if ( null !== $wporg ) { $checked = count( $wporg ); $active = (int) ( $internal['plugin_summary']['active'] ?? 0 ); $abandoned = array(); foreach ( $wporg as $slug => $info ) { if ( ! empty( $info['abandoned'] ) ) { $years = $info['years_since_update'] ?? null; $abandoned[] = null !== $years ? sprintf( '%s (%s years)', (string) $slug, (string) $years ) : (string) $slug; } } // Coverage is stated on every branch. enrich_plugins_with_wporg() // skips a plugin whose api.wordpress.org lookup failed or that is not // in the directory at all, so "none abandoned" over a partial set is // not the same claim as "none abandoned" over all of them, and the // difference is exactly what makes a quiet feed read as a clean site. $coverage = $active > 0 && $checked < $active ? sprintf( ' Checked %d of %d active plugins against the WordPress.org directory; the rest are premium, custom, or their lookup failed, and this check says nothing about them.', $checked, $active ) : ''; if ( ! empty( $abandoned ) ) { $out['medium'][] = array( 'title' => sprintf( '%d active plugin%s had no update in over two years', count( $abandoned ), 1 === count( $abandoned ) ? ' has' : 's have' ), 'detail' => 'An abandoned plugin is running code nobody is patching. It needs no published CVE to be a liability: when one is found there will be no fix, and it is the reason unmaintained plugins dominate real WordPress compromises. Affected: ' . implode( ', ', $abandoned ) . '.' . $coverage, 'fix' => 'For each one, find a maintained replacement or confirm you can live without it, then remove it rather than deactivating it, since deactivated plugin files stay on disk and stay reachable. If a plugin is essential and unmaintained, you have taken on maintaining it yourself.', 'affected_component' => 'Plugins', 'confidence' => 'high', 'evidence' => 'rule', ); } elseif ( $checked > 0 ) { $out['good'][] = array( 'title' => sprintf( 'No abandoned plugins among the %d checked upstream', $checked ), 'detail' => 'Every active plugin found in the WordPress.org directory has been updated by its author within the last two years.' . $coverage, ); } } return $out; } /** * Classify a finding as one of the five posture topics, or '' for anything else. * * Deliberately narrow. A pattern here that is too broad silences a model * finding about a genuinely different fact. An AI finding about * DISABLE_WP_CRON, for instance, is not the unowned-hooks finding and must * must survive, and a suppression that removes real coverage is the same class * of fault as the gap this file is closing, just pointed the other way. * * @since 1.10.781 * @param array $f Finding with title/detail. * @return string One of the five topic keys, or ''. */ private static function posture_topic( array $f ): string { $t = strtolower( (string) ( $f['title'] ?? '' ) . ' ' . (string) ( $f['detail'] ?? '' ) ); if ( preg_match( '/auth_key|secure_auth|logged_in_key|nonce_salt|nonce_key|auth_salt|secret key|salts?\b/', $t ) ) { return 'auth_salts'; } // 2FA, but only where it is about administrators lacking one, not about // the enforcement setting, and not about passkeys as a separate topic. if ( preg_match( '/(two[- ]factor|2fa|second factor|admins_without_2fa)/', $t ) && preg_match( '/admin/', $t ) ) { return 'admin_2fa'; } if ( preg_match( '/weak_admin_usernames|(username|login).{0,40}(guess|well[- ]known|common|default|predictable)|(guess|well[- ]known|common|default|predictable).{0,40}(username|login)/', $t ) ) { return 'weak_admin_login'; } // Tight on purpose: unowned/unknown scheduled hooks only. WP-Cron health, // DISABLE_WP_CRON and overdue events are different findings. if ( preg_match( '/suspicious_hooks|(cron|scheduled (task|event|hook)).{0,60}(no active plugin|unknown|unrecognis|unrecogniz|not match|match no|no installed|orphan)/', $t ) || preg_match( '/(unknown|unrecognis|unrecogniz|orphan).{0,40}(cron|scheduled (task|event|hook))/', $t ) ) { return 'cron_unowned'; } if ( preg_match( '/abandoned|unmaintained|(no|not).{0,20}updated?.{0,30}(two|2|three|3|four|4|\d+)\s*years|years since.{0,20}update/', $t ) ) { return 'abandoned_plugin'; } // Server software, by product name plus either a CVE or an end-of-life // claim. "PHP display_errors is on" names PHP and matches neither, so it // stays with the model where it belongs. if ( preg_match( '/\b(php|mysql|mariadb|nginx|apache|curl|imagemagick|redis|memcached|sqlite)\b/', $t ) ) { if ( preg_match( '/cve-\d{4}-\d+|known vulnerabilit|exploited in the wild|cvss/', $t ) ) { return 'server_cve'; } if ( preg_match( '/end[- ]of[- ]life|\beol\b|no longer (receives?|supported|maintained|gets)|unsupported (branch|version)|security (fixes|patches|updates)/', $t ) ) { return 'server_eol'; } } return ''; } /** * Merge deterministic posture findings into a scan report. * * Runs the existing AI-versus-rules dedupe across the pair so a model * finding that restates one of these is collapsed into it, keeping the * higher severity of the two. The deterministic record is the survivor for * the same reason it is in the standard audit: its title, component and fix * are stable, where the model regenerates its own every run. * * @since 1.10.781 * @param array $report Report in the shared scan schema. * @param array $posture posture_findings() output. * @return array */ private static function merge_posture_findings( array $report, array $posture ): array { $buckets = array( 'critical', 'high', 'medium', 'low' ); // Topic suppression runs BEFORE the token dedupe, because the token dedupe // cannot reach this case. finding_signature_tokens() matches snake_case // identifiers and requires one of them to appear in a TITLE; a rule title // written for a human ("2 of 3 administrator accounts can sign in with a // password alone") carries none, and the model's retelling carries none // either, so a pair describing one fact in two prose sentences survives // intact and the report says the same thing twice. // // Only topics a posture finding actually raised are suppressed. If the rule // did not fire, the model's finding is the only coverage of that topic and // must reach the panel untouched. Suppressing it would recreate, from the // other direction, exactly the silence this whole change removes. $covered = array(); foreach ( $buckets as $sev ) { foreach ( (array) ( $posture[ $sev ] ?? array() ) as $f ) { $topic = self::posture_topic( $f ); if ( '' !== $topic ) { $covered[ $topic ] = true; } } } if ( ! empty( $covered ) ) { foreach ( $buckets as $sev ) { if ( empty( $report[ $sev ] ) || ! is_array( $report[ $sev ] ) ) { continue; } $report[ $sev ] = array_values( array_filter( $report[ $sev ], static function ( $f ) use ( $covered ) { if ( ! is_array( $f ) ) { return false; } // Only MODEL findings are candidates. Anything this plugin // wrote carries 'evidence', and suppression must never reach // it: the rule-only report's own "AI analysis did not run" // notice describes which deterministic checks still ran, so it // names salts and second factors and would classify as a // duplicate of the very findings it is introducing. if ( isset( $f['evidence'] ) ) { return true; } $topic = self::posture_topic( $f ); return '' === $topic || empty( $covered[ $topic ] ); } ) ); } } // Flatten both sides into the flat, severity-tagged shape dedupe_ai_vs_rules() // expects, dedupe once across all severities, then re-bucket. Deduping bucket // by bucket would miss the common case: the model rates the same fact one band // differently from the rule, and the pair would both survive. $ai_flat = array(); $rule_flat = array(); foreach ( $buckets as $sev ) { foreach ( (array) ( $report[ $sev ] ?? array() ) as $f ) { if ( is_array( $f ) ) { $f['severity'] = $sev; $ai_flat[] = $f; } } foreach ( (array) ( $posture[ $sev ] ?? array() ) as $f ) { $f['severity'] = $sev; $rule_flat[] = $f; } } list( $ai_flat, $rule_flat ) = self::dedupe_ai_vs_rules( $ai_flat, $rule_flat ); foreach ( $buckets as $sev ) { $report[ $sev ] = array(); } // Deterministic findings lead their bucket: they are the ones with a fix // that is known to apply to this site. foreach ( array_merge( $rule_flat, $ai_flat ) as $f ) { $sev = (string) ( $f['severity'] ?? 'low' ); unset( $f['severity'] ); if ( ! isset( $report[ $sev ] ) || ! is_array( $report[ $sev ] ) ) { $report[ $sev ] = array(); } $report[ $sev ][] = $f; } $report['good'] = array_merge( (array) ( $posture['good'] ?? array() ), array_filter( (array) ( $report['good'] ?? array() ), 'is_array' ) ); return $report; } /** * Score a report that no model judged, from its own finding counts. * * Only used when both judgement halves failed. Deliberately blunt and * deliberately capped below Excellent: this is a configuration-only view * with no code triage, no CVE judgement and no external exposure analysis * behind it, and a rule-only pass that found nothing must not present as a * clean bill of health. Same reason the report it scores carries a finding * saying the AI half did not run. * * @since 1.10.781 * @param array $report Report in the shared scan schema. * @return int Score, 0-100. */ private static function score_from_findings( array $report ): int { $weights = array( 'critical' => 25, 'high' => 12, 'medium' => 5, 'low' => 2, ); $score = 88; foreach ( $weights as $sev => $weight ) { $score -= count( (array) ( $report[ $sev ] ?? array() ) ) * $weight; } return max( 0, min( 88, $score ) ); } /** * Internal half of the deep-scan data set. * * This replaces the never-called gather_deep_security_data(): the live deep * scan used to assemble its own, smaller bundle, which meant theme data, * auth salts, the user audit, the cron audit and the WP.org abandonment * data were collected by code that nothing ever called. Those collectors * are wired in here so the live scan actually sees them. * * External checks, the plugin code scan and the CVE feeds stay in * cron_deep_scan() where they belong to the external half. * * @return array */ private static function gather_deep_internal_data(): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); $active_files = (array) get_option( 'active_plugins', array() ); $base = self::gather_security_data(); $base['core_integrity'] = self::check_core_integrity(); // modified/missing core files = backdoor signal $base['malware_indicators'] = self::scan_malware_indicators(); // PHP-in-uploads = webshell signal // PHP end-of-life status $php_eol_dates = array( '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 = array( '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 = array( '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 = array( 'display_errors_on' => ! in_array( $di_raw, array( '', '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 unpatched attack surface $inactive_plugins = array(); foreach ( $all_plugins as $plugin_file => $plugin_data ) { if ( ! in_array( $plugin_file, $active_files, true ) ) { $inactive_plugins[] = array( 'name' => $plugin_data['Name'], 'version' => $plugin_data['Version'], 'file' => $plugin_file, ); } } return array_merge( $base, array( 'theme' => self::gather_theme_data(), 'auth_salts' => self::check_auth_salts(), 'user_audit' => self::audit_users(), 'cron_audit' => self::audit_cron_events(), 'plugin_wporg' => self::enrich_plugins_with_wporg( $active_files ), 'php_eol' => $php_eol_info, 'auto_updates' => $auto_updates, 'display_errors' => $display_errors, 'inactive_plugins' => $inactive_plugins, ) ); } /** * Bounded homepage HTML fingerprint (passive, first-party). * * The homepage body was already being fetched by gather_security_data() and * thrown away, so version/plugin disclosure could only be guessed at from * local filter state. This keeps the evidence the model needs, generator * meta tag, ?ver= query strings, and the plugin/theme slugs visible in * asset URLs, plus a size-capped excerpt, never the whole page. * * @param string $html Raw homepage HTML. * @return array */ private static function fingerprint_home_html( string $html ): array { $out = array( 'fetched' => '' !== $html, 'bytes' => strlen( $html ), 'meta_generator' => null, 'generators' => array(), 'wp_version_in_html' => null, 'ver_params' => array(), 'plugin_slugs' => array(), 'theme_slugs' => array(), 'asset_paths' => array(), 'excerpt' => '', ); if ( '' === $html ) { return $out; } if ( preg_match_all( '/]+name=["\']generator["\'][^>]*content=["\']([^"\']{1,160})["\']/i', $html, $gm ) ) { $out['generators'] = array_slice( array_values( array_unique( array_map( 'trim', $gm[1] ) ) ), 0, 6 ); $out['meta_generator'] = $out['generators'][0] ?? null; foreach ( $out['generators'] as $gen ) { if ( preg_match( '/WordPress\s+([0-9][0-9.]*)/i', $gen, $vm ) ) { $out['wp_version_in_html'] = $vm[1]; break; } } } if ( preg_match_all( '/[?&]ver=([0-9a-zA-Z._-]{1,32})/', $html, $vp ) ) { $out['ver_params'] = array_slice( array_values( array_unique( $vp[1] ) ), 0, 25 ); } // The asset paths this site serves plugins and themes from, as the API reports // them, so a relocated content directory is still fingerprinted correctly. $plugins_path = preg_quote( untrailingslashit( (string) wp_parse_url( CSDT_Paths::plugins_url_base(), PHP_URL_PATH ) ), '#' ); $themes_path = preg_quote( untrailingslashit( (string) wp_parse_url( get_theme_root_uri(), PHP_URL_PATH ) ), '#' ); if ( preg_match_all( '#' . $plugins_path . '/([a-z0-9][a-z0-9_-]{0,60})/#i', $html, $pl ) ) { $out['plugin_slugs'] = array_slice( array_values( array_unique( array_map( 'strtolower', $pl[1] ) ) ), 0, 40 ); } if ( preg_match_all( '#' . $themes_path . '/([a-z0-9][a-z0-9_-]{0,60})/#i', $html, $th ) ) { $out['theme_slugs'] = array_slice( array_values( array_unique( array_map( 'strtolower', $th[1] ) ) ), 0, 20 ); } if ( preg_match_all( '#(?:src|href)=["\']([^"\']{0,200}/wp-(?:content|includes)/[^"\']{0,200})["\']#i', $html, $ap ) ) { $out['asset_paths'] = array_slice( array_values( array_unique( $ap[1] ) ), 0, 15 ); } // Bounded excerpt: the (where fingerprints live), whitespace // collapsed and hard-capped, so the prompt never carries a whole page. $head = $html; if ( preg_match( '##is', $html, $hm ) ) { $head = $hm[0]; } $out['excerpt'] = substr( (string) preg_replace( '/\s+/', ' ', $head ), 0, 4000 ); return $out; } /** * Probe the REST API root (/wp-json/) and extract the namespace list. * * Namespaces enumerate the plugins that register REST routes, which is the * single richest passive plugin-fingerprinting vector. The raw payload is * large (~200 KB on a typical site), so it is parsed here and only the * namespace list and derived slugs are returned. * * @param string $base Trailing-slashed site base URL. * @return array */ private static function probe_wp_json_root( string $base ): array { $out = array( 'reachable' => false, 'http_code' => 0, 'namespace_count' => 0, 'namespaces' => array(), 'plugin_slugs' => array(), 'route_count' => 0, ); $r = wp_safe_remote_get( rest_url(), array( 'timeout' => 8, 'user-agent' => CSDT_DevTools::USER_AGENT, 'sslverify' => csdt_loopback_ssl_verify(), ) ); $j = CSDT_Http::json( $r ); if ( 0 === $j['code'] ) { $out['error'] = $j['error']; return $out; } $out['http_code'] = $j['code']; if ( 200 !== $out['http_code'] ) { return $out; } $data = $j['data']; if ( null === $data ) { return $out; } $out['reachable'] = true; $out['route_count'] = is_array( $data['routes'] ?? null ) ? count( $data['routes'] ) : 0; $namespaces = array(); foreach ( (array) ( $data['namespaces'] ?? array() ) as $ns ) { $ns = trim( (string) $ns ); if ( '' !== $ns ) { $namespaces[] = $ns; } } $namespaces = array_values( array_unique( $namespaces ) ); $out['namespace_count'] = count( $namespaces ); $out['namespaces'] = array_slice( $namespaces, 0, 40 ); // A namespace vendor prefix is normally the plugin slug (akismet/v1 → akismet). $slugs = array(); foreach ( $namespaces as $ns ) { $prefix = strtolower( trim( (string) strstr( $ns . '/', '/', true ) ) ); if ( '' !== $prefix && ! in_array( $prefix, array( 'wp', 'oembed', 'wp-site-health', 'wp-block-editor' ), true ) ) { $slugs[ $prefix ] = true; } } $out['plugin_slugs'] = array_slice( array_keys( $slugs ), 0, 40 ); return $out; } /** * Probe /feed/rdf/ for author disclosure. * * The RDF feed leaks real logins even on sites where * /wp-json/wp/v2/users correctly returns 404, so it is a separate * enumeration vector and must be reported independently. * * @param string $base Trailing-slashed site base URL. * @return array */ private static function probe_feed_authors( string $base ): array { $out = array( 'checked' => true, 'source' => 'feed/rdf/', 'http_code' => 0, 'exposed' => false, 'creators' => array(), ); $r = wp_safe_remote_get( $base . 'feed/rdf/', array( 'timeout' => 6, 'user-agent' => CSDT_DevTools::USER_AGENT, 'sslverify' => csdt_loopback_ssl_verify(), ) ); if ( is_wp_error( $r ) ) { $out['error'] = CSDT_Error_Text::in_seconds( $r->get_error_message() ); return $out; } $out['http_code'] = (int) wp_remote_retrieve_response_code( $r ); if ( 200 !== $out['http_code'] ) { return $out; } $body = (string) wp_remote_retrieve_body( $r ); if ( '' === $body ) { return $out; } if ( preg_match_all( '#]*>\s*(?:)?\s*#is', $body, $m ) ) { $creators = array(); foreach ( $m[1] as $name ) { $name = sanitize_text_field( trim( wp_strip_all_tags( $name ) ) ); if ( '' !== $name ) { $creators[ $name ] = true; } } $out['creators'] = array_slice( array_keys( $creators ), 0, 5 ); $out['exposed'] = ! empty( $out['creators'] ); } return $out; } 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, array( 'from_cache' => true ) ) ); } else { wp_send_json_success( array( 'no_cache' => true ) ); } return; } $ai_cfg = CSDT_AI_Dispatcher::get_config(); $provider = $ai_cfg['provider']; if ( ! $ai_cfg['key'] ) { wp_send_json_error( array( 'message' => 'No API key configured.', 'need_key' => true, ) ); return; } // The previous report is deliberately NOT deleted here any more. // // It used to be, on the reasoning that a stale report must not be mistaken for this // run's output. That hazard is real, but deleting up front solved it by destroying data // before knowing whether there would be anything to replace it with, and since this // scan has never once completed on this site, the practical effect was that every // attempt wiped the last good report and left nothing behind. "It is as if I never // attempted the scan." // // The hazard is handled by comparing timestamps instead: the report carries scanned_at, // the option below records when this run began, and ajax_scan_status() only treats a // report as this run's result when it was written after that. A failed run therefore // leaves the previous report intact and visible, and cannot be misread as the new one. // // Recorded separately from the status option because that one expires (expires_at) and // the comparison still has to work afterwards, the expired case is precisely when a // stale report would otherwise be served as if it were fresh. update_option( 'csdt_deep_scan_last_start', time(), false ); update_option( 'csdt_deep_scan_status', array( 'status' => 'running', 'started_at' => time(), 'expires_at' => time() + 900, ), false ); // Send response immediately, then run scan after connection closes CSDT_AI_Dispatcher::send_and_continue( array( 'queued' => true ) ); self::cron_deep_scan(); exit; } /** * Deterministic fact set, derived in PHP with no model involved. * * The only inventory there is: the source of truth for the CVE feed lookup * and the "facts" the judgement pass is given. * * A cheap model used to restate the same data first ("Pass A"), with this as * its fallback. It was removed because it could only lose: this set is the * richer one, the judgement pass gets the raw data as well, a slug the model * reshaped was looked up in the feed as if it were installed, and one * provider's safety filter refused that call 22 times out of 22, so every * one of those audits ran on this set anyway. * * @param array $internal gather_deep_internal_data() output. * @param array $external gather_external_checks() output. * @return array */ private static function derive_facts_locally( array $internal, array $external ): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); $active_files = (array) get_option( 'active_plugins', array() ); $plugins = array(); foreach ( $active_files as $file ) { $dir = dirname( (string) $file ); $slug = ( '.' === $dir || '' === $dir ) ? basename( (string) $file, '.php' ) : $dir; $plugins[] = array( 'slug' => strtolower( $slug ), 'name' => $all_plugins[ $file ]['Name'] ?? $slug, 'version' => $all_plugins[ $file ]['Version'] ?? null, ); } $theme = wp_get_theme(); $usernames = array(); $vectors = array(); foreach ( (array) ( $external['rest_users']['slugs'] ?? array() ) as $slug ) { $usernames[ (string) $slug ] = true; $vectors['/wp-json/wp/v2/users'] = true; } foreach ( (array) ( $external['feed_user_enum']['creators'] ?? array() ) as $creator ) { $usernames[ (string) $creator ] = true; $vectors['/feed/rdf/ dc:creator'] = true; } if ( ! empty( $external['author_enum']['exposed'] ) ) { $vectors['/?author=1 redirect'] = true; } $headers_present = array(); $headers_absent = array(); foreach ( (array) ( $external['security_headers_external'] ?? array() ) as $name => $value ) { if ( null === $value || '' === $value ) { $headers_absent[] = (string) $name; } else { $headers_present[] = (string) $name; } } $endpoints = array( array( 'path' => 'wp-login.php', 'state' => ! empty( $external['wp_login']['accessible'] ) ? 'open' : 'blocked', 'http_code' => $external['wp_login']['code'] ?? null, ), array( 'path' => 'xmlrpc.php', 'state' => $external['xmlrpc']['state'] ?? 'unknown', 'http_code' => $external['xmlrpc']['code'] ?? null, ), array( 'path' => 'wp-cron.php', 'state' => ! empty( $external['wp_cron_public'] ) ? 'open' : 'blocked', 'http_code' => null, ), array( 'path' => trim( (string) wp_parse_url( rest_url(), PHP_URL_PATH ), '/' ) . '/', 'state' => ! empty( $external['wp_json_root']['reachable'] ) ? 'open' : 'blocked', 'http_code' => $external['wp_json_root']['http_code'] ?? null, ), ); return array( 'detected_plugins' => $plugins, 'detected_theme' => array( 'slug' => $theme->get_stylesheet(), 'name' => $theme->get( 'Name' ), 'version' => $theme->get( 'Version' ), ), 'wordpress' => array( 'version' => $internal['wordpress']['version'] ?? get_bloginfo( 'version' ), 'php_version' => $internal['php_version'] ?? PHP_VERSION, 'version_disclosed_in_html' => ! empty( $internal['home_html']['wp_version_in_html'] ), ), 'exposed_files' => array_values( array_unique( array_merge( (array) ( $internal['exposed_files'] ?? array() ), (array) ( $external['exposed_files'] ?? array() ), (array) ( $external['backup_files_exposed'] ?? array() ) ) ) ), // Kept as its own fact rather than folded into exposed_files: a // downloadable full-site archive is a different event from a // readable license.txt, and flattening the two loses the size, the // confirmation that bytes were served, and whether the scan proved // its own fetch path, the three things that decide severity. 'exposed_archives' => self::summarise_archive_scan( $internal['exposed_archives'] ?? array() ), 'exposed_folders' => CSDT_Dir_Probe::summarise( $internal['exposed_folders'] ?? null ), 'open_endpoints' => $endpoints, 'headers_present' => $headers_present, 'headers_absent' => $headers_absent, 'csp' => array( 'mode' => $external['csp_quality']['mode'] ?? ( $internal['csp_state']['mode'] ?? 'unknown' ), 'detail' => $external['csp_quality']['note'] ?? null, ), 'enumerated_usernames' => array_slice( array_keys( $usernames ), 0, 10 ), 'enumeration_vectors' => array_keys( $vectors ), 'rest_namespaces' => (array) ( $external['wp_json_root']['namespaces'] ?? array() ), 'externally_visible_slugs' => array_values( array_unique( array_merge( (array) ( $external['wp_json_root']['plugin_slugs'] ?? array() ), (array) ( $internal['home_html']['plugin_slugs'] ?? array() ) ) ) ), ); } /** * BEFORE THE JUDGEMENT PASS, deterministic CVE lookup on the detected components. * * Deliberately not a model call: model CVE recall is frozen at its training * cutoff and invents affected-version ranges. Degrades cleanly to an * unchecked marker when the feed class is unavailable. * * @param array $facts derive_facts_locally() output. * @return array */ private static function lookup_component_cves( array $facts ): array { $components = array(); foreach ( (array) ( $facts['detected_plugins'] ?? array() ) as $p ) { $slug = is_array( $p ) ? strtolower( (string) ( $p['slug'] ?? '' ) ) : strtolower( (string) $p ); if ( '' === $slug ) { continue; } if ( ! isset( $components[ 'plugin:' . $slug ] ) ) { $components[ 'plugin:' . $slug ] = array( 'type' => 'plugin', 'slug' => $slug, 'name' => is_array( $p ) ? ( $p['name'] ?? $slug ) : $slug, 'version' => is_array( $p ) ? ( $p['version'] ?? null ) : null, ); } } $theme = (array) ( $facts['detected_theme'] ?? array() ); if ( ! empty( $theme['slug'] ) ) { $components[ 'theme:' . strtolower( (string) $theme['slug'] ) ] = array( 'type' => 'theme', 'slug' => strtolower( (string) $theme['slug'] ), 'name' => $theme['name'] ?? $theme['slug'], 'version' => $theme['version'] ?? null, ); } // The software beneath WordPress. A WordPress update fixes none of it, and // until 1.10.844 none of it was checked against any advisory at all: the // versions were collected for the report and compared against nothing. $server = self::server_software_components(); foreach ( $server['components'] as $sc ) { $components[ 'server:' . $sc['type'] ] = $sc; } return self::feed_lookup( $components, $server ); } /** * One feed call for every scan path, so the deep scan and the standard scan * cannot drift in what they record, shield or report. * * Deliberately not a model call: model CVE recall is frozen at its training * cutoff and invents affected-version ranges. Degrades cleanly to an * unchecked marker when the feed class is unavailable, and that marker is * what the provenance line and the prompt both read. * * @since 1.10.848 split out of lookup_component_cves() for the standard scan. * @param array $components Feed components keyed for de-duplication. * @param array $server server_software_components() output. * @return array */ private static function feed_lookup( array $components, array $server ): array { if ( empty( $components ) ) { return array( 'checked' => false, 'reason' => 'no components identified', 'matches' => array(), ); } if ( ! class_exists( 'CSDT_Vuln_Feed' ) || ! method_exists( 'CSDT_Vuln_Feed', 'lookup' ) ) { return array( 'checked' => false, 'reason' => 'CVE feed unavailable on this install', 'matches' => array(), ); } // is_configured() describes which feed is active (it returns a config // array, not a boolean), so it is recorded for transparency rather than // used as a gate, the keyless NVD path is always available. $feed_label = ''; if ( method_exists( 'CSDT_Vuln_Feed', 'is_configured' ) ) { $cfg = CSDT_Vuln_Feed::is_configured(); $feed_label = is_array( $cfg ) ? (string) ( $cfg['label'] ?? '' ) : ''; } try { $result = CSDT_Vuln_Feed::lookup( array_values( $components ) ); } catch ( \Throwable $e ) { self::log_line( 'CSDT scan: CVE feed lookup failed, ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log return array( 'checked' => false, 'reason' => 'CVE feed error: ' . $e->getMessage(), 'matches' => array(), ); } if ( ! is_array( $result ) ) { return array( 'checked' => false, 'reason' => 'CVE feed returned no data', 'matches' => array(), ); } if ( empty( $result['available'] ) ) { return array( 'checked' => false, 'reason' => (string) ( $result['reason'] ?? 'CVE feed unavailable' ), 'matches' => array(), ); } // Hand the feed's OWN documented shape to the shield index, not this method's // return value. The report format is presentation and may be restyled; the // feed's component list is a contract. CSDT_Shield::record_candidates() never // throws, so a shield bookkeeping fault cannot cost us the CVE findings. // Shields close a plugin's own anonymous endpoints until it is updated. // There is no such thing for PHP or the database, so server software // never reaches the candidate index: a "shield" for php would offer to // refuse nothing. $wp_components = array_values( array_filter( (array) ( $result['components'] ?? array() ), static function ( $c ): bool { return is_array( $c ) && ! CSDT_Vuln_Feed::is_server_type( (string) ( $c['type'] ?? '' ) ); } ) ); if ( is_callable( array( 'CSDT_Shield', 'record_candidates' ) ) ) { CSDT_Shield::record_candidates( $wp_components ); } return array( 'checked' => true, 'source' => 'CSDT_Vuln_Feed deterministic lookup (not model recall)' . ( '' !== $feed_label ? ' via ' . $feed_label : '' ) . ( ! empty( $result['source'] ) ? ' [' . (string) $result['source'] . ']' : '' ), 'components_checked' => count( $components ), 'truncated' => ! empty( $result['truncated'] ), 'requests' => $result['requests'] ?? null, 'matches' => (array) ( $result['components'] ?? array() ), // What was measured about the host, and what could not be: a web // server that hides its version is unverifiable, and the report has to // say so rather than list one component fewer. 'server_software' => array( 'checked' => array_map( static function ( array $c ): string { return $c['name'] . ' ' . $c['version']; }, $server['components'] ), 'unverifiable' => $server['unverifiable'], ), ); } /** * Deterministic CVE lookup over the WHOLE install, for the standard scan. * * The standard scan used to send gather_security_data() to the model with no * feed data at all, so it was the one scan still citing CVE ids from model * recall: the deep scan's prompt forbids memory-sourced ids because it has * cve_matches to point at, and the standard scan's could not. This gives it * the same data from the same feed, over the same inventory the daily re-check * uses (inactive plugins and every theme included, since they sit on disk * unpatched), so the two agree about what is installed. * * @since 1.10.848 * @return array lookup_component_cves() shape. */ public static function lookup_installed_cves(): array { $components = array(); if ( class_exists( 'CSDT_Vuln_Recheck' ) && method_exists( 'CSDT_Vuln_Recheck', 'inventory' ) ) { foreach ( (array) CSDT_Vuln_Recheck::inventory() as $c ) { if ( ! is_array( $c ) || '' === (string) ( $c['slug'] ?? '' ) ) { continue; } $type = (string) ( $c['type'] ?? '' ); if ( class_exists( 'CSDT_Vuln_Feed' ) && CSDT_Vuln_Feed::is_server_type( $type ) ) { continue; // Added below with its unverifiable list. } $components[ $type . ':' . strtolower( (string) $c['slug'] ) ] = $c; } } $server = self::server_software_components(); foreach ( (array) ( $server['components'] ?? array() ) as $sc ) { $components[ 'server:' . $sc['type'] ] = $sc; } return self::feed_lookup( $components, $server ); } /** * Server-software findings joined to a report that a model produced. * * The public face of merge_posture_findings() for the standard scan: the * rule findings about PHP, the database and the rest survive whatever the * model said, and a model restating one is collapsed into it. * * @since 1.10.848 * @param array $report Report in the shared scan schema. * @param array $cve_matches lookup_installed_cves() output. * @return array */ public static function attach_server_software_findings( array $report, array $cve_matches ): array { return self::merge_posture_findings( $report, self::server_software_findings( $cve_matches ) ); } /** * The CVE citation rule for the standard scan's system prompt. * * Appended to the default prompt AND to any administrator override, for the * same reason the deep scan appends its scope line to an override: a custom * prompt must not be a way back to memory-sourced CVE ids. * * @since 1.10.848 * @param bool $feed_checked Whether cve_matches carries a completed lookup. * @return string */ public static function standard_scan_cve_instruction( bool $feed_checked ): string { $rule = 'Supplied context: "cve_matches" holds deterministic vulnerability-feed lookups for the installed plugins, themes, WordPress core and the server software. ' . 'Cite CVE IDs and CVSS scores ONLY from cve_matches, never from memory; a finding may carry the optional fields "cve_id", "cvss_score", "affected_component" and "source_url" only when cve_matches supplies them. ' . 'Entries in cve_matches whose type is php, mysql, mariadb, nginx, apache, curl, imagemagick, redis, memcached or sqlite are server software: PHP already raises each confirmed server-software CVE and each end-of-life branch as a finding itself, so do not restate them, and a WordPress, theme or plugin update never fixes one.'; if ( ! $feed_checked ) { $rule .= ' On this run cve_matches.checked is false: the feed did not run, so cite NO CVE id at all and do not describe any component as clean of known vulnerabilities.'; } return $rule; } /** * The server software this site runs on, as feed components. * * Reads what PHP can measure about its own host without shelling out: the * PHP version, the database server's version string, the web server's * Server header, and the versions of the libraries PHP is built against. * Reading is separated from parsing so the parser can be tested against * the strings real hosts report. * * @return array{components:array>,unverifiable:array} */ public static function server_software_components(): array { global $wpdb; $raw = array( 'php' => PHP_VERSION, 'db' => '', 'web' => isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : '', 'curl' => '', 'imagemagick' => '', 'sqlite' => '', 'redis' => '', 'memcached' => '', ); // db_server_info() keeps the "-MariaDB" suffix db_version() strips, which // is the one fact that decides WHICH product feed to ask. if ( isset( $wpdb ) && is_object( $wpdb ) && method_exists( $wpdb, 'db_server_info' ) ) { $raw['db'] = (string) $wpdb->db_server_info(); } if ( function_exists( 'curl_version' ) ) { $cv = curl_version(); $raw['curl'] = is_array( $cv ) ? (string) ( $cv['version'] ?? '' ) : ''; } if ( class_exists( 'Imagick' ) && method_exists( 'Imagick', 'getVersion' ) ) { try { $iv = Imagick::getVersion(); $raw['imagemagick'] = is_array( $iv ) ? (string) ( $iv['versionString'] ?? '' ) : ''; } catch ( \Throwable $e ) { $raw['imagemagick'] = ''; } } if ( class_exists( 'SQLite3' ) ) { try { $sv = SQLite3::version(); $raw['sqlite'] = is_array( $sv ) ? (string) ( $sv['versionString'] ?? '' ) : ''; } catch ( \Throwable $e ) { $raw['sqlite'] = ''; } } // Only a running cache server counts, and only through the object cache // that is actually in use. The Redis Object Cache drop-in exposes the // server's version; a bare extension says nothing about any server. $oc = $GLOBALS['wp_object_cache'] ?? null; if ( is_object( $oc ) ) { if ( method_exists( $oc, 'redis_version' ) ) { try { $raw['redis'] = (string) $oc->redis_version(); } catch ( \Throwable $e ) { $raw['redis'] = ''; } } elseif ( isset( $oc->redis_version ) && is_scalar( $oc->redis_version ) ) { $raw['redis'] = (string) $oc->redis_version; } if ( isset( $oc->m ) && $oc->m instanceof \Memcached ) { try { $mv = $oc->m->getVersion(); if ( is_array( $mv ) ) { $raw['memcached'] = (string) reset( $mv ); } } catch ( \Throwable $e ) { $raw['memcached'] = ''; } } } return self::server_software_from( $raw ); } /** * Turn what a host reports about itself into feed components. * * Pure, so tests can drive it with the strings real hosts produce: * "10.11.4-MariaDB-log", "8.0.33-0ubuntu0.22.04.2", "nginx/1.25.3", * "Apache/2.4.57 (Debian)", "ImageMagick 6.9.11-60 Q16 x86_64", and the * bare "nginx" a hardened server sends, which is measurable as "unknown" * and nothing else. * * @param array $raw Keys php, db, web, curl, imagemagick, sqlite, redis, memcached. * @return array{components:array>,unverifiable:array} */ public static function server_software_from( array $raw ): array { $components = array(); $unverifiable = array(); $add = static function ( string $type, string $version ) use ( &$components ): void { $components[] = array( 'type' => $type, 'slug' => $type, 'name' => CSDT_Vuln_Feed::SERVER_SOFTWARE[ $type ], 'version' => $version, ); }; $php = CSDT_Vuln_Feed::server_version( (string) ( $raw['php'] ?? '' ) ); if ( '' !== $php ) { $add( 'php', $php ); } $db = (string) ( $raw['db'] ?? '' ); if ( '' !== trim( $db ) ) { $dbv = CSDT_Vuln_Feed::server_version( $db ); if ( '' === $dbv ) { $unverifiable[] = 'Database server (version not disclosed: "' . trim( $db ) . '")'; } else { $add( false !== stripos( $db, 'mariadb' ) ? 'mariadb' : 'mysql', $dbv ); } } $web = trim( (string) ( $raw['web'] ?? '' ) ); if ( '' !== $web ) { if ( preg_match( '/^(nginx|apache)(?:\/(\S+))?/i', $web, $m ) ) { $product = strtolower( $m[1] ); $webv = CSDT_Vuln_Feed::server_version( (string) ( $m[2] ?? '' ) ); if ( '' === $webv ) { // server_tokens off / ServerTokens Prod: a good setting, and one // that makes the web server unverifiable from inside PHP. $unverifiable[] = CSDT_Vuln_Feed::SERVER_SOFTWARE[ $product ] . ' (version hidden by the server, which is correct, so it cannot be checked from here)'; } else { $add( $product, $webv ); } } elseif ( false !== stripos( $web, 'litespeed' ) || false !== stripos( $web, 'openresty' ) || false !== stripos( $web, 'caddy' ) || false !== stripos( $web, 'iis' ) ) { $unverifiable[] = 'Web server "' . $web . '" (no advisory feed for this product)'; } } foreach ( array( 'curl', 'imagemagick', 'sqlite', 'redis', 'memcached' ) as $lib ) { $v = CSDT_Vuln_Feed::server_version( (string) ( $raw[ $lib ] ?? '' ) ); if ( '' !== $v ) { $add( $lib, $v ); } } return array( 'components' => $components, 'unverifiable' => $unverifiable, ); } /** * JUDGEMENT PASS, run one judgement half and parse it, isolating every failure. * * Each half owns its own try/catch so a transport error, refusal or * unparseable reply in one half can never stop the other from running * (CSDT_AI_Dispatcher::call_parallel() is sequential and aborts the whole * batch on the first throw, which used to lose the second call entirely). * * @return array{report:?array,reason:string,message:string} */ /** * Records which phase of the deep dive is running, for the poller to display. * * WHY THIS EXISTS * --------------- * The status option held `running` and nothing else, so a five-minute scan showed a moving bar, an * elapsed counter and no indication of what was happening. Reported 2026-08-17: "this took 5 mins. * Can we give interim updates to the screen to show that its not hanging?" * * REAL PHASES, NOT A TIMED FICTION. The tempting fix is to have the browser guess from elapsed * time, it needs no server change and is indistinguishable from working, right up to the run that * stalls in pass B while the screen cheerfully claims to be writing the report. These strings are * written by the code that is actually doing the thing, so when one stops advancing it is telling * the truth about where the run is stuck. * * Merged into the existing option rather than replacing it: started_at and expires_at are what the * poller's give-up logic reads, and losing them would make a running scan look expired. * * @param string $stage Human-readable phase, shown verbatim. * @return void */ private static function deep_stage( string $stage ): void { $raw = get_option( 'csdt_deep_scan_status', array() ); if ( ! is_array( $raw ) || ( $raw['status'] ?? '' ) !== 'running' ) { // Completed, errored or cleared: a late stage write must not resurrect it as running. return; } $raw['stage'] = $stage; $raw['stage_at'] = time(); update_option( 'csdt_deep_scan_status', $raw, false ); } private static function run_judgement_half( string $system, string $user_msg, string $model, string $label, bool $is_retry = false, ?array $pre = null ): array { try { // $pre is a result already fetched by call_each_parallel(), which submits both // halves before waiting on either. Only the CALL is skipped here, parsing, // diagnostics, classification and the one retry are identical either way, which // is the whole reason parallelism was added at this seam rather than inside them. // A retry always re-calls (it passes $pre = null), so the rare slow path stays // sequential and nothing has to be re-plumbed for it. if ( null !== $pre ) { if ( empty( $pre['ok'] ) ) { throw new \RuntimeException( esc_html( (string) ( $pre['error'] ?? 'the completion failed' ) ) ); } $text = (string) $pre['text']; } else { // 6144 was the ceiling both halves ran into. The internal half carries CVE // matches, integrity results and malware indicators, and a full report over // that data does not always fit, a reply cut off at the ceiling is the // failure this whole path was losing a half of the audit to. // accept_truncated: parse_json_report() below repairs a cut-off reply and marks // it 'salvaged', which the scan reports, so the partial text is wanted here. $text = CSDT_AI_Dispatcher::call( $system, $user_msg, $model, self::JUDGEMENT_MAX_TOKENS, '', true ); } } catch ( \Throwable $e ) { $reason = self::ai_failure_reason( $e ); self::log_line( 'CSDT deep scan: ' . $label . ' half call failed (' . $reason . '), ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log return array( 'report' => null, 'reason' => $reason, 'message' => $e->getMessage(), ); } try { return array( 'report' => CSDT_AI_Dispatcher::parse_json_report( $text ), 'reason' => 'ok', 'message' => '', ); } catch ( \Throwable $e ) { // parse_json_report() prefixes refusals with 'AI_REFUSAL: ', so a // refusal is never reported as a parse bug (and vice versa). $reason = self::ai_failure_reason( $e ); if ( 'error' === $reason ) { $reason = self::looks_like_refusal( $text ) ? 'refused' : 'unparseable'; } // Say WHAT came back, not just that it was wrong. // // This half failed for weeks reporting only "the model reply was not valid JSON: // AI returned unexpected format.", true, unactionable, and identical whether the // reply was truncated, prose, an error object or empty. There was no way to tell // from the outside, so nobody could fix it. $diag = self::describe_bad_reply( $text ); if ( 'unparseable' === $reason ) { // A ZERO-BYTE reply is its own diagnosis and must not be called // "unparseable". Nothing arrived to parse: on 2026-08-11 the managed // endpoint answered 504 twice at 90.18s and 90.30s, the proxy's nginx // fastcgi_read_timeout, set to 90s deliberately because Cloudflare's edge // gives up at 100s, and both halves logged "the reply was empty (0 // bytes)". Classed as unparseable, that fired the format-restatement // retry below, which spent a second full 90s window restating JSON rules // to a connection that had already been cut. Roughly three minutes of a // six-minute scan was this. $reason = $diag['truncated'] ? 'truncated' : ( 0 === (int) ( $diag['len'] ?? 0 ) ? 'empty' : 'unparseable' ); } if ( 'empty' === $reason ) { // Alerted rather than only logged, because this had no alert of its own and // ran unnoticed for as long as it did precisely because of that. self::alert_ai_gateway_timeout( $label, $e->getMessage() ); } self::record_half_diagnostics( $label, $diag ); self::log_line( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log 'CSDT deep scan: ' . $label . ' half ' . $reason . ', ' . $e->getMessage() . ' | ' . $diag['summary'] ); // ── One retry, and only one ──────────────────────────────────────────────── // // The observed failure is INTERMITTENT: the same half, over the same site data, // failed from the browser and then parsed cleanly on the next run. So the reply // was a one-off formatting slip, and throwing away half a security audit over one // is the wrong trade, the run has already cost a minute and the user is waiting. // // Retried with the format requirement restated, because that is the thing that // went wrong. Guarded by $is_retry so this can never recurse: a provider having a // bad day must not turn one scan into an unbounded spend. // // 'empty' is deliberately NOT in this list. A retry here is only worth its 90 // seconds when there was a reply whose FORMAT was wrong; a zero-byte reply means // the transport failed, and restating the schema cannot change that. Retrying it // doubled the cost of the exact failure that was already timing out. if ( ! $is_retry && in_array( $reason, array( 'unparseable', 'truncated' ), true ) ) { self::log_line( 'CSDT deep scan: retrying the ' . $label . ' half once with the format restated' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log $retry = self::run_judgement_half( $system . "\n\n" . self::JUDGEMENT_RETRY_NOTE, $user_msg, $model, $label, true ); if ( null !== $retry['report'] ) { $retry['retried'] = true; return $retry; } // Both attempts failed. Report the FIRST diagnosis, which is the one already // logged and recorded, rather than the retry's. } return array( 'report' => null, 'reason' => $reason, 'message' => 'truncated' === $reason ? $diag['summary'] : $e->getMessage(), ); } } /** * Characterise a reply that would not parse, cheaply and without storing the whole thing. * * Truncation is detected structurally rather than by asking the provider: count the braces * and brackets that opened and never closed, ignoring those inside strings. A reply cut off * at the token ceiling leaves them unbalanced, which is exactly why parse_json_report() * fails on it, strrpos() finds a NESTED closing brace and hands json_decode() a fragment. * * @since 1.10.517 * @param string $text Raw model reply. * @return array{summary:string,truncated:bool,len:int,head:string,tail:string} */ private static function describe_bad_reply( string $text ): array { $len = strlen( $text ); $trim = trim( $text ); $depth_curly = 0; $depth_square = 0; $in_string = false; $escaped = false; for ( $i = 0; $i < $len; $i++ ) { $ch = $text[ $i ]; if ( $escaped ) { $escaped = false; continue; } if ( '\\' === $ch ) { $escaped = true; continue; } if ( '"' === $ch ) { $in_string = ! $in_string; continue; } if ( $in_string ) { continue; } if ( '{' === $ch ) { ++$depth_curly; } elseif ( '}' === $ch ) { --$depth_curly; } elseif ( '[' === $ch ) { ++$depth_square; } elseif ( ']' === $ch ) { --$depth_square; } } $unclosed = $depth_curly > 0 || $depth_square > 0 || $in_string; $has_open = false !== strpos( $trim, '{' ); $truncated = $has_open && $unclosed; if ( '' === $trim ) { $summary = 'the reply was empty (0 bytes)'; } elseif ( $truncated ) { $summary = sprintf( 'the reply was cut off mid-JSON at %d bytes (%d unclosed object(s), %d unclosed array(s)%s), it hit the token ceiling', $len, max( 0, $depth_curly ), max( 0, $depth_square ), $in_string ? ', inside an unterminated string' : '' ); } elseif ( ! $has_open ) { $summary = sprintf( 'the reply contained no JSON object at all (%d bytes)', $len ); } else { // Name the actual decode error. Without this the message was "did not decode or had // no score", which covers a raw newline inside a string, an unescaped quote, a // trailing comma and a perfectly valid document that simply omitted the score, four // problems with four different fixes. Diagnosing the live failure needed exactly // this line and it was not there. json_decode( $trim, true ); $json_err = json_last_error_msg(); $decoded = json_decode( $trim, true ); if ( is_array( $decoded ) ) { $summary = sprintf( 'the reply was %d bytes of VALID JSON but had no usable "score" (keys present: %s)', $len, implode( ', ', array_slice( array_keys( $decoded ), 0, 8 ) ) ); } else { $summary = sprintf( 'the reply was %d bytes of JSON that json_decode() rejected: %s', $len, $json_err ); } } return array( 'summary' => $summary, 'truncated' => $truncated, 'len' => $len, 'head' => substr( $trim, 0, 400 ), 'tail' => substr( $trim, -400 ), ); } /** * Keep the last failure per half, for the Diagnostics tab and for support. * * Bounded to the two halves and to head/tail excerpts, never the whole reply, which can be * tens of kilobytes and has no business living in wp_options. * * @since 1.10.517 * @param string $label Half label. * @param array $diag describe_bad_reply() output. * @return void */ private static function record_half_diagnostics( string $label, array $diag ): void { $all = get_option( self::OPTION_DEEP_DIAG, array() ); if ( ! is_array( $all ) ) { $all = array(); } $all[ $label ] = array( 'at' => time(), 'summary' => (string) $diag['summary'], 'len' => (int) $diag['len'], 'head' => (string) $diag['head'], 'tail' => (string) $diag['tail'], ); update_option( self::OPTION_DEEP_DIAG, $all, false ); } /** * Human-readable description of a failed half, for the partial-result note. */ private static function half_failure_text( string $label, array $half ): string { $why = array( 'truncated' => 'the model reply was cut off before it finished', 'refused' => 'the model declined to answer', 'quota' => 'the AI quota or rate limit was reached', 'unparseable' => 'the model reply was not valid JSON', // Distinct from unparseable on purpose: nothing arrived at all, which points at // the connection rather than the model, and is the difference between "try again" // and "the request is taking longer than the gateway allows". 'empty' => 'the AI returned nothing at all, which usually means the request timed out in transit', 'error' => 'the AI call failed', ); $msg = trim( (string) ( $half['message'] ?? '' ) ); return $label . ' (' . ( $why[ $half['reason'] ] ?? 'the AI call failed' ) . ( '' !== $msg ? ': ' . $msg : '' ) . ')'; } public static function cron_deep_scan(): void { // This function only ever runs in the background (WP-Cron, or an // admin-ajax request that already flushed its response via // send_and_continue()). Declare it so the CVE feed's own // "never block a page load" guard can allow the lookup. if ( ! defined( 'CSDT_BACKGROUND_SCAN' ) ) { define( 'CSDT_BACKGROUND_SCAN', true ); } $code_triage = array(); $model = self::resolve_deep_scan_model(); $facts_source = 'not run'; $partial = false; $partial_note = ''; $cve_matches = array(); $posture = array(); // Honest AI accounting, same contract as ajax_site_audit(): 'ok' only // when a judgement half actually returned a usable report. A rule-only // fallback says so in ai_status and ai_note rather than presenting // itself as an AI deep dive that happened to find less. $ai_used = true; $ai_status = 'ok'; $ai_note = ''; try { if ( get_transient( 'csdt_deep_scan_cancelled' ) ) { delete_transient( 'csdt_deep_scan_cancelled' ); return; } self::deep_stage( 'Collecting site data' ); // ── Collect (deterministic PHP, no model involved) ────────────── $base_data = self::gather_deep_internal_data(); // Judged in PHP, before any model is involved, so these five facts // survive every AI outcome. See posture_findings(). $posture = self::posture_findings( $base_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; } if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); $active_files = (array) get_option( 'active_plugins', array() ); $plugins_unchecked = array(); $core_unchecked = ''; $plugin_cves = self::check_plugin_vulnerabilities( $active_files, $all_plugins, $plugins_unchecked ); $core_cves = self::check_core_vulnerabilities( (string) get_bloginfo( 'version' ), $core_unchecked ); $cve_unchecked = self::cve_lookup_unchecked( $plugins_unchecked, $core_unchecked ); // ── Inventory, built in code. No model restates it: see derive_facts_locally(). $facts = self::derive_facts_locally( $base_data, $external ); $facts_source = 'deterministic'; self::deep_stage( 'Checking components against known vulnerabilities' ); // ── Deterministic CVE lookup on that inventory's components $cve_matches = self::lookup_component_cves( $facts ); self::deep_stage( 'Judging severity and what to fix first' ); // ── Judgement pass: strong model judges facts + CVE matches (the raw // evidence rides along so findings can still cite specifics) ──── $shared = array( 'facts' => $facts, 'cve_matches' => $cve_matches, ); $msg_internal = 'WordPress internal configuration facts, CVE feed matches, core-file integrity, and malware-indicator data (JSON):' . "\n\n" . wp_json_encode( array_merge( $shared, array( 'internal_data' => $base_data ) ), JSON_PRETTY_PRINT ); // Active verification, when a recent run exists for this site. This is // the strongest evidence in the bundle: everything else in the external // half is inference, these findings were reproduced by real requests. $active_verification = class_exists( 'CSDT_Nuclei' ) ? CSDT_Nuclei::evidence_for_deep_scan() : array(); $msg_external = 'WordPress external exposure facts, passive fingerprinting, plugin code scan, code triage, and known CVE data (JSON):' . "\n\n" . wp_json_encode( array_merge( $shared, array( 'external_checks' => $external, 'plugin_code_scan' => $code_scan, 'code_triage' => $code_triage, 'plugin_cves' => $plugin_cves, 'core_cves' => $core_cves, 'cve_lookup_unchecked' => $cve_unchecked, 'active_verification' => $active_verification, ) ), JSON_PRETTY_PRINT ); // Both halves are submitted before either is waited on, so they run at the same // time instead of one after the other. They are independent, different prompts // over different slices of the same audit, and nothing downstream cares which // finished first, so the sequencing was never anything but an accident of // call_each() being a foreach. On the managed path this roughly halves the // scan's wall clock; with a site's own API key there is no queue to submit to // and call_each_parallel() falls back to running them in turn. $pair = CSDT_AI_Dispatcher::call_each_parallel( array( 'internal' => array( 'system' => self::judgement_prompt( 'internal' ), 'user' => $msg_internal, 'model' => $model, 'max_tokens' => self::JUDGEMENT_MAX_TOKENS, // Repaired and marked 'salvaged' by run_judgement_half(). 'accept_truncated' => true, ), 'external' => array( 'system' => self::judgement_prompt( 'external' ), 'user' => $msg_external, 'model' => $model, 'max_tokens' => self::JUDGEMENT_MAX_TOKENS, // Repaired and marked 'salvaged' by run_judgement_half(). 'accept_truncated' => true, ), ) ); $half_int = self::run_judgement_half( self::judgement_prompt( 'internal' ), $msg_internal, $model, 'internal', false, $pair['internal'] ?? null ); $half_ext = self::run_judgement_half( self::judgement_prompt( 'external' ), $msg_external, $model, 'external', false, $pair['external'] ?? null ); $rep_int = $half_int['report']; $rep_ext = $half_ext['report']; $failed = array(); if ( null === $rep_int ) { $failed[] = self::half_failure_text( 'the internal configuration half', $half_int ); } if ( null === $rep_ext ) { $failed[] = self::half_failure_text( 'the external exposure half', $half_ext ); } // A SALVAGED half is not a failed half and not a clean one either: the reply was // cut off at the token ceiling and everything the model had finished saying was // recovered. Reported, because a findings list that stops early must not read as // "nothing further found". $salvaged = array(); if ( is_array( $rep_int ) && ! empty( $rep_int['salvaged'] ) ) { $salvaged[] = 'the internal configuration half'; } if ( is_array( $rep_ext ) && ! empty( $rep_ext['salvaged'] ) ) { $salvaged[] = 'the external exposure half'; } if ( $rep_int && $rep_ext ) { $report = CSDT_AI_Dispatcher::merge_reports( $rep_int, $rep_ext ); } elseif ( $rep_int ) { $report = $rep_int; } elseif ( $rep_ext ) { $report = $rep_ext; } else { // Both halves failed. This used to throw, which stored no report at // all and left the panel showing the previous run, so a quota stop // or a provider outage silently took the whole posture picture with // it, including five facts PHP had already established without help // from any model. Fall back to a rule-only report the way // ajax_site_audit() has always done, and be explicit that the AI // half did not run so an absent finding reads as unknown rather // than as clean. The failure is still alerted below. $ai_used = false; $ai_status = 'error'; foreach ( array( $half_int, $half_ext ) as $half ) { $reason = (string) ( $half['reason'] ?? '' ); if ( in_array( $reason, array( 'refused', 'quota' ), true ) ) { $ai_status = $reason; break; } } $ai_note = self::ai_failure_note( $ai_status, implode( '; ', $failed ) ); $report = array( 'summary' => 'AI judgement did not run for this scan, so what follows is the rule-based configuration check only. Treat anything not listed here as unchecked rather than as clean.', 'critical' => array(), 'high' => array(), 'medium' => array(), 'low' => array( array( 'evidence' => 'rule', 'title' => 'AI analysis did not run: this is a rule-based report', 'detail' => $ai_note . ' The deterministic checks below still ran in full: secret keys and salts, administrator second factors and logins, scheduled tasks, and upstream plugin abandonment. Everything that depends on the model is missing from this report: code triage, CVE judgement, external exposure analysis and prioritisation.', 'fix' => 'Re-run the deep scan. If it keeps failing, check the AI provider status, key and quota under Security > AI settings. This score is not comparable with a full scan.', ), ), 'good' => array(), ); /* * Still alerted, on the same terms the exception path used. * Producing a usable rule-only report is not the same as the AI * having worked, and the owner needs to know the provider, * key or quota is down. Otherwise the failure is now invisible * precisely because the scan stopped failing loudly. * * Test-driven runs are excluded here for the same reason as in * the catch block below: the nightly check has its own alerting, * and this one reached a phone at 04:26. */ if ( class_exists( 'CSDT_Telegram' ) && CSDT_Options::get( 'csdt_notify_telegram_enabled' ) === '1' && ! CSDT_DevTools::viewer_is_test_session() ) { CSDT_Alert_Events::send( CSDT_Alert_Events::GENERAL, "AI deep scan: both judgement halves failed.\n\nA rule-based report was still produced and saved.\n\nReason: " . implode( '; ', $failed ), 'Cyber DevTools', 'error' ); } } // A half-scan must never be presented as a complete scan: mark it, // name the half that failed and why, and surface it in the findings // list, the summary and the stored history. if ( ! empty( $failed ) ) { $partial = true; $partial_note = 'Partial result: ' . implode( ' and ', $failed ) . ' did not complete, so this score and finding list cover only the half that ran.'; $report['partial'] = true; $report['partial_note'] = $partial_note; $report['summary'] = '[Partial scan] ' . trim( (string) ( $report['summary'] ?? '' ) ); $report['low'] = array_merge( array( array( 'title' => 'Partial scan result, half of the audit did not complete', 'detail' => $partial_note, 'fix' => 'Re-run the deep scan. If it keeps failing, check the AI provider status, key and quota under Security > AI settings; the scan score is not comparable with a full scan until both halves complete.', ), ), (array) ( $report['low'] ?? array() ) ); } if ( ! empty( $salvaged ) ) { $salvage_note = 'Recovered result: ' . implode( ' and ', $salvaged ) . ' was cut off by the AI provider before it finished. Everything it had already ' . 'reported was recovered and is shown below, but its finding list may stop short ' . 'of the full set, re-run the scan for a complete one.'; $report['salvaged'] = true; $report['salvaged_note'] = $salvage_note; $report['low'] = array_merge( array( array( 'title' => 'Part of this report was recovered from a truncated AI reply', 'detail' => $salvage_note, 'fix' => 'Re-run the deep scan. Nothing shown here is wrong, the list is simply incomplete, so treat the absence of a finding as unknown rather than as clean.', ), ), (array) ( $report['low'] ?? array() ) ); } } catch ( \Throwable $e ) { update_option( 'csdt_deep_scan_status', array( 'status' => 'error', 'message' => $e->getMessage(), 'expires_at' => time() + 300, ), false ); /* * NOT when a test drove the scan. * * A site can run a scheduled end-to-end check that drives a real deep dive through a * Test Account Manager session to prove the feature still works. Every failure of that * check would otherwise reach the owner as an ERROR alert about a scan they had not * asked for, in the middle of the night, for an upstream provider being slow. * * Such a check has its own alerting, so nothing goes unreported; it goes to the channel that can act on it instead of to a * phone at four in the morning. Same rule as ajax_perf_critical_notify(): an automated * run's failures are reported by the thing that caused them. */ if ( class_exists( 'CSDT_Telegram' ) && CSDT_Options::get( 'csdt_notify_telegram_enabled' ) === '1' && ! CSDT_DevTools::viewer_is_test_session() ) { CSDT_Alert_Events::send( CSDT_Alert_Events::GENERAL, "AI deep scan failed.\n\nError: " . $e->getMessage(), 'Cyber DevTools', 'error' ); } return; } if ( get_transient( 'csdt_deep_scan_cancelled' ) ) { delete_transient( 'csdt_deep_scan_cancelled' ); return; } $report = self::factcheck_plugin_findings( $report ); // Deterministic posture findings join AFTER the fact-check, which exists to // second-guess model claims about installed plugins. Putting rule findings // through it would spend an AI call re-verifying what PHP measured directly, // and on a failed call could drop it. // Server-software findings join the posture set: same rule-first contract, // same suppression of a model restating them, same survival when no model ran. $posture = array_merge_recursive( $posture, self::server_software_findings( $cve_matches ), self::cve_lookup_findings( $cve_unchecked ) ); $report = self::merge_posture_findings( $report, $posture ); // A rule-only report has no model-supplied score. Derive one from the // findings rather than leaving the panel to render a missing or inherited // number as if a model had judged the site. if ( ! $ai_used ) { $report['score'] = self::score_from_findings( $report ); $report['score_label'] = CSDT_AI_Dispatcher::score_label( (int) $report['score'] ); } $output = array( 'report' => $report, 'code_triage' => $code_triage, 'model_used' => $ai_used ? CSDT_AI_Dispatcher::model_label( $model ) : '', 'scanned_at' => time(), 'from_cache' => false, // Honest AI accounting: true only when a judgement pass returned a // usable parsed report. False means the deterministic checks ran and // the model did not, and ai_note says which failure it was. 'ai_used' => $ai_used, 'ai_status' => $ai_used ? ( $partial ? 'partial' : 'ok' ) : $ai_status, 'ai_note' => $ai_note, 'partial' => $partial, 'partial_note' => $partial_note, 'facts_source' => $facts_source, 'cve_feed_used' => ! empty( $cve_matches['checked'] ), // The boolean above is kept because stored reports carry it; everything // a reader needs in order to weigh "0 findings" is in here. 'cve_feed' => self::cve_feed_provenance( $cve_matches ), // The Host Software table, from this scan's own CVE answers (no second lookup). 'host_software' => self::deep_host_software( $cve_matches ), ); self::deep_stage( 'Writing the report' ); update_option( 'csdt_deep_scan_v1', $output, false ); update_option( 'csdt_deep_scan_status', array( 'status' => 'complete', 'completed_at' => time(), 'expires_at' => time() + 900, 'partial' => $partial, 'message' => $partial_note, ), false ); self::append_scan_history( 'deep', $report, $output['model_used'], $output['scanned_at'], // A rule-only run made no judgement call, so the dispatcher's last // recorded mode would describe some earlier request, not this scan. $ai_used ? null : 'none', $ai_used ? null : '' ); } /** * Append a completed scan to the audit trail. * * Records not just the result but its provenance: which kind of scan produced * it, which model answered, and who paid for that model. Without the last two * a history row cannot answer the question that actually gets asked about a * finding, "what looked at this, and was that our subscription or my own * key?", and a managed-to-own-key failover was indistinguishable from a * managed run. $ai_mode/$ai_provider default to what CSDT_AI_Dispatcher * recorded on the branch that ran, which is the only reliable source; pass * them explicitly only for a scan that made no call through the dispatcher. * * @param string $type Scan type: 'standard' | 'deep' | 'audit'. * @param array $report Report in the shared scan schema. * @param string $model_used Honest model label (CSDT_AI_Dispatcher::model_label()). * @param int $scanned_at Unix timestamp. * @param string|null $ai_mode 'managed' | 'own_key' | 'none'; null = ask the dispatcher. * @param string|null $ai_provider Provider that served it; null = ask the dispatcher. */ public static function append_scan_history( string $type, array $report, string $model_used, int $scanned_at, ?string $ai_mode = null, ?string $ai_provider = null ): void { $history = get_option( 'csdt_scan_history', array() ); if ( ! is_array( $history ) ) { $history = array(); } $served = class_exists( 'CSDT_AI_Dispatcher' ) ? CSDT_AI_Dispatcher::served() : array( 'mode' => 'none', 'provider' => '', ); array_unshift( $history, array( 'type' => $type, 'score' => $report['score'] ?? null, 'score_label' => $report['score_label'] ?? '', 'summary' => $report['summary'] ?? '', 'summary_parts' => $report['summary_parts'] ?? null, 'critical_count' => count( $report['critical'] ?? array() ), 'high_count' => count( $report['high'] ?? array() ), 'medium_count' => count( $report['medium'] ?? array() ), 'model_used' => $model_used, 'ai_mode' => null !== $ai_mode ? $ai_mode : (string) $served['mode'], 'ai_provider' => null !== $ai_provider ? $ai_provider : (string) $served['provider'], 'scanned_at' => $scanned_at, 'findings' => array( 'critical' => $report['critical'] ?? array(), 'high' => $report['high'] ?? array(), 'medium' => $report['medium'] ?? array(), 'low' => $report['low'] ?? array(), 'good' => $report['good'] ?? array(), ), ) ); // Keep last 50 across both scan types $history = array_slice( $history, 0, 50 ); update_option( 'csdt_scan_history', $history, false ); self::maybe_alert_new_critical( 'AI Security Scan', 'csdt_last_scan_critical_count', count( $report['critical'] ?? array() ), array_column( (array) ( $report['critical'] ?? array() ), 'title' ), 'security' ); } 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', array() ) ); } 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'; } $raw = get_option( $status_key ); $status = ( $raw && isset( $raw['expires_at'] ) && $raw['expires_at'] > time() ) ? $raw : false; $result = get_option( $result_key ); if ( ! $status ) { // No status: either nothing ever ran, or a run's status expired (started_at + 900) // while the client was still waiting on it. // // Those two need different answers, and telling them apart matters now that a // failed deep scan no longer deletes the previous report. Serving that report as // 'complete' here would show the OLD audit as though the run just finished, the // exact confusion the old delete_option() was protecting against. So a report is // only this run's output if it was written after the run began. $stale = false; if ( $result && $type === 'deep' ) { $last_start = (int) get_option( 'csdt_deep_scan_last_start', 0 ); $scanned_at = (int) ( $result['scanned_at'] ?? 0 ); // 2s of slack: last_start and the status row are written in separate calls, so // a same-second run must not be judged stale by a clock tick. $stale = $last_start > 0 && $scanned_at > 0 && $scanned_at < ( $last_start - 2 ); } if ( $result && ! $stale ) { wp_send_json_success( array( 'status' => 'complete', 'data' => array_merge( $result, array( 'from_cache' => true ) ), ) ); } else { // 'idle' with a report on screen is how the client learns the run ended without // producing one. It says so, and says the previous report is still valid, // instead of hiding the dialog and leaving no trace of the attempt. wp_send_json_success( array( 'status' => 'idle' ) ); } return; } if ( $status['status'] === 'running' ) { wp_send_json_success( array( 'status' => 'running', // deep_stage() writes these into the same option; the client only // shows a phase it actually read from here (see cs-vuln-scan.js). 'stage' => (string) ( $status['stage'] ?? '' ), 'stage_at' => (int) ( $status['stage_at'] ?? 0 ), ) ); return; } if ( $status['status'] === 'complete' && $result ) { wp_send_json_success( array( 'status' => 'complete', 'data' => array_merge( $result, array( 'from_cache' => false ) ), ) ); return; } if ( $status['status'] === 'error' ) { wp_send_json_success( array( 'status' => 'error', 'message' => $status['message'] ?? 'Scan failed.', ) ); return; } wp_send_json_success( array( 'status' => 'idle' ) ); } // ── WP_Filesystem helpers ──────────────────────────────────────────────── private static function fs_read( string $path ): string|false { if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); global $wp_filesystem; return $wp_filesystem instanceof WP_Filesystem_Base ? $wp_filesystem->get_contents( $path ) : false; } }