16 ) { return false; } $method = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the sanitized method with its original unslashed bytes only to reject rewriting; no raw value is stored, displayed or accepted as a method. return 'POST' === $method && $method === wp_unslash( $_SERVER['REQUEST_METHOD'] ); } public static function register(): void { add_action( 'admin_menu', array( self::class, 'menu' ) ); add_action( 'admin_enqueue_scripts', array( self::class, 'assets' ) ); add_action( 'admin_post_antivirus_ai_action', array( self::class, 'handle' ) ); add_action( 'wp_ajax_antivirus_ai_status', array( Experience::class, 'liveStatus' ) ); Appearance::register(); Onboarding::register(); Journey::register(); add_filter( 'plugin_action_links_' . ANTIVIRUS_AI_BASENAME, static function ( $links ) { array_unshift( $links, '' . esc_html__( 'Setup guide', 'shieldlume-security' ) . '', '' . esc_html__( 'Dashboard', 'shieldlume-security' ) . '' ); return $links; } ); } public static function menu(): void { add_menu_page( __( 'Shieldlume Security', 'shieldlume-security' ), __( 'Shieldlume Security', 'shieldlume-security' ), 'manage_options', 'antivirus-ai', array( self::class, 'render' ), 'dashicons-shield', 80 ); } public static function assets( string $hook ): void { if ( 'toplevel_page_antivirus-ai' !== $hook ) { return; } wp_enqueue_style( 'antivirus-ai-admin', plugins_url( 'assets/admin.css', ANTIVIRUS_AI_FILE ), array(), ANTIVIRUS_AI_VERSION ); wp_enqueue_style( 'antivirus-ai-setup-guide', plugins_url( 'assets/setup-guide.css', ANTIVIRUS_AI_FILE ), array( 'antivirus-ai-appearance' ), ANTIVIRUS_AI_VERSION ); wp_enqueue_style( 'antivirus-ai-security-brief', plugins_url( 'assets/security-brief.css', ANTIVIRUS_AI_FILE ), array( 'antivirus-ai-appearance' ), ANTIVIRUS_AI_VERSION ); wp_enqueue_style( 'antivirus-ai-more-plugins', plugins_url( 'assets/more-plugins.css', ANTIVIRUS_AI_FILE ), array( 'antivirus-ai-appearance' ), ANTIVIRUS_AI_VERSION ); wp_enqueue_style( 'antivirus-ai-pro-benefits', plugins_url( 'assets/pro-benefits.css', ANTIVIRUS_AI_FILE ), array( 'antivirus-ai-appearance' ), ANTIVIRUS_AI_VERSION ); wp_enqueue_script( 'antivirus-ai-pro-previews', plugins_url( 'assets/pro-previews.js', ANTIVIRUS_AI_FILE ), array(), ANTIVIRUS_AI_VERSION, true ); wp_localize_script( 'antivirus-ai-pro-previews', 'avaiProPreviews', array( 'leavePrompt' => __( 'You have unsaved protection settings. Leave without saving?', 'shieldlume-security' ) ) ); wp_enqueue_style( 'antivirus-ai-finding-review', plugins_url( 'assets/finding-review.css', ANTIVIRUS_AI_FILE ), array( 'antivirus-ai-appearance' ), ANTIVIRUS_AI_VERSION ); wp_enqueue_script( 'antivirus-ai-finding-review', plugins_url( 'assets/finding-review.js', ANTIVIRUS_AI_FILE ), array(), ANTIVIRUS_AI_VERSION, true ); wp_enqueue_script( 'antivirus-ai-experience', plugins_url( 'assets/experience.js', ANTIVIRUS_AI_FILE ), array(), ANTIVIRUS_AI_VERSION, true ); wp_localize_script( 'antivirus-ai-experience', 'avaiExperience', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'antivirus_ai_status' ), 'menuOpen' => __( 'Menu', 'shieldlume-security' ), 'menuClose' => __( 'Close menu', 'shieldlume-security' ), 'motionOn' => __( 'Motion on', 'shieldlume-security' ), 'motionOff' => __( 'Motion off', 'shieldlume-security' ), 'reducedMotion' => __( 'Your system prefers reduced motion.', 'shieldlume-security' ), 'scanFinished' => __( 'Scan finished · ', 'shieldlume-security' ), 'savedProgressRecordedTime' => __( 'Saved progress refreshed · reopen the brief for its recorded time', 'shieldlume-security' ), 'reviewResults' => __( 'REVIEW RESULTS', 'shieldlume-security' ), 'automatic' => __( 'Saved progress refreshes automatically while this tab is visible.', 'shieldlume-security' ), 'updated' => __( 'Scan progress updated from saved evidence.', 'shieldlume-security' ), 'finished' => __( 'Scan state changed. Open the updated scan to review its results and available actions.', 'shieldlume-security' ), 'counterUnavailable' => __( 'Unknown', 'shieldlume-security' ), 'unavailable' => __( 'Live progress is unavailable. Refresh this page to retry.', 'shieldlume-security' ) ) ); } public static function url( string $tab = 'overview', array $extra = array() ): string { return add_query_arg( array( 'page' => 'antivirus-ai', 'tab' => in_array( $tab, self::TABS, true ) ? $tab : 'overview', ) + $extra, admin_url( 'admin.php' ) ); } public static function formStart( string $operation, string $tab = 'overview' ): void { echo '
'; echo ''; if ( 'findings' === $tab ) { self::findingContextFields(); } wp_nonce_field( 'antivirus_ai_action', '_avai_nonce' ); } /** Form fields shared by finding actions and local appearance controls. */ public static function findingContextFields(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only navigation context; these bounded values grant no action authority and are validated again after each control verifies its action nonce. foreach ( self::findingContext( $_GET ) as $name => $value ) { echo ''; } } /** Carry only existing, validated local findings navigation; never accept a return URL. */ public static function findingContext( array $values, string $prefix = '' ): array { $context = array(); foreach ( array( 'category', 'severity', 'confidence', 'status', 'scan_id', 'search' ) as $name ) { $key = $prefix . $name; if ( ! isset( $values[ $key ] ) || ! is_string( $values[ $key ] ) || strlen( $values[ $key ] ) > 400 ) { continue; } $value = sanitize_text_field( wp_unslash( $values[ $key ] ) ); if ( $value !== wp_unslash( $values[ $key ] ) ) { continue; } try { $context += FindingExport::filters( array( $name => $value ) ); } catch ( \InvalidArgumentException $error ) { /* Invalid navigation never changes or authorizes the action. */ } } $finding = self::key( $values, $prefix . 'finding' ); if ( preg_match( '/^f_[a-f0-9]{60}$/D', $finding ) ) { $context['finding'] = $finding; } $page = self::key( $values, $prefix . 'paged' ); if ( preg_match( '/^[1-9][0-9]{0,2}$/D', $page ) && (int) $page <= 800 ) { $context['paged'] = (int) $page; } return $context; } private static function button( string $operation, string $label, string $tab = 'overview', string $id = '', string $style = 'secondary' ): void { self::formStart( $operation, $tab ); if ( '' !== $id ) { echo ''; } submit_button( $label, $style, 'submit', false ); echo '
'; } /** Identifiers are sanitized and must already use their canonical spelling. */ private static function key( array $values, string $name ): string { if ( ! isset( $values[ $name ] ) || ! is_string( $values[ $name ] ) || strlen( $values[ $name ] ) > 128 ) { return ''; } $value = wp_unslash( $values[ $name ] ); $clean = sanitize_key( $value ); return strlen( $clean ) <= 64 && $clean === $value && $clean === $values[ $name ] ? $clean : ''; } private static function text( array $values, string $name, int $maximum, bool $multiline = false ): string { if ( ! isset( $values[ $name ] ) || ! is_string( $values[ $name ] ) || strlen( $values[ $name ] ) > $maximum * 2 ) { return ''; } $value = wp_unslash( $values[ $name ] ); $clean = $multiline ? sanitize_textarea_field( $value ) : sanitize_text_field( $value ); return strlen( $clean ) <= $maximum ? $clean : ''; } private static function page( array $values, string $name, int $maximum ): int { $value = self::key( $values, $name ); return preg_match( '/^[1-9][0-9]{0,5}$/D', $value ) ? min( $maximum, absint( $value ) ) : 1; } private static function email( array $values, string $name ): string { if ( ! isset( $values[ $name ] ) ) { return ''; } if ( ! is_string( $values[ $name ] ) || strlen( $values[ $name ] ) > 512 ) { throw new \InvalidArgumentException( 'Enter a valid notification email address.' ); } $value = trim( wp_unslash( $values[ $name ] ) ); $clean = sanitize_email( $value ); if ( strlen( $clean ) > 254 || $clean !== $value || ( '' !== $clean && ! is_email( $clean ) ) ) { throw new \InvalidArgumentException( 'Enter a valid notification email address.' ); } return $clean; } private static function objectId( array $values ): string { $id = self::key( $values, 'object_id' ); if ( ! preg_match( '/^[a-z0-9_]{1,64}$/D', $id ) ) { throw new \InvalidArgumentException( 'Invalid server-issued record identifier.' ); } return $id; } private static function reviewDate( array $values ): string { if ( ! array_key_exists( 'review_on', $values ) ) { return ''; } if ( ! is_string( $values['review_on'] ) || strlen( $values['review_on'] ) > 10 ) { throw new \InvalidArgumentException( 'Invalid review date.' ); } $value = sanitize_text_field( wp_unslash( $values['review_on'] ) ); if ( $value !== $values['review_on'] ) { throw new \InvalidArgumentException( 'Invalid review date.' ); } return ExceptionReview::date( $value ); } private static function finding( Store $store, string $id ): array { $finding = $store->get( $id ); if ( ! $finding || 'finding' !== $finding['_kind'] ) { throw new \RuntimeException( 'The finding is unavailable. Refresh the list.' ); } return $finding; } public static function handle(): void { $downloadActor = get_current_user_id(); $downloadSite = get_current_blog_id(); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Administrator access is required.', 'shieldlume-security' ), '', array( 'response' => 403 ) ); return; } if ( ! self::postRequest() ) { wp_die( esc_html__( 'A POST request is required.', 'shieldlume-security' ), '', array( 'response' => 403 ) ); return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- The original nonce is read only for type and byte-length guards; sanitize_text_field(wp_unslash()) runs here before the standalone verifier and any requested operation. $nonce = isset( $_POST['_avai_nonce'] ) && is_string( $_POST['_avai_nonce'] ) && strlen( $_POST['_avai_nonce'] ) <= 40 ? sanitize_text_field( wp_unslash( $_POST['_avai_nonce'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Original nonce bytes are used only to reject sanitizer changes; only the bounded sanitized nonce reaches the following standalone verifier. if ( '' === $nonce || strlen( $nonce ) > 20 || $nonce !== wp_unslash( $_POST['_avai_nonce'] ) ) { wp_die( esc_html__( 'Refresh this form and try again.', 'shieldlume-security' ), '', array( 'response' => 403 ) ); return; } if ( ! wp_verify_nonce( $nonce, 'antivirus_ai_action' ) ) { wp_die( esc_html__( 'Refresh this form and try again.', 'shieldlume-security' ), '', array( 'response' => 403 ) ); return; } $operation = self::key( $_POST, 'operation' ); $allowed = array( 'scan', 'tick', 'cancel', 'acknowledge', 'exception', 'remove_exception', 'exception_review', 'rescan', 'export', 'filtered_export', 'settings', 'notify', 'confirm_repository', 'revoke_repository', 'diagnostics', 'diagnostics_refresh', 'retry_schedule', 'false_positive' ); if ( ! in_array( $operation, $allowed, true ) ) { wp_die( esc_html__( 'Unknown operation.', 'shieldlume-security' ), '', array( 'response' => 400 ) ); return; } $tab = self::key( $_POST, 'tab' ); if ( ! in_array( $tab, self::TABS, true ) ) { $tab = 'overview'; } $store = new Store(); $redirect = self::url( $tab, 'findings' === $tab ? self::findingContext( $_POST, 'return_' ) : array() ); $message = ''; $kind = 'success'; try { switch ( $operation ) { case 'scan': $store->prune(); $id = ( new Scanner( $store ) )->start(); Scheduler::scheduleTick( $id ); $message = __( 'A local scan is queued or already active. Continue bounded batches below or allow WP-Cron to resume it.', 'shieldlume-security' ); break; case 'tick': $id = self::objectId( $_POST ); $scan = ( new Scanner( $store ) )->tick( $id, 25 ); if ( in_array( $scan['_status'] ?? $scan['status'] ?? '', array( 'queued', 'running' ), true ) ) { Scheduler::scheduleTick( $id ); } /* translators: %s: persisted scan state. */ $message = sprintf( __( 'Batch request processed. Scan state: %s. Review progress and coverage below.', 'shieldlume-security' ), $scan['_status'] ?? $scan['status'] ?? 'unknown' ); break; case 'cancel': $id = self::objectId( $_POST ); $current = $store->get( $id ); if ( $current && 'scan' === $current['_kind'] && ! in_array( $current['_status'], array( 'queued', 'running' ), true ) ) { $message = __( 'The scan is already terminal. Its evidence and result are unchanged.', 'shieldlume-security' ); break; } ( new Scanner( $store ) )->cancel( $id ); wp_clear_scheduled_hook( 'antivirus_ai_tick', array( $id ) ); $message = __( 'Scan cancelled. Partial evidence is retained; this is not a clean result.', 'shieldlume-security' ); break; case 'acknowledge': $id = self::objectId( $_POST ); $finding = self::finding( $store, $id ); if ( ! in_array( $finding['_status'], array( 'open', 'acknowledged' ), true ) ) { throw new \RuntimeException( 'Only an open finding can be acknowledged.' ); } $finding['acknowledged_at'] = gmdate( 'c' ); $finding['acknowledged_by'] = get_current_user_id(); $store->put( 'finding', $id, $finding, 'acknowledged', $finding['_revision'] ); $store->activity( 'finding_acknowledged', array( 'finding_id' => $id, 'actor' => get_current_user_id(), ) ); $message = __( 'Finding acknowledged. Its evidence and security implications are retained.', 'shieldlume-security' ); break; case 'exception': $id = self::objectId( $_POST ); $finding = self::finding( $store, $id ); $reason = self::text( $_POST, 'reason', 1000, true ); if ( strlen( $reason ) < 10 || strlen( $reason ) > 1000 ) { throw new \RuntimeException( 'An exception needs a reason between 10 and 1,000 bytes.' ); } if ( 'resolved' === $finding['_status'] || empty( $finding['content_hash'] ) ) { throw new \RuntimeException( 'This finding has no active content-bound scope for an exception.' ); } $key = Finding::exceptionKey( $finding ); $record = array( 'key' => $key, 'reason' => $reason, 'finding_id' => $id, 'actor' => get_current_user_id(), 'created_at' => gmdate( 'c' ), 'review_on' => self::reviewDate( $_POST ), 'scope' => array_intersect_key( $finding, array_flip( array( 'location', 'content_hash', 'component_version', 'detector_id', 'detector_version', 'policy_version' ) ) ), ); $store->put( 'exception', 'ex_' . substr( $key, 0, 60 ), $record, 'active' ); $store->put( 'finding', $id, $finding, 'excepted', $finding['_revision'] ); $store->activity( 'exception_created', array( 'finding_id' => $id, 'key' => $key, 'reason' => $reason, 'actor' => get_current_user_id(), ) ); $message = __( 'Reasoned exception saved for this exact content, component, detector, and policy. A meaningful change causes reconsideration.', 'shieldlume-security' ); break; case 'exception_review': $revision = self::key( $_POST, 'exception_revision' ); if ( ! preg_match( '/^[1-9][0-9]{0,9}$/D', $revision ) ) { throw new \InvalidArgumentException( 'Invalid exception revision. Reload the finding.' ); } ExceptionReview::save( $store, self::objectId( $_POST ), self::key( $_POST, 'exception_key' ), (int) $revision, self::reviewDate( $_POST ) ); $context = self::findingContext( $_POST, 'return_' ); $redirect = self::url( 'findings', $context ?: array( 'finding' => self::objectId( $_POST ) ) ); $message = __( 'Review date saved locally. The exception, finding status and protection settings are unchanged.', 'shieldlume-security' ); break; case 'remove_exception': $id = self::objectId( $_POST ); $finding = self::finding( $store, $id ); $key = Finding::exceptionKey( $finding ); $store->delete( 'ex_' . substr( $key, 0, 60 ) ); if ( 'excepted' === $finding['_status'] ) { $store->put( 'finding', $id, $finding, 'open', $finding['_revision'] ); } $store->activity( 'exception_removed', array( 'finding_id' => $id, 'actor' => get_current_user_id(), ) ); $message = __( 'Exception removed. The finding needs review again.', 'shieldlume-security' ); break; case 'rescan': $id = ( new Scanner( $store ) )->rescanFinding( self::objectId( $_POST ) ); Scheduler::scheduleTick( $id ); $redirect = self::url( 'overview' ); $message = __( 'Rescan queued. New observations will reconsider the finding.', 'shieldlume-security' ); break; case 'export': nocache_headers(); self::downloadAuthority( $downloadActor, $downloadSite ); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="antivirus-ai-findings.json"' ); header( 'X-Content-Type-Options: nosniff' ); self::streamExport( $store, $downloadActor, $downloadSite ); exit; case 'filtered_export': nocache_headers(); $json = FindingExport::prepare( $store, self::text( $_POST, 'export_selection', 2048 ), self::key( $_POST, 'export_signature' ) ); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="shieldlume-filtered-findings.json"' ); header( 'X-Content-Type-Options: nosniff' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Complete bounded JSON is encoded and authorized by FindingExport::prepare; HTML escaping would corrupt this JSON attachment. echo $json; exit; case 'diagnostics': self::download( 'antivirus-ai-diagnostics.json', Diagnostics::bundle( $store ), $downloadActor, $downloadSite ); exit; case 'false_positive': $data = Diagnostics::falsePositive( self::finding( $store, self::objectId( $_POST ) ), '1' === self::key( $_POST, 'evidence_consent' ) ); self::download( 'antivirus-ai-false-positive.json', $data, $downloadActor, $downloadSite ); exit; case 'diagnostics_refresh': Diagnostics::checks( $store ); $message = __( 'Local setup checks reran. Review current observations and remedies below; no external data was submitted.', 'shieldlume-security' ); break; case 'retry_schedule': $settings = get_option( 'antivirus_ai_settings', array() ); if ( empty( $settings['daily'] ) ) { throw new \RuntimeException( 'Automatic scanning is off. Enable it in Settings or start a manual local scan.' ); } Scheduler::settingsChanged( $settings ); Scheduler::daily(); $active = ( new Scanner( $store ) )->latest(); if ( $active && in_array( $active['_status'], array( 'queued', 'running' ), true ) ) { Scheduler::tick( $active['_id'] ); } $message = __( 'The enabled schedule was recreated and one bounded scan batch was requested. Review actual progress and any recorded error.', 'shieldlume-security' ); break; case 'settings': self::saveSettings( $store ); $message = __( 'Settings saved. External checks run only during explicitly requested or scheduled jobs.', 'shieldlume-security' ); break; case 'confirm_repository': case 'revoke_repository': self::repositoryAction( $operation, $store ); $redirect = self::url( 'settings' ); $message = __( 'Repository identity decision saved. Start another scan to use the current identity scope.', 'shieldlume-security' ); break; case 'notify': Notifications::process(); $message = __( 'A bounded notification batch was processed. Accepted by wp_mail does not establish inbox delivery.', 'shieldlume-security' ); break; } } catch ( \Throwable $error ) { $message = substr( $error->getMessage(), 0, 1200 ); $kind = 'error'; } set_transient( 'antivirus_ai_notice_' . get_current_user_id(), array( 'kind' => $kind, 'message' => $message, ), 120 ); wp_safe_redirect( $redirect ); exit; } private static function saveSettings( Store $store ): void { $old = array(); $settings = array(); foreach ( array( 'online_references', 'vulnerability_feed', 'daily', 'email', 'login_limit' ) as $key ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This private helper runs only after handle() verifies administrator authority and the shared action nonce; reject malformed checkbox values before any settings write. if ( array_key_exists( $key, $_POST ) && ( ! is_string( $_POST[ $key ] ) || '1' !== self::key( $_POST, $key ) ) ) { throw new \InvalidArgumentException( 'A preference contains an invalid value. Reload Protection settings and try again. Your saved preferences are unchanged.' ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This private helper runs only after handle() verifies administrator authority and the shared antivirus_ai_action POST nonce; values are validated before mutation. $settings[ $key ] = '1' === self::key( $_POST, $key ); } $settings['telemetry'] = false; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This private helper runs only after handle() verifies administrator authority and the shared antivirus_ai_action POST nonce; values are validated before mutation. $email = self::email( $_POST, 'email_to' ); if ( $settings['email'] && ! is_email( $email ) ) { throw new \RuntimeException( 'Enter a valid notification email address.' ); } $settings['email_to'] = $email; if ( $settings['login_limit'] && LoginLimiter::conflicts() ) { throw new \RuntimeException( 'Overlapping security plugin detected. Disable its overlapping login protection or leave Shieldlume Security throttling off; coexistence needs manual verification.' ); } $changes = $settings; $settings = FeedConnection::saveSettings( static function ( array $current ) use ( $changes, &$old ): array { $old = $current; return array_replace( $current, $changes ); } ); if ( (bool) ( $old['daily'] ?? false ) !== $settings['daily'] || ( $settings['daily'] && ! wp_next_scheduled( 'antivirus_ai_daily' ) ) ) { Scheduler::settingsChanged( $settings ); } if ( ! $settings['email'] ) { wp_clear_scheduled_hook( 'antivirus_ai_notify' ); } $store->activity( 'settings_changed', array( 'actor' => get_current_user_id(), 'online_references' => $settings['online_references'], 'vulnerability_feed' => $settings['vulnerability_feed'], 'daily' => $settings['daily'], 'email' => $settings['email'], 'login_limit' => $settings['login_limit'], ) ); do_action( 'antivirus_ai_settings_saved', $settings ); } public static function render(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Administrator access is required.', 'shieldlume-security' ), '', array( 'response' => 403 ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Exact administrator-only view navigation; choices use POST. The optional review card records only a bounded local display count. $tab = self::key( $_GET, 'tab' ); if ( ! in_array( $tab, self::TABS, true ) ) { $tab = Onboarding::needed() ? 'setup' : 'overview'; } if ( in_array( $tab, array( 'welcome', 'welcome-next' ), true ) ) { Welcome::render( 'welcome-next' === $tab ); return; } $labels = array( 'overview' => __( 'Dashboard', 'shieldlume-security' ), 'findings' => __( 'Findings', 'shieldlume-security' ), 'protection' => __( 'Protection', 'shieldlume-security' ), 'activity' => __( 'Activity', 'shieldlume-security' ), 'settings' => __( 'Protection settings', 'shieldlume-security' ), 'setup' => __( 'Setup guide', 'shieldlume-security' ), 'pro-automation' => __( 'Automation', 'shieldlume-security' ), 'pro-investigations' => __( 'Investigations & AI', 'shieldlume-security' ), 'pro-reports' => __( 'Reports', 'shieldlume-security' ), 'pro' => __( 'Explore Pro', 'shieldlume-security' ), 'more-plugins' => __( 'More Plugins', 'shieldlume-security' ), 'help' => __( 'Help & diagnostics', 'shieldlume-security' ), 'data' => __( 'Data & uninstall', 'shieldlume-security' ), ); Experience::open( $tab, $labels ); $notice = get_transient( 'antivirus_ai_notice_' . get_current_user_id() ); if ( is_array( $notice ) ) { delete_transient( 'antivirus_ai_notice_' . get_current_user_id() ); $welcome_next_notice = false; if ( 'overview' === $tab && 'success' === ( $notice['kind'] ?? '' ) && is_string( $notice['message'] ?? null ) && '' !== $notice['message'] ) { try { $actor = get_current_user_id(); $welcome_next_notice = 'next' === get_transient( 'antivirus_ai_welcome_popup_' . $actor ) && $notice['message'] === get_transient( 'antivirus_ai_welcome_next_notice_' . $actor ); } catch ( \Throwable $error ) { /* Keep the ordinary notice if optional presentation state is unavailable. */ } } // Keep the normal fallback until the matching dialog successfully opens with its own status message. echo '

' . esc_html( $notice['message'] ?? '' ) . '

'; } try { $store = new Store(); switch ( $tab ) { case 'pro-automation': case 'pro-investigations': case 'pro-reports': ProPreviews::render( $tab ); break; case 'more-plugins': MorePlugins::render(); break; case 'data': DataRemoval::render(); break; case 'setup': Onboarding::render(); break; case 'overview': Welcome::dashboard(); ExceptionReview::reminders( $store ); Experience::overview( $store ); ReviewPrompt::render( $store ); break; case 'protection': SecurityCenter::render(); break; case 'findings': Experience::intro( __( 'EVIDENCE, NOT GUESSWORK', 'shieldlume-security' ), __( 'A finding. A clearer next step.', 'shieldlume-security' ), __( 'Explore what was observed, why it matters, and what you can do about it.', 'shieldlume-security' ) ); self::findings( $store ); break; case 'activity': Experience::intro( __( 'YOUR SITE’S PAPER TRAIL', 'shieldlume-security' ), __( 'Every move, in view.', 'shieldlume-security' ), __( 'Your scans, findings and decisions, recorded on your own site.', 'shieldlume-security' ) ); self::activity( $store ); break; case 'settings': Experience::intro( __( 'BUILT AROUND YOUR CHOICES', 'shieldlume-security' ), __( 'Protection, on your terms.', 'shieldlume-security' ), __( 'Choose what runs, what connects, and how you hear about it.', 'shieldlume-security' ) ); self::settings(); break; case 'pro': Experience::intro( __( 'OPTIONAL PRO ADD-ON', 'shieldlume-security' ), __( 'For sites you maintain regularly.', 'shieldlume-security' ), __( 'Check a changing site more often, organize investigations and share a useful record of the work.', 'shieldlume-security' ) ); Experience::editions(); Journey::renderDetails(); echo '
' . esc_html__( 'Compare included tools, price and support', 'shieldlume-security' ) . ''; self::editions(); echo '
'; break; case 'help': Experience::intro( __( 'A LITTLE GUIDANCE GOES A LONG WAY', 'shieldlume-security' ), __( 'Let’s get you unstuck.', 'shieldlume-security' ), __( 'Practical answers, real setup checks, and a clear next action.', 'shieldlume-security' ) ); Experience::help(); ReviewPrompt::help(); self::diagnostics( $store, true ); break; } } catch ( \Throwable $error ) { echo '

' . esc_html__( 'The requested view is unavailable. No clean result can be inferred.', 'shieldlume-security' ) . ' ' . esc_html( $error->getMessage() ) . '

'; } Experience::close( $labels ); } private static function filters(): array { $filters = array(); foreach ( array( 'category', 'severity', 'confidence', 'status', 'scan_id' ) as $key ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only administrator filters or pagination use bounded sanitized values; these inputs neither change records nor authorize actions. $value = self::key( $_GET, $key ); if ( '' !== $value ) { $filters[ $key ] = substr( $value, 0, 64 ); } } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only administrator filters or pagination use bounded sanitized values; these inputs neither change records nor authorize actions. $search = self::text( $_GET, 'search', 200 ); if ( '' !== $search ) { $filters['search'] = substr( $search, 0, 200 ); } return $filters; } private static function findings( Store $store ): void { echo '

' . esc_html__( 'Review your findings', 'shieldlume-security' ) . '

' . esc_html__( 'Start with a finding below. Review what it means, then choose an action; nothing is repaired automatically.', 'shieldlume-security' ) . '

'; do_action( 'antivirus_ai_findings_extensions', $store ); $filters = self::filters(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only exact finding selection; export authorization still uses a signed scope, administrator capability and POST nonce. $selectedId = self::key( $_GET, 'finding' ); echo '
'; if ( isset( $filters['scan_id'] ) ) { echo ''; } $choices = array( 'status' => array( 'open' => __( 'Open', 'shieldlume-security' ), 'acknowledged' => __( 'Reviewed', 'shieldlume-security' ), 'excepted' => __( 'Accepted exception', 'shieldlume-security' ), 'resolved' => __( 'Resolved', 'shieldlume-security' ) ), 'severity' => array( 'critical' => __( 'Critical', 'shieldlume-security' ), 'high' => __( 'High', 'shieldlume-security' ), 'medium' => __( 'Medium', 'shieldlume-security' ), 'low' => __( 'Low', 'shieldlume-security' ), 'info' => __( 'Informational', 'shieldlume-security' ) ), 'confidence' => array( 'confirmed' => __( 'Confirmed', 'shieldlume-security' ), 'strong' => __( 'Strong', 'shieldlume-security' ), 'suspicious' => __( 'Suspicious', 'shieldlume-security' ), 'informational' => __( 'Informational', 'shieldlume-security' ) ), 'category' => array( 'known_malware' => __( 'Known malware', 'shieldlume-security' ), 'suspicious_behavior' => __( 'Suspicious behavior', 'shieldlume-security' ), 'integrity_change' => __( 'Integrity change', 'shieldlume-security' ), 'vulnerable_software' => __( 'Vulnerable software', 'shieldlume-security' ), 'configuration' => __( 'Configuration', 'shieldlume-security' ) ), ); $filterLabels = array( 'status' => array( __( 'Finding status', 'shieldlume-security' ), __( 'All statuses', 'shieldlume-security' ) ), 'severity' => array( __( 'Severity', 'shieldlume-security' ), __( 'All severities', 'shieldlume-security' ) ), 'confidence' => array( __( 'Confidence', 'shieldlume-security' ), __( 'All confidence levels', 'shieldlume-security' ) ), 'category' => array( __( 'Category', 'shieldlume-security' ), __( 'All categories', 'shieldlume-security' ) ), ); foreach ( $choices as $key => $values ) { echo ' '; } submit_button( __( 'Filter', 'shieldlume-security' ), 'secondary', '', false ); echo '
'; echo '
' . esc_html__( 'Download findings for your records', 'shieldlume-security' ) . ''; try { $fields = FindingExport::fields( $filters, $selectedId ); self::formStart( 'filtered_export', 'findings' ); foreach ( $fields as $name => $value ) { echo ''; } submit_button( __( 'Export this filtered view (JSON)', 'shieldlume-security' ), 'secondary', 'submit', false ); echo '

' . esc_html__( 'Downloads current matching records across all result pages, or only the selected finding. Limited to 1,000 records and 8 MiB, with criteria, counts and any truncation recorded. It is not a historical scan snapshot.', 'shieldlume-security' ) . '

'; } catch ( \Throwable $error ) { echo '

' . esc_html__( 'This view cannot be exported. Reload with valid filters or choose an available finding.', 'shieldlume-security' ) . '

'; } self::button( 'export', __( 'Export all retained findings (JSON)', 'shieldlume-security' ), 'findings' ); echo '

' . esc_html__( 'Exports contain sensitive paths, hashes, and evidence. Protect the downloaded file. Exports are bounded to 20,000 findings and identify any truncation. If access or storage changes during a download, it stops with complete:false; earlier bytes cannot be recalled. Discard that partial file and request a new export.', 'shieldlume-security' ) . '

'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only administrator filters or pagination use bounded sanitized values; these inputs neither change records nor authorize actions. $page = self::page( $_GET, 'paged', 800 ); $result = $store->searchFindings( $filters, 25, ( $page - 1 ) * 25 ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only finding selection requires administrator access, exact finding-ID grammar and an existing finding record; it grants no mutation authority. if ( '' !== $selectedId ) { $selectedFinding = preg_match( '/^f_[a-f0-9]{60}$/D', $selectedId ) ? $store->get( $selectedId ) : null; $validFinding = $selectedFinding && 'finding' === ( $selectedFinding['_kind'] ?? '' ) && FindingGuide::id( $selectedFinding ) === $selectedId; $result = array( 'items' => $validFinding ? array( $selectedFinding ) : array(), 'total' => $validFinding ? 1 : 0 ); $page = 1; echo '

' . esc_html( $validFinding ? __( 'Showing the selected retained finding.', 'shieldlume-security' ) : __( 'That finding is unavailable. It may have been removed or its reference may be invalid.', 'shieldlume-security' ) ) . ' ' . esc_html__( 'Browse all findings', 'shieldlume-security' ) . '

'; } FindingReview::render( $result['items'], ( new Scanner( $store ) )->latest() ); /* translators: %d: number of findings matching the selected filters. */ echo '

' . esc_html( sprintf( __( '%d matching findings', 'shieldlume-security' ), $result['total'] ) ) . '

'; $reviewIds = array(); foreach ( $result['items'] as $item ) { if ( in_array( $item['_status'] ?? '', array( 'open', 'acknowledged' ), true ) && count( $reviewIds ) < 20 ) { $reviewIds[] = $item['_id']; } } if ( count( $reviewIds ) > 1 ) { if ( Journey::proUnlocked() ) { Journey::requestButton( 'investigations', __( 'Open investigations for these findings', 'shieldlume-security' ), $reviewIds ); } else { ProPreviews::invitation( 'investigations' ); } } if ( ! $result['items'] ) { echo '
' . wp_kses( Experience::icon( 'shield' ), Experience::markupTags() ) . '

' . esc_html__( 'Nothing matches this view.', 'shieldlume-security' ) . '

' . esc_html__( 'Try a different filter or run a scan. An empty list alone does not establish complete or clean coverage.', 'shieldlume-security' ) . '

' . esc_html__( 'Back to your dashboard', 'shieldlume-security' ) . '
'; } foreach ( $result['items'] as $finding ) { self::findingCard( $finding, $store ); } self::pagination( 'findings', $page, (int) $result['total'], 25, $filters ); } private static function findingCard( array $finding, Store $store ): void { $id = $finding['_id']; echo '

' . esc_html( $finding['location'] ) . '

' . esc_html( $finding['severity'] ) . ' · ' . esc_html( $finding['confidence'] ) . ' · ' . esc_html( str_replace( '_', ' ', $finding['category'] ) ) . ' · ' . esc_html( FindingGuide::statusLabel( $finding['_status'] ?? null ) ) . '

' . esc_html( $finding['reason'] ) . '

'; echo '
' . esc_html__( 'Recommended action', 'shieldlume-security' ) . '
' . esc_html( $finding['recommended_action'] ) . '
' . esc_html__( 'Observed', 'shieldlume-security' ) . '
' . esc_html( $finding['observed_at'] ) . '
' . esc_html__( 'Detector and rule version', 'shieldlume-security' ) . '
' . esc_html( $finding['detector_id'] . ' / ' . $finding['detector_version'] ) . '
'; self::details( __( 'Full evidence and limits', 'shieldlume-security' ), array_intersect_key( $finding, array_flip( array( 'id', 'evidence', 'limitations', 'source', 'scope', 'rule_freshness', 'content_hash', 'component_version', 'policy_version', 'action_eligibility', 'first_seen', 'history' ) ) ) ); ContentReview::link( $finding ); if ( in_array( $finding['_status'], array( 'open', 'acknowledged' ), true ) ) { self::button( 'acknowledge', __( 'Mark as reviewed', 'shieldlume-security' ), 'findings', $id ); echo '

' . esc_html__( 'Marking it reviewed keeps the finding and evidence; it does not resolve the issue.', 'shieldlume-security' ) . '

'; } self::button( 'rescan', __( 'Check this finding again', 'shieldlume-security' ), 'findings', $id ); echo '
' . esc_html__( 'Prepare a false-positive report', 'shieldlume-security' ) . '

' . esc_html__( 'Preview the additional detector, version and hash evidence below. Hashes can identify content. No source code, paths, database contents or credentials are included, and nothing is submitted automatically.', 'shieldlume-security' ) . '

'; self::details( __( 'Preview additional evidence', 'shieldlume-security' ), Diagnostics::falsePositive( $finding, true ) ); self::formStart( 'false_positive', 'findings' ); echo '

'; submit_button( __( 'Download selected report evidence', 'shieldlume-security' ), 'secondary', 'submit', false ); echo '
'; if ( 'excepted' === $finding['_status'] ) { ExceptionReview::render( $store, $finding ); self::button( 'remove_exception', __( 'Remove this exception', 'shieldlume-security' ), 'findings', $id ); } elseif ( 'resolved' !== $finding['_status'] && ! empty( $finding['content_hash'] ) ) { echo '
' . esc_html__( 'Accept this exact finding', 'shieldlume-security' ) . '

' . esc_html__( 'Use this only after checking the evidence. The exception applies to this exact content and rule; relevant changes bring it back for review.', 'shieldlume-security' ) . '

'; self::formStart( 'exception', 'findings' ); echo '


'; ExceptionReview::field( 'review-' . $id ); submit_button( __( 'Save exception', 'shieldlume-security' ), 'secondary', 'submit', false ); echo '
'; } $advisory = $finding['source']['advisory_url'] ?? ''; if ( is_string( $advisory ) && str_starts_with( $advisory, 'https://' ) ) { echo '

' . esc_html__( 'Open source advisory (external site)', 'shieldlume-security' ) . '

'; } do_action( 'antivirus_ai_finding_actions', $finding ); echo '
'; } private static function activity( Store $store ): void { echo '

' . esc_html__( 'Scan activity and notifications', 'shieldlume-security' ) . '

'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only administrator filters or pagination use bounded sanitized values; these inputs neither change records nor authorize actions. $page = self::page( $_GET, 'paged', 10000 ); echo '
'; Experience::events( $store->list( 'activity', null, 25, ( $page - 1 ) * 25 ), true ); echo '
'; ProPreviews::invitation( 'reports' ); self::pagination( 'activity', $page, $store->count( 'activity' ), 25 ); do_action( 'antivirus_ai_activity_extensions', $store ); echo '

' . esc_html__( 'Notification attempts', 'shieldlume-security' ) . '

' . esc_html__( 'Accepted means wp_mail accepted the send request; inbox delivery is unverified. Attempting or unknown means delivery may be uncertain and is not retried automatically. Review your mail provider before requesting another test. Suppressed means permission, recipient or evidence changed before a guarded handoff.', 'shieldlume-security' ) . '

'; $queueWarning = get_option( 'antivirus_ai_notification_warning', array() ); if ( ! empty( $queueWarning['message'] ) ) { echo '

' . esc_html( $queueWarning['message'] ) . '

'; } self::button( 'notify', __( 'Process pending notification batch', 'shieldlume-security' ), 'activity' ); foreach ( $store->list( 'notification', null, 20 ) as $notification ) { self::details( ( $notification['_status'] ?? '' ) . ' · ' . ( $notification['type'] ?? '' ), $notification ); } } private static function settingsOption( string $key, array $options, array $settings ): void { [$label, $description] = $options[ $key ]; echo '


' . esc_html( $description ) . '

'; } private static function settings(): void { $settings = get_option( 'antivirus_ai_settings', array() ); echo '

' . esc_html__( 'Scan and alert settings', 'shieldlume-security' ) . '

' . esc_html__( 'Start with scans, sign-in limits and alerts. Online checks are optional and have separate permissions below.', 'shieldlume-security' ) . '

'; $conflicts = LoginLimiter::conflicts(); if ( $conflicts ) { echo '

' . esc_html__( 'Potential overlapping login protection:', 'shieldlume-security' ) . ' ' . esc_html( implode( ', ', $conflicts ) ) . '. ' . esc_html__( 'Shieldlume Security login throttling is suspended while these plugins are active.', 'shieldlume-security' ) . '

'; } $warning = get_option( 'antivirus_ai_login_warning', array() ); if ( ! empty( $warning['message'] ) ) { echo '

' . esc_html__( 'Last throttling warning:', 'shieldlume-security' ) . ' ' . esc_html( $warning['message'] ) . '

'; } echo '
'; self::formStart( 'settings', 'settings' ); $options = array( 'online_references' => array( __( 'Check files against official originals', 'shieldlume-security' ), __( 'During scans, request official core/plugin checksums and inspect exact-version theme ZIPs locally without extracting or running their code. WordPress.org receives your network address, request timing, requested component/version and core locale, with a fixed Shieldlume user-agent. Pro can also retrieve originals for separately approved repairs. Custom and premium code without a legitimate reference remains unverified.', 'shieldlume-security' ) ), 'vulnerability_feed' => array( __( 'Check for known software vulnerabilities', 'shieldlume-security' ), __( 'During scans, download the configured vulnerability feed and compare installed names and versions locally. The service receives its access key and your network address. Connect permitted feed access in Protection; this checkbox alone does not configure it.', 'shieldlume-security' ) ), 'daily' => array( __( 'Schedule a daily scan', 'shieldlume-security' ), __( 'Check daily using WordPress scheduling. Site traffic and your host affect timing; delays appear in Dashboard. Turning this off does not cancel a scan already running.', 'shieldlume-security' ) ), 'email' => array( __( 'Queue critical alerts and a daily scan digest', 'shieldlume-security' ), __( 'Email essential findings to the address below through your site’s mail service. Send attempts and errors are recorded; delivery to your inbox cannot be guaranteed.', 'shieldlume-security' ) ), 'login_limit' => array( __( 'Limit repeated login attempts', 'shieldlume-security' ), __( 'Temporarily limit sign-ins after 10 failed attempts per account or 30 per network in 15 minutes. Shared networks can affect legitimate users. Identifiers are stored as expiring salted hashes; IPv6 uses /64 networks.', 'shieldlume-security' ) ), ); echo '

' . esc_html__( 'Everyday settings', 'shieldlume-security' ) . '

'; foreach ( array( 'daily', 'login_limit', 'email' ) as $key ) { self::settingsOption( $key, $options, $settings ); if ( 'daily' === $key ) { ProPreviews::invitation( 'scheduling', true ); } } echo '


'; $online = ! empty( $settings['online_references'] ) || ! empty( $settings['vulnerability_feed'] ); echo '
' . esc_html__( 'Optional online checks', 'shieldlume-security' ) . ' · ' . esc_html( $online ? __( 'Review saved permissions', 'shieldlume-security' ) : __( 'Off unless selected', 'shieldlume-security' ) ) . '

' . esc_html__( 'These checks contact the services described below. Closing this section leaves the saved checkboxes selected; clear a checkbox and save to withdraw its permission.', 'shieldlume-security' ) . '

'; foreach ( array( 'online_references', 'vulnerability_feed' ) as $key ) { self::settingsOption( $key, $options, $settings ); } echo '

' . esc_html__( 'Connect or review vulnerability-feed access', 'shieldlume-security' ) . '

'; echo '
' . esc_html__( 'Advanced operating and recovery guidance', 'shieldlume-security' ) . '

' . esc_html__( 'Recover from login throttling by waiting 15 minutes or setting ANTIVIRUS_AI_DISABLE_LOGIN_LIMIT to true in trusted server configuration. Forwarded headers are ignored unless ANTIVIRUS_AI_TRUSTED_PROXIES lists exact trusted proxy IPs; the rightmost untrusted validated address is used.', 'shieldlume-security' ) . '

' . esc_html__( 'For reliable scheduling, have the host run wp cron event run --due-now and the documented bounded WP-CLI scan commands as an administrator. Disable WP-Cron page-load spawning only after the host schedule is verified.', 'shieldlume-security' ) . '

' . esc_html__( 'Open findings and recovery journals are retained. Routine scan/activity/notification history expires after 30 days by default, with bounded cleanup. Deactivation and ordinary uninstall retain evidence and recovery. Optional installation sharing and newsletter signup stay off until selected in Welcome choices. They are independent of these security settings.', 'shieldlume-security' ) . '

'; submit_button( __( 'Save settings', 'shieldlume-security' ) ); echo ''; ProPreviews::finishInlineForms(); if ( Journey::proUnlocked() ) { Journey::requestButton( 'scheduling', __( 'Open scheduled checks', 'shieldlume-security' ) ); } echo '
'; ProPreviews::preferences(); echo '
'; self::repositorySettings(); echo '
'; do_action( 'antivirus_ai_settings_extra', $settings ); echo '
'; Welcome::settings(); echo '

' . esc_html__( 'Data & uninstall', 'shieldlume-security' ) . '

' . esc_html__( 'Choose what stays on this site if you delete the plugin. Keeping your data is the default; removal requires a separate review and confirmation.', 'shieldlume-security' ) . '

' . esc_html__( 'Review uninstall choices', 'shieldlume-security' ) . '
'; } private static function downloadAuthority( int $actor, int $site ): void { if ( ! WorkflowAuthority::administrator() || $actor !== get_current_user_id() || $site !== get_current_blog_id() ) { throw new \RuntimeException( 'Export access changed. Sign in with current administrator access and request a new download.' ); } } private static function download( string $filename, array $data, int $actor, int $site ): void { if ( ! in_array( $filename, array( 'antivirus-ai-diagnostics.json', 'antivirus-ai-false-positive.json' ), true ) ) { throw new \RuntimeException( 'Unsupported diagnostic download.' ); } $json = wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE ); if ( ! is_string( $json ) ) { throw new \RuntimeException( 'The diagnostic download could not be encoded.' ); } nocache_headers(); self::downloadAuthority( $actor, $site ); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header( 'X-Content-Type-Options: nosniff' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Authorized JSON attachment; HTML escaping would corrupt the encoded diagnostic document. echo $json; } private static function diagnostics( Store $store, bool $preview ): void { echo '

' . esc_html__( 'Check this installation', 'shieldlume-security' ) . '

' . esc_html__( 'See what needs attention on this host. Ready means the local setup check passed; it does not mean the site is free of threats.', 'shieldlume-security' ) . '

' . esc_html__( 'Current checks and remedies', 'shieldlume-security' ) . ''; $timing = Scheduler::diagnostics(); $scans = ( new Scanner( $store ) )->summary(); echo '
' . esc_html__( 'What actually ran · scan timing', 'shieldlume-security' ) . '
'; $times = array( __( 'Automatic routine', 'shieldlume-security' ) => ! empty( $timing['enabled'] ) ? __( 'Enabled', 'shieldlume-security' ) : __( 'Off', 'shieldlume-security' ), __( 'Latest schedule attempt', 'shieldlume-security' ) => ! empty( $timing['last_run'] ) ? gmdate( 'Y-m-d H:i:s', $timing['last_run'] ) . ' UTC' : __( 'Not recorded', 'shieldlume-security' ), __( 'Associated scan', 'shieldlume-security' ) => str_replace( '_', ' ', $timing['last_scan_status'] ?? 'not_recorded' ), __( 'Latest completed broad scan', 'shieldlume-security' ) => $scans['last_successful']['completed_at'] ?? __( 'Not recorded', 'shieldlume-security' ), __( 'Last attempt delay', 'shieldlume-security' ) => (int) ( $timing['last_delay_seconds'] ?? 0 ) . ' seconds', __( 'Pending retry', 'shieldlume-security' ) => ! empty( $timing['retry_due'] ) ? gmdate( 'Y-m-d H:i:s', $timing['retry_due'] ) . ' UTC' : __( 'None recorded', 'shieldlume-security' ), ); foreach ( $times as $label => $value ) { echo '
' . esc_html( $label ) . '
' . esc_html( $value ) . '
'; } echo '
'; if ( ! empty( $timing['overlap_deferred'] ) ) { echo '

' . esc_html__( 'A targeted check was already active. The scheduled broad scan remains due and will retry after that work.', 'shieldlume-security' ) . '

'; } echo '

' . esc_html__( 'A wake-up or resumed job is not a completed scan. Review coverage and the recorded completion time. WordPress cron can be delayed by low traffic.', 'shieldlume-security' ) . '

'; self::button( 'diagnostics_refresh', __( 'Recheck local setup', 'shieldlume-security' ), $preview ? 'help' : 'overview' ); self::button( 'retry_schedule', __( 'Resume scheduled work', 'shieldlume-security' ), 'overview' ); if ( $preview ) { echo '

' . esc_html__( 'The download rechecks the same allowlisted fields shown in this preview. It excludes source code, database contents, credentials, customer records, site addresses, raw error messages and absolute server paths. Nothing is automatically submitted.', 'shieldlume-security' ) . '

'; self::details( __( 'Preview redacted diagnostic bundle', 'shieldlume-security' ), Diagnostics::bundle( $store ) ); self::button( 'diagnostics', __( 'Download redacted diagnostics', 'shieldlume-security' ), 'settings' ); } else { echo '

' . esc_html__( 'Preview and download redacted diagnostics', 'shieldlume-security' ) . '

'; } echo '
'; } private static function editions(): void { echo '

' . esc_html__( 'Free and Pro features', 'shieldlume-security' ) . '

'; foreach ( array( array( 'Local detection and complete findings', 'Manual and daily scans, source-covered vulnerability and integrity checks', 'The same detection foundation, with investigation groups and review guidance' ), array( 'Recovery workflow', 'Finding evidence, native WordPress editing links and rescanning', 'Eligible verified file repairs, protected recovery and reviewed batches with separate approval' ), array( 'Scan schedules', 'Manual and daily scans, actual progress and missed-run checks', 'Flexible scan schedules and checks after supported changes; timing depends on WordPress cron' ), array( 'History and reports', 'Existing exports and 30-day routine retention; findings and recovery retained', '90-day default routine history, preserved custom settings, incident reports and opt-in daily local summaries' ), array( 'Explanations', 'Deterministic evidence and local guidance', 'Local investigation guidance and optional AI explanations through the separate Pro add-on and its service' ), ) as $row ) { echo ''; } echo '
' . esc_html__( 'Capability', 'shieldlume-security' ) . 'FREEPRO
' . esc_html( $row[0] ) . '' . esc_html( $row[1] ) . '' . esc_html( $row[2] ) . '
' . esc_html__( 'Annual price, renewal & site allowance', 'shieldlume-security' ) . '

' . esc_html( Product::priceLabel() ) . '. ' . esc_html__( 'The Pro plan is billed annually at $149.90 USD per year, plus applicable tax, for one production website and one explicitly paired staging installation. It renews annually until cancelled. Installing either plugin does not start a subscription. Review the current offer and purchase terms on antivirus.ai; your accepted account terms remain authoritative.', 'shieldlume-security' ) . '

' . esc_html__( 'Optional services & availability', 'shieldlume-security' ) . '

' . esc_html__( 'The separate Pro add-on needs a successful online licence activation before its local tools unlock. Earned local access continues offline and after subscription expiry; explicit local Disconnect removes it. Online references, connected services, updates and downloads retain their separate access and permission requirements. Optional AI explanations require an available service, eligible account and separate permission. New Pro purchases include 10 completed AI explanations per UTC day and 300 per UTC month, shared with paired staging. Failed attempts and saved-answer retrieval do not use that allowance; separate attempt limits apply. Free contains no paid AI implementation.', 'shieldlume-security' ) . '

' . esc_html__( 'Existing local tools and recovery remain available after service expiry. Local detection and recovery have no cloud quota. Scheduled changed-content checks still read and hash eligible files; skipped or unsupported items remain visible as coverage limits.', 'shieldlume-security' ) . '

' . esc_html__( 'Self-service support & reporting channels', 'shieldlume-security' ) . '

' . esc_html__( 'Documentation, guided diagnostics and asynchronous billing, bug, false-positive and vulnerability-report channels support this self-service model. No live chat, calls, human malware triage, managed cleanup or response-time guarantee is included.', 'shieldlume-security' ) . '

'; $labels = array( 'billing' => 'Billing issues', 'bugs' => 'Product bugs', 'false_positive' => 'False-positive reports', 'security' => 'Vulnerability disclosures' ); $channels = Product::channels(); foreach ( $labels as $key => $label ) { if ( ! empty( $channels[ $key ] ) ) { echo '

' . esc_html( $label ) . '

'; } } if ( count( array_filter( $channels ) ) < 4 ) { echo '

' . esc_html__( 'A reporting destination is unavailable. Keep your redacted report locally and use a listed contact channel for help; nothing is sent automatically.', 'shieldlume-security' ) . '

'; } echo '
'; } private static function repositoryComponents( array $inventory ): array { $items = array(); foreach ( array( 'plugin' => 'plugins', 'theme' => 'themes', ) as $type => $collection ) { foreach ( array_slice( $inventory[ $collection ] ?? array(), 0, 1000 ) as $component ) { $key = $type . ':' . ( 'plugin' === $type ? $component['file'] : $component['slug'] ); $token = hash( 'sha256', $key . '|' . $component['name'] . '|' . $component['version'] ); $items[] = array( 'type' => $type, 'key' => $key, 'token' => $token, 'component' => $component, ); } } return $items; } private static function repositoryAction( string $operation, Store $store ): void { if ( ! class_exists( RepositoryIntegrity::class ) ) { throw new \RuntimeException( 'Repository verification is unavailable.' ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This private helper runs only after handle() verifies administrator authority and the shared antivirus_ai_action POST nonce; values are validated before mutation. $token = self::key( $_POST, 'component_id' ); if ( ! preg_match( '/^[a-f0-9]{64}$/D', $token ) ) { throw new \RuntimeException( 'Invalid server-owned component identifier.' ); } $owner = $store->lock( 'repository_identities', 60 ); if ( ! $owner ) { throw new \RuntimeException( 'Another repository identity decision is being saved. Reload and retry.' ); } try { $confirmations = Store::freshOption( 'antivirus_ai_repository_confirmations', array() ); if ( ! is_array( $confirmations ) ) { throw new \RuntimeException( 'Saved repository identity decisions cannot be read. Restore them before changing trust.' ); } if ( 'revoke_repository' === $operation ) { foreach ( $confirmations as $key => $record ) { if ( hash_equals( hash( 'sha256', $key ), $token ) ) { unset( $confirmations[ $key ] ); Store::saveOption( 'antivirus_ai_repository_confirmations', $confirmations ); $store->activity( 'repository_identity_revoked', array( 'component' => $key, 'actor' => get_current_user_id(), ) ); return; } } throw new \RuntimeException( 'The recorded repository identity is no longer present.' ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This private helper runs only after handle() verifies administrator authority and the shared antivirus_ai_action POST nonce; values are validated before mutation. if ( '1' !== self::key( $_POST, 'repository_attestation' ) ) { throw new \RuntimeException( 'Confirm that this installed component came from its matching WordPress.org repository. This does not certify its contents as clean.' ); } foreach ( self::repositoryComponents( Inventory::collect() ) as $item ) { if ( ! hash_equals( $item['token'], $token ) ) { continue; } if ( ! isset( $confirmations[ $item['key'] ] ) && count( $confirmations ) >= 100 ) { throw new \RuntimeException( 'This beta supports 100 explicit repository identity confirmations. Revoke an unused entry first.' ); } $confirmations[ $item['key'] ] = RepositoryIntegrity::confirmation( $item['component'], $item['type'] ) + array( 'actor' => get_current_user_id() ); Store::saveOption( 'antivirus_ai_repository_confirmations', $confirmations ); $store->activity( 'repository_identity_confirmed', array( 'component' => $item['key'], 'version' => $item['component']['version'], 'actor' => get_current_user_id(), ) ); return; } throw new \RuntimeException( 'The component changed or is unavailable. Refresh inventory and review its identity again.' ); } finally { $store->unlock( 'repository_identities', $owner ); } } private static function repositorySettings(): void { if ( ! class_exists( RepositoryIntegrity::class ) ) { return; } echo '
' . esc_html__( 'Repository identities and vulnerability feed setup', 'shieldlume-security' ) . '

' . esc_html__( 'Names and directory slugs can collide. Confirm repository origin only after checking how this exact component was acquired. Confirmation binds its name, file, and version; an update requires renewed confirmation. It does not declare local code clean. This beta stores at most 100 confirmations.', 'shieldlume-security' ) . '

'; $configured = 'configured' === FeedConnection::status(); echo '

' . esc_html__( 'Wordfence feed credential:', 'shieldlume-security' ) . ' ' . esc_html( $configured ? __( 'configured; provider access still requires a successful request', 'shieldlume-security' ) : __( 'not connected; open Protection to save your permitted feed token', 'shieldlume-security' ) ) . '

'; $confirmations = get_option( 'antivirus_ai_repository_confirmations', array() ); if ( ! is_array( $confirmations ) ) { $confirmations = array(); } $items = self::repositoryComponents( Inventory::collect() ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only administrator filters or pagination use bounded sanitized values; these inputs neither change records nor authorize actions. $page = self::page( $_GET, 'component_page', 40 ); foreach ( array_slice( $items, ( $page - 1 ) * 50, 50 ) as $item ) { $component = $item['component']; $identity = RepositoryIntegrity::identity( $component, $item['type'], $confirmations ); echo '

' . esc_html( $component['name'] . ' ' . $component['version'] ) . '

' . esc_html( $item['key'] ) . '

' . esc_html( $identity['reason'] ?: ( $identity['basis'] ?? 'Repository identity unverified.' ) ) . '

'; if ( empty( $identity['verified'] ) ) { self::formStart( 'confirm_repository', 'settings' ); echo '

'; submit_button( __( 'Confirm repository origin', 'shieldlume-security' ), 'secondary', 'submit', false ); echo ''; } echo '
'; } if ( $page > 1 ) { echo '' . esc_html__( 'Previous components', 'shieldlume-security' ) . ' '; } if ( $page * 50 < count( $items ) ) { echo '' . esc_html__( 'Next components', 'shieldlume-security' ) . ''; } echo '

' . esc_html__( 'Stored identity decisions', 'shieldlume-security' ) . '

'; foreach ( array_slice( $confirmations, 0, 100, true ) as $key => $record ) { self::details( $key, $record ); self::formStart( 'revoke_repository', 'settings' ); echo ''; submit_button( __( 'Revoke this identity confirmation', 'shieldlume-security' ), 'secondary', 'submit', false ); echo ''; } echo '
'; } private static function details( string $label, array $value, bool $open = false ): void { echo '' . esc_html( $label ) . '
' . esc_html( wp_json_encode( $value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ) ) . '
'; } private static function pagination( string $tab, int $page, int $total, int $size, array $filters = array() ): void { $pages = max( 1, (int) ceil( $total / $size ) ); echo ''; } public static function streamExport( Store $store, ?int $actor = null, ?int $site = null ): void { $actor = $actor ?? get_current_user_id(); $site = $site ?? get_current_blog_id(); self::downloadAuthority( $actor, $site ); echo '{"schema_version":1,"exported_at":' . wp_json_encode( gmdate( 'c' ) ) . ',"sensitive_metadata":true,"findings":['; $first = true; $count = 0; try { for ( $offset = 0; $offset < 20000; $offset += 200 ) { $items = $store->list( 'finding', null, 200, $offset ); self::downloadAuthority( $actor, $site ); foreach ( $items as $item ) { if ( ! $first ) { echo ','; } echo wp_json_encode( $item, JSON_INVALID_UTF8_SUBSTITUTE ); $first = false; ++$count; } if ( count( $items ) < 200 ) { break; } } $truncated = $store->count( 'finding' ) > $count; self::downloadAuthority( $actor, $site ); echo '],"count":' . (int) $count . ',"complete":true,"truncated":' . ( $truncated ? 'true' : 'false' ) . '}'; } catch ( \Throwable $error ) { // Bytes already emitted cannot be recalled. End with generic, explicitly partial metadata. echo '],"count":' . (int) $count . ',"complete":false,"truncated":true,"error":"Export stopped because access or local storage changed. This partial file is not a complete export. Request a new download with current administrator access."}'; } } }