true, 'has_key' => $ai_setup['ready'], 'setup_msg' => $ai_setup['message'], 'masked' => $saved_ant ? '••••••••' . substr( $saved_ant, -4 ) : '', 'maskedGemini' => $saved_gem ? '••••••••' . substr( $saved_gem, -4 ) : '', 'maskedOpenai' => $saved_oai ? '••••••••' . substr( $saved_oai, -4 ) : '', ) ); } public static function ajax_security_test_key(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $provider = CSDT_AI_Dispatcher::sanitize_provider( isset( $_POST['provider'] ) ? sanitize_key( wp_unslash( $_POST['provider'] ) ) : 'anthropic' ); $raw_key = isset( $_POST['api_key'] ) ? sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) : ''; $key = trim( str_replace( '•', '', $raw_key ) ); if ( 'openai' === $provider ) { if ( ! $key ) { $key = get_option( 'csdt_devtools_openai_key', '' ); } if ( ! $key ) { wp_send_json_error( array( 'message' => 'No OpenAI API key provided.' ) ); return; } $resp = wp_remote_post( // phpcs:ignore PluginCheck.CodeAnalysis.AIProvider.DirectIntegration -- bring-your-own-key call to a provider disclosed in readme.txt; the WordPress 7.0 AI Client cannot express BYOK with managed-plan failover. 'https://api.openai.com/v1/responses', array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 30, 'headers' => array( 'Authorization' => 'Bearer ' . $key, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( array( 'model' => CSDT_AI_Dispatcher::fast_model( 'openai' ), 'input' => 'Hi', 'max_output_tokens' => 64, ) ), ) ); } elseif ( $provider === 'gemini' ) { if ( ! $key ) { $key = get_option( 'csdt_devtools_gemini_key', '' ); } if ( ! $key ) { wp_send_json_error( array( 'message' => 'No Gemini API key provided.' ) ); return; } // The model is the one Auto uses, read from the dispatcher. It was hard-coded // to gemini-2.5-flash, which Google only serves to projects that already used // it, so a brand new Gemini key failed this test. // phpcs:ignore PluginCheck.CodeAnalysis.AIProvider.DirectIntegration -- bring-your-own-key call to a provider disclosed in readme.txt; the WordPress 7.0 AI Client cannot express BYOK with managed-proxy failover, and 7.0 is above this plugin's floor. $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode( CSDT_AI_Dispatcher::auto_model( 'gemini', 0 ) ) . ':generateContent?key=' . rawurlencode( $key ); $resp = wp_remote_post( $url, array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'contents' => array( array( 'role' => 'user', 'parts' => array( array( 'text' => 'Hi' ) ), ), ), ) ), ) ); } else { if ( ! $key ) { $key = get_option( 'csdt_devtools_anthropic_key', '' ); } if ( ! $key ) { wp_send_json_error( array( 'message' => 'No API key provided.' ) ); return; } $resp = wp_remote_post( // phpcs:ignore PluginCheck.CodeAnalysis.AIProvider.DirectIntegration -- bring-your-own-key call to a provider disclosed in readme.txt; the WordPress 7.0 AI Client cannot express BYOK with managed-proxy failover, and 7.0 is above this plugin's floor. 'https://api.anthropic.com/v1/messages', array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 15, 'headers' => array( 'x-api-key' => $key, 'anthropic-version' => '2023-06-01', 'content-type' => 'application/json', ), 'body' => wp_json_encode( array( // Auto's fast model, from the dispatcher, so this test never names a retired id. 'model' => CSDT_AI_Dispatcher::fast_model( 'anthropic' ), 'max_tokens' => 10, 'messages' => array( array( 'role' => 'user', 'content' => 'Hi', ), ), ) ), ) ); } $r = CSDT_Http::json( $resp ); if ( 0 === $r['code'] ) { wp_send_json_error( array( 'message' => 'Could not reach ' . CSDT_AI_Dispatcher::provider_label( $provider ) . ': ' . $r['error'] ) ); return; } if ( 200 === $r['code'] ) { wp_send_json_success( array( 'valid' => true, 'message' => '✓ API key is valid', ) ); } else { wp_send_json_error( array( 'valid' => false, 'message' => CSDT_Http::reason( $r ), ) ); } } public static function ajax_vuln_scan(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $cache_only = ! empty( $_POST['cache_only'] ); // Page-load pre-fill: return cache silently or signal nothing cached if ( $cache_only ) { $cached = get_option( 'csdt_security_scan_v2' ); if ( $cached !== false ) { wp_send_json_success( array_merge( $cached, array( 'from_cache' => true ) ) ); } else { wp_send_json_success( array( 'no_cache' => true ) ); } return; } $ai_cfg = CSDT_AI_Dispatcher::get_config(); if ( ! $ai_cfg['key'] ) { wp_send_json_error( array( 'message' => 'No API key configured.', 'need_key' => true, ) ); return; } // Clear previous result and mark as running delete_option( 'csdt_security_scan_v2' ); update_option( 'csdt_vuln_scan_status', array( 'status' => 'running', 'started_at' => time(), 'expires_at' => time() + 600, ), false ); // Send response immediately, then run scan after connection closes CSDT_AI_Dispatcher::send_and_continue( array( 'queued' => true ) ); self::cron_vuln_scan(); exit; } public static function cron_vuln_scan(): void { if ( get_transient( 'csdt_vuln_scan_cancelled' ) ) { delete_transient( 'csdt_vuln_scan_cancelled' ); return; } try { $model = get_option( 'csdt_devtools_security_model', '_auto' ); // The feed BEFORE the model, and its result INSIDE the request. Until // 1.10.848 this scan sent the site data with no feed lookup at all, so // it was the one scan whose CVE ids could only come from model recall, // which is frozen at a training cutoff and invents version ranges. The // deep scan's prompt forbids memory-sourced ids because it has // cve_matches to point at; this scan now has the same, over the same // whole-install inventory the daily re-check uses. $cve_matches = CSDT_Site_Audit::lookup_installed_cves(); $request = self::build_request( CSDT_Site_Audit::gather_security_data(), $cve_matches, (string) get_option( 'csdt_devtools_security_prompt', '' ) ); // The dispatcher's floor, not a literal, so this reads as the budget it // actually gets. A bare 4096 here was inert, call() raises anything below // DEFAULT_MAX_TOKENS, but it read as a deliberate per-feature cap, which is // what made the empty-completion failures look like a client-side problem. // // accept_truncated: parse_json_report() below repairs a report cut off at the token // ceiling and marks it 'salvaged', so a cut-off reply is still worth having here. $text = CSDT_AI_Dispatcher::call( $request['system'], $request['user'], $model, CSDT_AI_Dispatcher::DEFAULT_MAX_TOKENS, '', true ); // One parser for every scan path: CSDT_AI_Dispatcher::parse_json_report(). // This used to be a second, weaker copy of the fence-strip + // json_decode logic, so the standard scan skipped the // outermost-object extraction and every schema check. $report = CSDT_AI_Dispatcher::parse_json_report( $text ); $report = CSDT_Site_Audit::factcheck_plugin_findings( $report ); } catch ( \Throwable $e ) { self::record_scan_failure( $e ); return; } $output = self::build_output( $report, CSDT_AI_Dispatcher::model_label( $model ), $cve_matches, time() ); update_option( 'csdt_security_scan_v2', $output, false ); update_option( 'csdt_vuln_scan_status', array( 'status' => 'complete', 'completed_at' => time(), 'expires_at' => time() + 600, ), false ); CSDT_Site_Audit::append_scan_history( 'standard', $output['report'], $output['model_used'], $output['scanned_at'] ); } /** * The system prompt and user message for one standard scan, as data. * * Pure, so a test can prove what the model is asked without a WordPress * install: that cve_matches travels inside the site data, and that the * citation rule is appended to the default prompt and to an administrator's * override alike. * * @since 1.10.848 * @param array $security_data gather_security_data() output. * @param array $cve_matches lookup_installed_cves() output. * @param string $custom_prompt The csdt_devtools_security_prompt option, '' for none. * @return array{system:string,user:string} */ public static function build_request( array $security_data, array $cve_matches, string $custom_prompt ): array { $base = '' !== trim( $custom_prompt ) ? $custom_prompt : CSDT_Site_Audit::default_security_prompt(); $security_data['cve_matches'] = $cve_matches; return array( 'system' => $base . "\n\n" . CSDT_Site_Audit::standard_scan_cve_instruction( ! empty( $cve_matches['checked'] ) ), 'user' => 'WordPress site security data (JSON):' . "\n\n" . wp_json_encode( $security_data, JSON_PRETTY_PRINT ), ); } /** * The stored report for one standard scan, with the feed's findings and its * coverage attached. * * Server-software findings (a PHP branch past end of life, a database with a * confirmed advisory) join as rule findings whatever the model wrote, and the * cve_feed block is what the panel renders above the findings so that "0 * vulnerabilities" is read against how much was actually checked. * * @since 1.10.848 * @param array $report Parsed, fact-checked model report. * @param string $model_label CSDT_AI_Dispatcher::model_label() output. * @param array $cve_matches lookup_installed_cves() output. * @param int $scanned_at Unix time. * @return array */ public static function build_output( array $report, string $model_label, array $cve_matches, int $scanned_at ): array { $report = CSDT_Site_Audit::attach_server_software_findings( $report, $cve_matches ); return array( 'report' => $report, 'model_used' => $model_label, 'scanned_at' => $scanned_at, 'from_cache' => false, 'cve_feed_used' => ! empty( $cve_matches['checked'] ), 'cve_feed' => CSDT_Site_Audit::cve_feed_provenance( $cve_matches ), ); } /** * Write a scan failure to the status option, distinguishing a model refusal * from a technical failure. * * A refusal ("I can't help with penetration testing…") used to surface as * 'AI returned unexpected format.', which is indistinguishable from a * truncation or a JSON bug and sends the admin looking in the wrong place. * The status now carries a refusal flag plus honest, actionable wording. * * @since 1.10.314 * @param \Throwable $e The failure. * @return void */ private static function record_scan_failure( \Throwable $e ): void { $refusal = CSDT_AI_Dispatcher::is_refusal_error( $e ); if ( $refusal ) { $detail = trim( substr( $e->getMessage(), strlen( CSDT_AI_Dispatcher::REFUSAL_PREFIX ) ) ); $message = 'The AI model declined this request, so no report was produced. This is a refusal, not a parsing error. ' . 'Try re-running the scan, switching model or provider under Security settings, or softening any custom system prompt. ' . 'Model response: ' . $detail; } else { $message = $e->getMessage(); } update_option( 'csdt_vuln_scan_status', array( 'status' => 'error', 'refusal' => $refusal, 'message' => $message, 'expires_at' => time() + 300, ), false ); } }