0 && ( time() - $started ) > self::PENDING_MAX_AGE ) { delete_option( self::OPTION_PENDING ); return array(); } return $pending; } public static function register(): void { CSDT_Ajax::register( 'wp_ajax_csdt_nuclei_start', array( __CLASS__, 'ajax_start' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_nuclei_poll', array( __CLASS__, 'ajax_poll' ) ); } /** * The licence key that authorises active scanning, or '' when unlicensed. * * Active verification runs on CloudScale infrastructure against a live site, * so unlike the passive scan it has no bring-your-own-key path. */ private static function licence_key(): string { if ( ! class_exists( 'CSDT_Managed_AI' ) ) { return ''; } $o = get_option( CSDT_Managed_AI::OPTION, array() ); return is_array( $o ) ? trim( (string) ( $o['proxy_license_key'] ?? '' ) ) : ''; } /** True when the feature is usable at all. */ public static function is_available(): bool { return '' !== self::licence_key(); } /** This site's own host, the default (and automatically authorised) target. */ public static function own_target(): string { $host = wp_parse_url( home_url(), PHP_URL_HOST ); return is_string( $host ) ? strtolower( $host ) : ''; } /** * How long an active-scan result is still shown as current. * * A pass means "nothing was exploitable when this ran". Plugins update weekly, so * past a week that sentence is history rather than status, and the panel was * rendering a two-week-old pass in the same bold green as one from a minute ago, * with the age in grey above it. The verdict now carries its own age and stops * being green here. * * Deliberately NOT the 30 days in evidence_for_deep_scan(): that answers "is this * still usable as evidence for another scan", which tolerates more age than "is it * safe to reassure somebody with this". */ const FRESH_DAYS = 7; /** * The fixed state the panel renders: this site, authorised by the licence * registered to it. There is exactly one target and exactly one state, so * unlike the removed multi-state version this needs no host argument and no * broker round trip to answer. * * @return array{label:string,detail:string,tone:string,actions:array{scan:bool}} */ public static function state_payload(): array { // Named at a first-time reader, not at someone comparing this to the states // it replaced. "Authorised" and "nothing to prove" only meant anything next // to a domain that WASN'T, with that gone, they read as jargon answering a // question nobody asked. Naming the actual host and the actual click makes // this stand on its own. return array( 'label' => '๐Ÿ  Ready to test ' . self::own_target(), 'detail' => 'Included in your CloudScale licence, nothing to set up. Click Run to fire real, safety-limited attack requests at this site and see exactly what gets through.', 'tone' => 'ok', 'actions' => array( 'scan' => true ), ); } /** * POST to the broker. * * @param array $body Request body. * @return array{ok:bool,code:int,data:array,message:string} */ private static function post( array $body ): array { $key = self::licence_key(); if ( '' === $key ) { return array( 'ok' => false, 'code' => 0, 'data' => array(), 'message' => 'Active verification needs a CloudScale subscription, it runs on our infrastructure, not your server.', ); } $res = wp_remote_post( self::ENDPOINT, array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 25, 'headers' => array( 'Content-Type' => 'application/json', 'X-License-Key' => $key, ), 'body' => wp_json_encode( $body ), ) ); return self::interpret( $res ); } /** GET from the broker (polling). */ private static function get( array $args ): array { $key = self::licence_key(); if ( '' === $key ) { return array( 'ok' => false, 'code' => 0, 'data' => array(), 'message' => 'No CloudScale subscription configured.', ); } $url = add_query_arg( array_merge( array( 'key' => $key ), $args ), self::ENDPOINT ); return self::interpret( wp_remote_get( $url, array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 20 ) ) ); } /** * Normalise a wp_remote_* result into a decided outcome. * * Transport failure and an HTTP error are reported distinctly: "we could not * reach the broker" and "the broker refused" need different user actions, and * collapsing them is how a silent-failure bug starts. * * @param array|WP_Error $res * @return array{ok:bool,code:int,data:array,message:string} */ private static function interpret( $res ): array { $r = CSDT_Http::json( $res ); if ( 0 === $r['code'] ) { return array( 'ok' => false, 'code' => 0, 'data' => array(), 'message' => 'Could not reach the CloudScale scanner: ' . $r['error'], ); } $code = $r['code']; $data = null !== $r['data'] ? $r['data'] : array(); // A 2xx whose body is not JSON came from something in front of the broker (a // Cloudflare challenge is served as 200). Until 1.10.934 it counted as success // with no data. if ( null === $r['data'] ) { return array( 'ok' => false, 'code' => $code, 'data' => array(), 'message' => 'The CloudScale scanner could not be read: ' . CSDT_Http::describe( $r ) . '.', ); } if ( $code >= 200 && $code < 300 ) { return array( 'ok' => true, 'code' => $code, 'data' => $data, 'message' => '', ); } return array( 'ok' => false, 'code' => $code, 'data' => $data, 'message' => (string) ( $data['error'] ?? ( 'The scanner returned HTTP ' . $code . '.' ) ), ); } // โ”€โ”€ AJAX โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** Shared gate for every handler here. */ private static function guard(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 ); } } /** Queue an active scan against this site, the only target a licence authorises. */ public static function ajax_start(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); self::guard(); $target = self::own_target(); if ( '' === $target ) { wp_send_json_error( array( 'message' => "Could not determine this site's host." ) ); } $r = self::post( array( 'action' => 'scan', 'target' => $target, ) ); if ( ! $r['ok'] ) { wp_send_json_error( array( 'message' => $r['message'], 'code' => $r['code'], 'retry_after' => isset( $r['data']['retry_after'] ) ? (int) $r['data']['retry_after'] : 0, ) ); } $scan_id = (int) ( $r['data']['scan_id'] ?? 0 ); update_option( self::OPTION_PENDING, array( 'scan_id' => $scan_id, 'host' => (string) ( $r['data']['host'] ?? $target ), 'ownership' => (string) ( $r['data']['ownership'] ?? '' ), 'started_at' => time(), ), false ); wp_send_json_success( array( 'scan_id' => $scan_id, 'status' => (string) ( $r['data']['status'] ?? 'queued' ), 'host' => (string) ( $r['data']['host'] ?? $target ), 'ownership' => (string) ( $r['data']['ownership'] ?? '' ), 'already_queued' => ! empty( $r['data']['already_queued'] ), 'poll_after' => (int) ( $r['data']['poll_after'] ?? 20 ), ) ); } /** Poll an in-flight scan; caches and returns the report once complete. */ public static function ajax_poll(): void { // guard() verifies this same nonce; it is verified here as well so that the check sits // in the handler that reads the request, where a reviewer reading one function sees it. check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); self::guard(); $pending = self::pending_scan(); $scan_id = isset( $_POST['scan_id'] ) ? absint( wp_unslash( $_POST['scan_id'] ) ) : 0; if ( ! $scan_id ) { $scan_id = (int) ( $pending['scan_id'] ?? 0 ); } if ( ! $scan_id ) { wp_send_json_success( array( 'status' => 'idle' ) ); } $r = self::get( array( 'id' => $scan_id ) ); if ( ! $r['ok'] ) { // The broker is unreachable or refused. Leave the pending record alone, // this may be a blip and the scan may still be running, but it is now // bounded by PENDING_MAX_AGE rather than surviving forever. wp_send_json_error( array( 'message' => $r['message'] ) ); } $status = (string) ( $r['data']['status'] ?? '' ); if ( 'queued' === $status || 'running' === $status ) { // Whitelisted field by field rather than passed through wholesale: this // is broker output heading for the DOM, and the panel should only ever // receive the integers it knows how to draw. $progress = array(); $raw = isset( $r['data']['progress'] ) && is_array( $r['data']['progress'] ) ? $r['data']['progress'] : array(); foreach ( array( 'pass', 'passes', 'requests', 'requests_total', 'templates', 'rps', 'matched', 'errors', 'budget' ) as $int_key ) { if ( isset( $raw[ $int_key ] ) ) { $progress[ $int_key ] = (int) $raw[ $int_key ]; } } if ( isset( $raw['group'] ) ) { $progress['group'] = sanitize_text_field( (string) $raw['group'] ); } wp_send_json_success( array( 'status' => $status, 'scan_id' => $scan_id, 'elapsed' => is_array( $pending ) ? max( 0, time() - (int) ( $pending['started_at'] ?? time() ) ) : 0, 'progress' => $progress, ) ); } delete_option( self::OPTION_PENDING ); if ( 'error' === $status ) { // Recorded, not just reported. A failed scan used to leave nothing behind // on this site at all, store_result() only ran for a completed one, so // the only account of it lived in the broker's admin panel, and from the // site's side a run that died was indistinguishable from one never // started. "Every scan has an audit row" has to include the failures. self::append_history( array( 'scan_id' => $scan_id, 'host' => (string) ( $pending['host'] ?? ( $r['data']['host'] ?? '' ) ), 'ownership' => (string) ( $r['data']['ownership'] ?? ( $pending['ownership'] ?? '' ) ), 'status' => 'error', 'error' => (string) ( $r['data']['error'] ?? 'The active scan failed.' ), 'scanned_at' => time(), ), $r['data'] ); wp_send_json_error( array( 'message' => (string) ( $r['data']['error'] ?? 'The active scan failed.' ), ) ); } $result = self::store_result( $r['data'] ); wp_send_json_success( array( 'status' => 'complete', 'data' => $result, ) ); } /** * Append one active scan, succeeded or failed, to the local audit trail. * * Every row carries the same three provenance facts the AI scans record, so a * single question can be asked of the whole trail rather than of each feature: * * scan_type, 'active'. This is the only scan in the plugin that sends real * requests at the site; a reader must never have to infer that * from which option the row happens to live in. * engine , what actually did the testing. There is no language model in * this path at all, so recording a model id here would be a * fiction; the honest answer is the pinned scanner build and * template checkout, which is also the only thing that changes * what a scan can find. * ai_mode , 'managed'. Active verification runs on CloudScale * infrastructure against a licence key and has no * bring-your-own-key path (see licence_key()), so unlike the AI * scans this cannot be 'own_key'. Stored rather than assumed, * because a reader comparing rows across scan types needs the * field present to compare on. * * @param array $row Row fields specific to this outcome. * @param array $payload Raw broker response, for the engine versions. */ private static function append_history( array $row, array $payload ): void { $history = get_option( self::OPTION_HISTORY, array() ); if ( ! is_array( $history ) ) { $history = array(); } $templates = (string) ( $payload['templates_version'] ?? '' ); $binary = (string) ( $payload['nuclei_version'] ?? '' ); $engine = 'nuclei' . ( '' !== $binary ? ' ' . $binary : '' ) . ( '' !== $templates ? ' ยท templates ' . $templates : '' ); array_unshift( $history, array_merge( array( 'scan_type' => 'active', 'engine' => $engine, 'nuclei_version' => $binary, 'templates_version' => $templates, 'ai_mode' => 'managed', 'ai_provider' => 'cloudscale', // No model is involved: nuclei matches pinned templates, it does // not reason. Named explicitly so an empty model column reads as a // fact about this scan type rather than as missing data. 'model_used' => 'n/a, template-based scanner, no AI model', ), $row ) ); update_option( self::OPTION_HISTORY, array_slice( $history, 0, self::HISTORY_MAX ), false ); } /** * The local active-scan audit trail, newest first, with the provenance fields * normalised so every consumer sees the same shape. * * Rows written before those fields existed are filled in here rather than left * blank, because for THIS scan type the values are not a guess. Active * verification authorises against a licence key and nothing else (see * licence_key(): there is no bring-your-own-key path and never has been), and * nuclei matches pinned templates without a model. So 'active' / 'managed' / * no-model are structural facts about every row that can exist, past or * future, unlike the AI scans, where the same back-fill WOULD be a guess, * because whether a key or the subscription paid can genuinely differ per run * and cannot be recovered after the fact. Those rows stay honestly blank. * * @return array> */ public static function history(): array { $history = get_option( self::OPTION_HISTORY, array() ); if ( ! is_array( $history ) ) { return array(); } $out = array(); foreach ( $history as $row ) { if ( ! is_array( $row ) ) { continue; } $row['scan_type'] = '' !== (string) ( $row['scan_type'] ?? '' ) ? $row['scan_type'] : 'active'; $row['ai_mode'] = '' !== (string) ( $row['ai_mode'] ?? '' ) ? $row['ai_mode'] : 'managed'; $row['ai_provider'] = '' !== (string) ( $row['ai_provider'] ?? '' ) ? $row['ai_provider'] : 'cloudscale'; $row['model_used'] = '' !== (string) ( $row['model_used'] ?? '' ) ? $row['model_used'] : 'n/a, template-based scanner, no AI model'; // The engine versions were stored on the result, not the history row, // until this trail was surfaced. An older row can say which scanner it // used only if the versions happen to be there; do not invent them. // Named for the reader as CloudScale's own, and WITHOUT the upstream version // numbers. Which engine performs the verification is a supply decision, so // naming it makes every change of engine a public promise, and the version // strings identify it just as plainly as the name would, since they are the // upstream project's own release numbers. Both stay in the stored row // (nuclei_version / templates_version) because support needs to know exactly // what produced a given scan; this is only what the trail DISPLAYS. if ( '' === (string) ( $row['engine'] ?? '' ) ) { $binary = (string) ( $row['nuclei_version'] ?? '' ); $templates = (string) ( $row['templates_version'] ?? '' ); $row['engine'] = '' !== $binary || '' !== $templates ? 'CloudScale templates' : 'CloudScale templates (build not recorded for this scan)'; } $out[] = $row; } return $out; } /** * Cache a completed broker payload and append it to history. * * @param array $payload Raw broker response. * @return array The stored, plugin-shaped result. */ private static function store_result( array $payload ): array { $report = self::to_report( $payload ); $result = array( 'report' => $report, 'scan_id' => (int) ( $payload['scan_id'] ?? 0 ), 'host' => (string) ( $payload['host'] ?? '' ), 'target' => (string) ( $payload['target'] ?? '' ), 'ownership' => (string) ( $payload['ownership'] ?? '' ), 'diff' => is_array( $payload['diff'] ?? null ) ? $payload['diff'] : array(), 'templates_version' => (string) ( $payload['templates_version'] ?? '' ), 'nuclei_version' => (string) ( $payload['nuclei_version'] ?? '' ), 'duration_ms' => (int) ( $payload['duration_ms'] ?? 0 ), 'note' => (string) ( $payload['error'] ?? '' ), 'scanned_at' => time(), ); update_option( self::OPTION_RESULT, $result, false ); self::append_history( array( 'scan_id' => $result['scan_id'], 'host' => $result['host'], 'ownership' => $result['ownership'], 'status' => 'done', 'error' => '', 'scanned_at' => $result['scanned_at'], 'score' => $report['score'], 'critical' => count( $report['critical'] ), 'high' => count( $report['high'] ), 'diff' => $result['diff'], ), $payload ); // Actively verified critical/high findings are qualitatively different // from inferred ones, they are proven reachable. Push them. $severe = count( $report['critical'] ) + count( $report['high'] ); if ( $severe > 0 && class_exists( 'CSDT_Telegram' ) && '1' === CSDT_Options::get( 'csdt_notify_telegram_enabled' ) ) { CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, "Active scan CONFIRMED {$severe} critical/high finding(s) on {$result['host']}.\n" . 'These were verified by real requests, not inferred from configuration.', 'Cyber DevTools', 'error' ); } return $result; } /** * Normalise broker findings into the plugin's report schema. * * Same shape as every other scan in this plugin * ({score, score_label, summary, critical[], high[], medium[], low[], good[]}) * so the existing renderer, history and notification paths work unchanged. * * The score here is deterministic, not model-generated: these findings are * proven, so weighting them by severity is honest arithmetic rather than a * judgement call. * * @param array $payload Broker response for a completed scan. * @return array */ public static function to_report( array $payload ): array { $report = array( 'score' => 100, 'score_label' => 'Excellent', 'summary' => '', 'critical' => array(), 'high' => array(), 'medium' => array(), 'low' => array(), 'good' => array(), // Detections (versions, fingerprints), kept rather than counted. The // summary used to say "3 informational detection(s) only" and the entries // were discarded on the line that counted them, so the panel named a // number the reader had no way to reach. The comment below already said // they "belong in the report as context"; now they are in it. 'info' => array(), ); $host = (string) ( $payload['host'] ?? '' ); $findings = is_array( $payload['findings'] ?? null ) ? $payload['findings'] : array(); $fixed = array(); $active = 0; foreach ( $findings as $f ) { if ( ! is_array( $f ) ) { continue; } $state = (string) ( $f['state'] ?? 'new' ); if ( 'fixed' === $state ) { $fixed[] = $f; continue; } $sev = (string) ( $f['severity'] ?? 'info' ); // 'info' templates are detections (fingerprinting), not weaknesses. // They belong in the report as context, never as a problem to fix. $bucket = in_array( $sev, array( 'critical', 'high', 'medium', 'low' ), true ) ? $sev : null; $entry = array( 'title' => ( 'new' === $state ? '[New] ' : '' ) . (string) ( $f['name'] ?? 'Finding' ), 'detail' => self::finding_detail( $f, $host ), 'fix' => self::finding_fix( $f ), ); // Optional schema fields are added only when the broker supplied them, // parse_json_report()'s contract is "omit what the data does not support". if ( ! empty( $f['cve_id'] ) ) { $entry['cve_id'] = (string) $f['cve_id']; } if ( ! empty( $f['cvss_score'] ) ) { $entry['cvss_score'] = (float) $f['cvss_score']; } if ( ! empty( $f['component'] ) ) { $entry['affected_component'] = (string) $f['component']; } if ( ! empty( $f['reference'] ) ) { // reference is a space-joined list; the schema wants one URL. $first = strtok( (string) $f['reference'], ' ' ); if ( is_string( $first ) && '' !== $first ) { $entry['source_url'] = $first; } } // Actively verified: the highest confidence this plugin can express. $entry['confidence'] = 'high'; if ( null === $bucket ) { // Low-noise, not invisible: these are listed in a collapsed section of // their own so a curious reader can open them, and they are never // counted towards the verdict. $report['info'][] = $entry; ++$active; continue; } $report[ $bucket ][] = $entry; } foreach ( $fixed as $f ) { $report['good'][] = array( 'title' => 'Fixed since last active scan: ' . (string) ( $f['name'] ?? 'finding' ), 'detail' => 'This was confirmed on the previous active scan of ' . $host . ' and no longer reproduces. Template: ' . (string) ( $f['template_id'] ?? '' ) . '.', ); } // โ”€โ”€ Deterministic score โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ $penalty = ( count( $report['critical'] ) * 30 ) + ( count( $report['high'] ) * 15 ) + ( count( $report['medium'] ) * 6 ) + ( count( $report['low'] ) * 2 ); $report['score'] = (int) max( 0, min( 100, 100 - $penalty ) ); $score = $report['score']; if ( $score >= 90 ) { $report['score_label'] = 'Excellent'; } elseif ( $score >= 75 ) { $report['score_label'] = 'Good'; } elseif ( $score >= 55 ) { $report['score_label'] = 'Fair'; } elseif ( $score >= 30 ) { $report['score_label'] = 'Poor'; } else { $report['score_label'] = 'Critical'; } $diff = is_array( $payload['diff'] ?? null ) ? $payload['diff'] : array(); $total = count( $report['critical'] ) + count( $report['high'] ) + count( $report['medium'] ) + count( $report['low'] ); if ( 0 === $total ) { // Written for someone who has not read the Explain panel. "reproduced no // exploitable findings" is accurate and is scanner vocabulary; the reader // wants to know whether anything got in. The detection count says where to // look rather than just how many there are. $report['summary'] = 'These tests could not break into ' . $host . '.' . ( $active > 0 ? sprintf( ' %d %s found, version and fingerprint information, not a weakness. Listed at the bottom.', $active, 1 === $active ? 'detail was' : 'details were' ) : '' ); $report['good'][] = array( 'title' => 'No vulnerabilities reproduced by active verification', 'detail' => 'The curated WordPress template set was executed against ' . $host . ' and nothing exploitable responded. This is stronger evidence than a configuration audit, ' . 'but it only covers what the template set tests, it is not proof the site has no vulnerabilities.', ); } else { $report['summary'] = 'Active verification of ' . $host . ' reproduced ' . $total . ' finding(s): ' . count( $report['critical'] ) . ' critical, ' . count( $report['high'] ) . ' high, ' . count( $report['medium'] ) . ' medium, ' . count( $report['low'] ) . ' low.' . ( isset( $diff['new'] ) ? ' ' . (int) $diff['new'] . ' new since the previous scan,' : '' ) . ( isset( $diff['fixed'] ) ? ' ' . (int) $diff['fixed'] . ' fixed.' : '' ); } // A timed-out scan is a partial scan and must never read as a clean bill // of health, the same rule the deep scan follows. $note = trim( (string) ( $payload['error'] ?? '' ) ); if ( '' !== $note ) { $report['partial'] = true; $report['partial_note'] = $note; $report['summary'] = '[Partial scan] ' . $report['summary']; array_unshift( $report['low'], array( 'title' => 'Active scan did not run to completion', 'detail' => $note, 'fix' => 'Re-run the active scan. Findings already listed are real; the absence of others is not proof they are absent.', ) ); } return $report; } /** Human-readable "what this is" for one finding, with no payload. */ private static function finding_detail( array $f, string $host ): string { $bits = array(); $where = (string) ( $f['matched_at'] ?? '' ); if ( '' !== $where ) { $bits[] = 'Confirmed by a live request against ' . $where . '.'; } else { $bits[] = 'Confirmed by a live request against ' . $host . '.'; } if ( ! empty( $f['cve_id'] ) ) { $cvss = ! empty( $f['cvss_score'] ) ? ' (CVSS ' . (float) $f['cvss_score'] . ')' : ''; $bits[] = 'Matches ' . (string) $f['cve_id'] . $cvss . '.'; } if ( ! empty( $f['evidence'] ) ) { $bits[] = 'Evidence: ' . (string) $f['evidence'] . '.'; } $bits[] = 'This is an actively verified finding, the exposure was reproduced, not inferred from configuration.'; // "CloudScale template", not the engine's name. The template id itself stays: it is // what makes a finding checkable and is what support asks for first. $bits[] = 'Detected by CloudScale template ' . (string) ( $f['template_id'] ?? 'unknown' ) . '.'; return implode( ' ', $bits ); } /** * Remediation text for one finding. * * Deliberately generic-but-actionable: the template set spans plugin CVEs, * exposed files and misconfiguration, and inventing a specific command per * template would be guesswork. The reference URL is the authoritative fix. */ private static function finding_fix( array $f ): string { $tags = (string) ( $f['tags'] ?? '' ); $component = (string) ( $f['component'] ?? '' ); $ref = strtok( (string) ( $f['reference'] ?? '' ), ' ' ); $ref_txt = is_string( $ref ) && '' !== $ref ? ' Advisory: ' . $ref : ''; if ( ! empty( $f['cve_id'] ) && '' !== $component && ! str_starts_with( $component, '/' ) ) { return 'Update ' . $component . ' to the patched release: `wp plugin update ' . $component . '` (or Dashboard > Updates). If no patched version exists, deactivate and remove it until one does.' . $ref_txt; } if ( str_contains( $tags, 'exposure' ) || str_contains( $tags, 'config' ) || str_contains( $tags, 'backup' ) || str_contains( $tags, 'logs' ) ) { $path = (string) ( $f['matched_at'] ?? '' ); return 'This file or path should not be publicly readable. Delete it if it is a leftover' . ( '' !== $path ? ' (' . $path . ')' : '' ) . ', or block it at the web-server layer, nginx: `location ~ /\\.(env|git)|\\.(bak|sql|log)$ { deny all; }`;' . ' Apache: a matching `` Require all denied block. Then rotate any credential it exposed.' . $ref_txt; } if ( str_contains( $tags, 'wordpress' ) && '' !== $component && ! str_starts_with( $component, '/' ) ) { return 'Update or remove the ' . $component . ' component: `wp plugin update ' . $component . '`, or `wp plugin deactivate ' . $component . ' && wp plugin delete ' . $component . '` if it is unmaintained.' . $ref_txt; } return 'Review the advisory and apply the vendor fix, then re-run the active scan to confirm the finding no longer reproduces.' . $ref_txt; } /** * The last active-scan result, shaped as evidence for the deep scan's * external judgement half. * * This is the A-to-B link: passive collection infers, active verification * proves, and the judgement pass gets both so it can say "reachable and * confirmed" instead of "possibly exposed". Returns an empty array when there * is no reasonably recent result, so a stale scan never masquerades as * current evidence. */ public static function evidence_for_deep_scan(): array { $r = get_option( self::OPTION_RESULT, array() ); if ( ! is_array( $r ) || empty( $r['scanned_at'] ) ) { return array(); } $age = time() - (int) $r['scanned_at']; if ( $age > 30 * DAY_IN_SECONDS ) { return array(); } // Only the site's own host is relevant evidence about this site. if ( strtolower( (string) ( $r['host'] ?? '' ) ) !== self::own_target() ) { return array(); } $report = is_array( $r['report'] ?? null ) ? $r['report'] : array(); $items = array(); foreach ( array( 'critical', 'high', 'medium', 'low' ) as $sev ) { foreach ( (array) ( $report[ $sev ] ?? array() ) as $f ) { $items[] = array( 'severity' => $sev, 'title' => (string) ( $f['title'] ?? '' ), 'component' => (string) ( $f['affected_component'] ?? '' ), 'cve_id' => (string) ( $f['cve_id'] ?? '' ), 'reference' => (string) ( $f['source_url'] ?? '' ), ); } } return array( 'ran_at' => gmdate( 'c', (int) $r['scanned_at'] ), 'age_days' => (int) floor( $age / DAY_IN_SECONDS ), 'host' => (string) ( $r['host'] ?? '' ), 'templates_version' => (string) ( $r['templates_version'] ?? '' ), 'partial' => ! empty( $report['partial'] ), 'confirmed_count' => count( $items ), 'confirmed' => array_slice( $items, 0, 40 ), 'diff' => is_array( $r['diff'] ?? null ) ? $r['diff'] : array(), ); } }