'none' ) when there is none yet. * * @param bool $use_cache Serve a cached finished scan if there is one. * @param bool $may_start Start a scan if the site has never been scanned. * @return array|WP_Error */ public static function current( $use_cache = true, $may_start = true ) { if ( $use_cache ) { $cached = get_transient( self::VIEW_CACHE ); if ( is_array( $cached ) ) { return $cached; } } $scan_id = self::current_scan_id(); if ( is_wp_error( $scan_id ) ) { return $scan_id; } if ( null === $scan_id ) { // The scan Consiva queues on connecting is best-effort, so the site may never have been scanned. return $may_start ? self::start() : array( 'status' => 'none' ); } if ( '' === $scan_id ) { return array( 'status' => 'none' ); } $client = new ConsivaClient(); $response = $client->get_scan( $scan_id ); if ( self::is_status( $response, 404 ) ) { // The stored id is stale (e.g. data reset at Consiva): look the current scan up again, once. $scan_id = self::current_scan_id( true ); if ( is_wp_error( $scan_id ) || null === $scan_id || '' === $scan_id ) { return is_wp_error( $scan_id ) ? $scan_id : array( 'status' => 'none' ); } $response = $client->get_scan( $scan_id ); } if ( is_wp_error( $response ) ) { return $response; } $view = self::view( $response['body'] ); if ( ! $view['done'] ) { return $view; } // Finished: the scan preview is capped, so show the full findings list instead. if ( 'failed' !== $view['status'] ) { $findings = $client->get_findings( array( 'limit' => (string) self::FINDINGS_LIMIT ) ); if ( ! is_wp_error( $findings ) && isset( $findings['body']['data'] ) && is_array( $findings['body']['data'] ) ) { $view['findings'] = self::findings( $findings['body']['data'] ); $view['findings_total'] = isset( $findings['body']['total'] ) && is_numeric( $findings['body']['total'] ) ? (int) $findings['body']['total'] : count( $view['findings'] ); } } set_transient( self::VIEW_CACHE, $view, self::VIEW_CACHE_TTL ); return $view; } /** * Starts a manual scan ("Scan again"). If one is already running, follows that one instead. * * @return array|WP_Error View of the scan now in progress. */ public static function start() { $response = ( new ConsivaClient() )->start_scan( 'manual' ); delete_transient( self::VIEW_CACHE ); if ( self::is_status( $response, 409 ) ) { // A scan is already running for this site: show it rather than an error. $scan_id = self::current_scan_id( true ); return is_wp_error( $scan_id ) ? $scan_id : self::current( false, false ); } if ( is_wp_error( $response ) ) { return $response; } $scan_id = isset( $response['body']['scan_id'] ) && is_string( $response['body']['scan_id'] ) ? $response['body']['scan_id'] : ''; if ( ! preg_match( self::ID_PATTERN, $scan_id ) ) { return new WP_Error( 'consiva_consent_invalid_payload', __( 'Consiva started a scan but did not say which one. Reload this page to see its progress.', 'consiva-ai' ), array( 'request_id' => $response['request_id'] ) ); } State::update( array( 'current_scan_id' => $scan_id ) ); return self::current( false, false ); } /** * Forgets the cached view (e.g. on disconnect). * * @return void */ public static function clear_cache() { delete_transient( self::VIEW_CACHE ); } /** * Id of the scan to show: the one this plugin last started, else the latest one Consiva reports. * * Part A's POST /installations response has no scan id, so the scan Consiva queues on provisioning is * found through GET /sites/{id}. * * @param bool $refresh Ignore the stored id and ask Consiva. * @return string|null|WP_Error null when the site has never been scanned; '' when its latest scan is * not one to show here. */ private static function current_scan_id( $refresh = false ) { $stored = (string) State::get( 'current_scan_id' ); if ( ! $refresh && '' !== $stored ) { return $stored; } $site = ( new ConsivaClient() )->get_site(); if ( is_wp_error( $site ) ) { return $site; } // latest_scan { scan_id, status, trigger }, or null before the first scan. if ( ! isset( $site['body']['latest_scan'] ) || ! is_array( $site['body']['latest_scan'] ) ) { return '' === $stored ? null : ( $refresh ? '' : $stored ); } $latest = $site['body']['latest_scan']; $id = isset( $latest['scan_id'] ) && is_string( $latest['scan_id'] ) ? $latest['scan_id'] : ''; // Verification re-scans belong to a different screen (§6.4). if ( ! preg_match( self::ID_PATTERN, $id ) || self::is_verification_trigger( isset( $latest['trigger'] ) ? $latest['trigger'] : '' ) ) { return $refresh ? '' : $stored; } State::update( array( 'current_scan_id' => $id ) ); return $id; } /** * Whether a scan trigger is a verification re-scan. The docs say "config_change"; the live API sends * "configchange" (as it sends "installation" for "install"), so both spellings count. * * @param mixed $trigger Trigger from Consiva. * @return bool */ public static function is_verification_trigger( $trigger ) { return 'configchange' === str_replace( array( '_', '-' ), '', strtolower( (string) $trigger ) ); } /** * Allowlisted, typed view of a scan response. Everything else Consiva returns is dropped. * * Fields: progress{…}, findings_summary{total, by_classification, preview (≤25)}, poll_after_ms. * * @param array $scan Decoded GET /sites/{id}/scan/{scan_id} body. * @return array */ public static function view( array $scan ) { $progress = isset( $scan['progress'] ) && is_array( $scan['progress'] ) ? $scan['progress'] : array(); $summary = isset( $scan['findings_summary'] ) && is_array( $scan['findings_summary'] ) ? $scan['findings_summary'] : array(); $status = self::pick( $scan, 'status', self::STATUSES, 'queued' ); $done = in_array( $status, self::DONE_STATUSES, true ); $by_class = array(); if ( isset( $summary['by_classification'] ) && is_array( $summary['by_classification'] ) ) { foreach ( self::CLASSES as $class ) { $by_class[ $class ] = self::int( $summary['by_classification'], $class ); } } $poll = self::int( $scan, 'poll_after_ms', 0 ); return array( 'scan_id' => isset( $scan['scan_id'] ) && is_string( $scan['scan_id'] ) && preg_match( self::ID_PATTERN, $scan['scan_id'] ) ? $scan['scan_id'] : '', 'status' => $status, 'done' => $done, 'phase' => $done ? 'done' : self::pick( $progress, 'phase', self::PHASES, 'queued' === $status ? 'queued' : 'crawling' ), 'trigger' => isset( $scan['trigger'] ) ? sanitize_key( (string) $scan['trigger'] ) : '', 'pages_scanned' => self::int( $progress, 'pages_scanned', self::int( $scan, 'pages_crawled' ) ), 'pages_requested' => self::int( $scan, 'pages_requested' ), 'pages_estimated' => self::int( $progress, 'pages_estimated' ), 'cookies' => self::int( $progress, 'cookies_found' ), 'trackers' => self::int( $progress, 'trackers_identified' ), 'scripts' => self::int( $progress, 'scripts_found' ), 'embeds' => self::int( $progress, 'embeds_found' ), 'forms' => self::int( $progress, 'forms_found' ), 'unclassified' => self::int( $progress, 'unclassified', isset( $by_class['unclassified'] ) ? $by_class['unclassified'] : 0 ), 'pending' => self::int( $progress, 'pending_classification' ), 'by_class' => $by_class, 'coverage_note' => isset( $scan['coverage_note'] ) && is_string( $scan['coverage_note'] ) ? sanitize_textarea_field( $scan['coverage_note'] ) : '', 'failure_reason' => isset( $scan['failure_reason'] ) && is_string( $scan['failure_reason'] ) ? sanitize_key( $scan['failure_reason'] ) : '', 'completed_at' => isset( $scan['completed_at'] ) && is_string( $scan['completed_at'] ) ? sanitize_text_field( $scan['completed_at'] ) : '', 'findings' => isset( $summary['preview'] ) && is_array( $summary['preview'] ) ? self::findings( $summary['preview'] ) : array(), 'findings_total' => self::int( $summary, 'total' ), // Consiva's hint for the next poll, clamped so a bad value cannot hammer the site or stall it. 'poll_after_ms' => $done ? null : max( 1000, min( 30000, $poll > 0 ? $poll : 2000 ) ), ); } /** * @param array $items Findings or preview items. * @return array> */ private static function findings( array $items ) { $out = array(); foreach ( array_slice( $items, 0, self::FINDINGS_LIMIT ) as $item ) { if ( ! is_array( $item ) ) { continue; } $class = isset( $item['classification'] ) && in_array( $item['classification'], self::CLASSES, true ) ? $item['classification'] : ''; $out[] = array( 'type' => self::pick( $item, 'finding_type', self::TYPES, 'other' ), 'identifier' => isset( $item['identifier'] ) && is_string( $item['identifier'] ) ? sanitize_text_field( $item['identifier'] ) : '', 'technology' => isset( $item['technology'] ) && is_string( $item['technology'] ) ? sanitize_text_field( $item['technology'] ) : '', 'vendor' => isset( $item['vendor'] ) && is_string( $item['vendor'] ) ? sanitize_text_field( $item['vendor'] ) : '', 'classification' => $class, 'state' => self::pick( $item, 'state', self::STATES, '' ), 'note' => isset( $item['risk_note'] ) && is_string( $item['risk_note'] ) ? sanitize_text_field( $item['risk_note'] ) : '', ); } return $out; } /** * @param array $from Source. * @param string $key Key. * @param string[] $allowed Allowed values. * @param string $fallback Fallback. * @return string */ private static function pick( array $from, $key, array $allowed, $fallback ) { return isset( $from[ $key ] ) && in_array( $from[ $key ], $allowed, true ) ? $from[ $key ] : $fallback; } /** * @param array $from Source. * @param string $key Key. * @param int $fallback Fallback. * @return int */ private static function int( array $from, $key, $fallback = 0 ) { return isset( $from[ $key ] ) && is_numeric( $from[ $key ] ) ? max( 0, (int) $from[ $key ] ) : $fallback; } /** * @param mixed $response Result of a ConsivaClient call. * @param int $status HTTP status. * @return bool */ private static function is_status( $response, $status ) { if ( ! is_wp_error( $response ) ) { return false; } $data = $response->get_error_data(); return is_array( $data ) && isset( $data['status'] ) && (int) $status === (int) $data['status']; } }