__( 'Reading site files', 'shieldlume-security' ), 'disk_space' => __( 'Storage space', 'shieldlume-security' ), 'protected_recovery' => __( 'Recovery before repairs', 'shieldlume-security' ), 'notification_delivery' => __( 'Notification delivery', 'shieldlume-security' ), 'scheduling' => __( 'Automatic scans', 'shieldlume-security' ), 'independent_worker' => __( 'Scanning without site visits', 'shieldlume-security' ), 'plugin_compatibility' => __( 'Free and Pro compatibility', 'shieldlume-security' ), 'intelligence_freshness' => __( 'Vulnerability information', 'shieldlume-security' ), 'cloud_pairing' => __( 'Optional Pro connection', 'shieldlume-security' ), 'license' => __( 'Pro account access', 'shieldlume-security' ), 'cloud_connectivity' => __( 'Optional service contact', 'shieldlume-security' ), ); return $labels[ $id ] ?? ucwords( str_replace( '_', ' ', $id ) ); } /** * Accept a bounded version spelling, without copying other text. * * @param mixed $value Candidate version. * @return string */ public static function version( $value ): string { return is_string( $value ) && preg_match( '/^[0-9]{1,5}(?:\.[0-9]{1,5}){0,3}(?:[-+][a-z0-9.]{1,24})?$/Di', $value ) ? $value : 'unknown'; } /** * Derive a stable reference without copying an untrusted identifier. * * @param mixed $value Local record identifier. * @return string */ public static function correlation( $value ): string { return is_string( $value ) && '' !== $value ? 'ref_' . substr( hash( 'sha256', $value ), 0, 24 ) : 'none'; } /** * Accept only a code-owned enumeration. * * @param mixed $value Candidate value. * @param array $allowed Allowed strings. * @param string $fallback Replacement for unknown values. * @return string */ private static function choice( $value, array $allowed, string $fallback = 'unknown' ): string { return is_string( $value ) && in_array( $value, $allowed, true ) ? $value : $fallback; } /** * Accept a bounded UTC timestamp without arbitrary log text. * * @param mixed $value Candidate timestamp. * @return string|null */ private static function timestamp( $value ): ?string { if ( is_int( $value ) && $value > 0 && $value < 4102444800 ) { return gmdate( 'c', $value ); } return is_string( $value ) && preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|\+00:00)$/D', $value ) ? $value : null; } /** * Project a scan into redacted operational facts. * * @param array|null $scan Retained scan or summary. * @return array|null */ public static function scan( ?array $scan ): ?array { if ( ! $scan ) { return null; } $counters = array(); $available = true; $recorded = is_array( $scan['counters'] ?? null ) ? $scan['counters'] : array(); foreach ( array( 'enumerated', 'checked', 'integrity_checked', 'structural_incomplete', 'hashed', 'analyzed', 'reused', 'database_checked', 'excluded', 'skipped', 'unreadable', 'exceptions' ) as $key ) { $value = $recorded[ $key ] ?? null; $counters[ $key ] = is_int( $value ) && $value >= 0 && $value <= 1000000000 ? $value : null; $available = $available && null !== $counters[ $key ]; } return array( 'correlation_id' => self::correlation( $scan['_id'] ?? $scan['id'] ?? '' ), 'status' => self::choice( $scan['_status'] ?? $scan['status'] ?? '', array( 'queued', 'running', 'completed', 'incomplete', 'cancelled', 'failed' ) ), 'mode' => self::choice( $scan['mode'] ?? ( ! empty( $scan['target_finding_id'] ) ? 'targeted' : 'full' ), array( 'full', 'changed', 'targeted' ) ), 'completed_at' => self::timestamp( $scan['completed_at'] ?? null ), 'result_state' => self::choice( $scan['result_state'] ?? '', array( 'no_threats_detected', 'attention_needed', 'confirmed_threat', 'scan_incomplete' ) ), 'counters' => $counters, 'counters_available' => $available, 'rule_version' => self::version( $scan['rule_registry']['version'] ?? '' ), 'feed_freshness' => self::choice( $scan['vulnerability_freshness']['status'] ?? '', array( 'current', 'stale', 'unavailable' ) ), ); } /** * Build a code-owned diagnostic observation and remedy. * * @param string $id Check identifier. * @param string $status Observed state. * @param string $message Observation. * @param string $remedy Specific next action. * @return array */ private static function check( string $id, string $status, string $message, string $remedy ): array { return array( 'check' => $id, 'status' => $status, 'observation' => $message, 'remedy' => $remedy, ); } /** * Evaluate local preflight state without sending or mutating evidence. * * @param Store $store Retained evidence store. * @param array|null $scan Optional latest scan. * @return array */ public static function checks( Store $store, ?array $scan = null ): array { $scan = $scan ?? $store->list( 'scan', null, 1 )[0] ?? null; $settings = get_option( 'antivirus_ai_settings', array() ); // This is the installation under inspection, not this plugin's asset directory. $root = InstallationPaths::wordpress(); $readable = '' !== $root && is_dir( $root ) && is_readable( $root ); $out = array( self::check( 'filesystem_access', $readable ? 'ready' : 'action_required', $readable ? 'The WordPress root can be read. Per-file coverage is established only by an actual scan.' : 'The WordPress root cannot be read.', 'Use the host file manager to correct account ownership and read permissions; do not make files world-writable. Then start a local scan and review skipped or unreadable files.' ) ); $space = $readable && function_exists( 'disk_free_space' ) ? @disk_free_space( $root ) : false; $out[] = self::check( 'disk_space', false === $space ? 'unverified' : ( $space < 16777216 ? 'action_required' : 'ready' ), false === $space ? 'Free disk space could not be read.' : ( $space < 16777216 ? 'Less than 16 MiB is free on the WordPress filesystem.' : 'At least 16 MiB is free on the WordPress filesystem; database capacity is not measured.' ), 'Free space using the host storage tools while retaining recovery evidence and backups. Check database quota separately, then rerun diagnostics.' ); $state = Scheduler::diagnostics(); $worker = get_option( 'antivirus_ai_worker_state', array() ); $enabled = ! empty( $state['enabled'] ); $issue = ! empty( $state['missing_wakeup'] ) || ! empty( $state['overdue'] ) || ! empty( $state['active_stalled'] ) || ! empty( $state['last_error'] ) || ! empty( $state['execution_error'] ) || ! empty( $state['schedule_error'] ) || ! empty( $state['overlap_deferred'] ); $out[] = self::check( 'scheduling', $issue ? 'action_required' : ( $enabled ? 'ready' : 'optional_off' ), $issue ? 'Scheduled or active work is deferred, missing a wake-up, overdue, stalled or has a recorded error.' : ( $enabled ? 'A scheduled wake-up exists. Scheduling alone is not evidence that a scan ran.' : 'Automatic scanning is off; manual scans are available.' ), 'Use Resume scheduled work to recreate an enabled schedule and process one bounded batch. Inspect the last completed scan. For traffic-independent execution, configure and verify the documented host cron worker.' ); $seen = (int) ( $worker['finished_at'] ?? $worker['started_at'] ?? 0 ); $out[] = self::check( 'independent_worker', $seen > time() - 900 && $seen <= time() + 300 && empty( $worker['last_error'] ) ? 'ready' : 'unverified', $seen > time() - 900 && $seen <= time() + 300 && empty( $worker['last_error'] ) ? 'A recent bounded host-worker invocation is recorded.' : 'Recent successful host-worker execution is unverified. Visitor traffic alone cannot establish independent monitoring.', 'Configure the documented administrator WP-CLI worker in host cron, run it, then recheck. Inspect actual scan completions; configure the separate service timer for cloud monitoring.' ); $heldMail = $store->count( 'notification', 'attempting' ) + $store->count( 'notification', 'unknown' ) + $store->count( 'notification', 'blocked' ); $mailEnabled = null !== NotificationDelivery::recipient( $settings ); $out[] = self::check( 'notification_delivery', $heldMail ? 'action_required' : ( $mailEnabled ? 'unverified' : 'optional_off' ), $heldMail ? 'Retained notification attempts need review. An attempting or unknown result can mean delivery is uncertain; these jobs are not repeated automatically.' : ( $mailEnabled ? 'Email alerts are enabled. Acceptance by WordPress does not establish inbox delivery.' : 'Optional email alerts are off or the saved recipient is invalid.' ), 'Review notification history in Protection settings and your mail provider. A requested test checks only that message. Deduplication lasts only while the notification record is retained; a later new observation can create another alert after history is removed.' ); $compatible = ! defined( 'ANTIVIRUS_AI_PRO_VERSION' ) || ( function_exists( 'antivirus_ai_pro_compatible' ) && antivirus_ai_pro_compatible() ); $out[] = self::check( 'plugin_compatibility', $compatible ? 'ready' : 'action_required', ! defined( 'ANTIVIRUS_AI_PRO_VERSION' ) ? 'FREE is running independently; PRO is optional.' : ( $compatible ? 'The active FREE and PRO versions satisfy the installed compatibility guard.' : 'PRO cannot start with the current FREE version or installation mode.' ), 'Install the compatible FREE ZIP first, then the matching PRO ZIP. Keep the same plugin directories and retain existing options and evidence. Multisite is unsupported.' ); $fresh = $scan['vulnerability_freshness']['status'] ?? 'unavailable'; $out[] = self::check( 'intelligence_freshness', empty( $settings['vulnerability_feed'] ) ? 'optional_off' : ( 'current' === $fresh ? 'ready' : 'action_required' ), empty( $settings['vulnerability_feed'] ) ? 'Optional vulnerability feed checks are off.' : ( 'current' === $fresh ? 'The most recent scan recorded current feed data; source coverage remains limited.' : 'Current feed coverage has not been observed.' ), 'Confirm feed permission and the permitted server-managed feed key, allow the documented outbound destinations, then start a scan. Review actual source dates and unavailable checks; bundled local rules continue working.' ); if ( defined( 'ANTIVIRUS_AI_PRO_VERSION' ) && $compatible ) { $pro = get_option( 'antivirus_ai_pro_settings', array() ); $paired = false; $license = 'unavailable'; try { $connection = \AntivirusAI\Pro\Vault::load(); $paired = ! empty( $connection['credential'] ); if ( ! empty( $connection['entitlement'] ) ) { $grant = \AntivirusAI\Pro\Entitlement::verify( $connection['entitlement'], $connection['site_id'], \AntivirusAI\Pro\Service::canonicalSite(), null, null, false ); $license = self::choice( $grant['state'] ?? '', array( 'valid', 'grace' ), 'unavailable' ); } } catch ( \Throwable $error ) { $license = 'unavailable'; } $out[] = self::check( 'cloud_pairing', $paired ? 'ready' : 'action_required', $paired ? 'Encrypted site credentials can be read for this canonical site.' : 'No usable site pairing was found.', 'Activate the owner license in PRO Settings and explicitly approve the ownership proof. After a site move or salt change, use owner-authorized transfer or re-pairing. Do not copy another production site credential.' ); $out[] = self::check( 'license', 'valid' === $license ? 'ready' : ( 'grace' === $license ? 'temporary_grace' : 'action_required' ), 'valid' === $license ? 'A locally trusted, site-bound entitlement is valid.' : ( 'grace' === $license ? 'The signed temporary-outage grace period is active.' : 'No currently valid signed entitlement could be established.' ), 'Use Refresh license now after correcting connectivity or clock time. Renew or correct the license through the asynchronous billing channel if needed. FREE, installed local PRO features and recovery remain available.' ); $cloud = get_option( 'antivirus_ai_pro_service_state', array() ); $fresh = ! empty( $cloud['checked_timestamp'] ) && (int) $cloud['checked_timestamp'] >= time() - 1800 && (int) $cloud['checked_timestamp'] <= time() + 300; $ok = 'connected' === ( $cloud['status'] ?? '' ) && $fresh; $out[] = self::check( 'cloud_connectivity', empty( $pro['cloud'] ) ? 'optional_off' : ( $ok ? 'ready' : 'action_required' ), empty( $pro['cloud'] ) ? 'Cloud heartbeat consent is off.' : ( $ok ? 'A recent successful service heartbeat is recorded.' : 'Recent successful contact has not been established. Lost contact is not proof of compromise.' ), 'Use Retry connection now. Verify the service HTTPS origin, public DNS, trusted keys, site clock, PHP cURL and permitted outbound access. Configure the host cron worker and service timer for traffic-independent operation; local scanning remains available.' ); } return apply_filters( 'antivirus_ai_diagnostic_checks', $out ); } /** * Construct the exact redacted preview and download schema. * * @param Store $store Retained evidence store. * @return array */ public static function bundle( Store $store ): array { global $wp_version; $scan = $store->list( 'scan', null, 1 )[0] ?? null; $free = get_option( 'antivirus_ai_settings', array() ); $pro = get_option( 'antivirus_ai_pro_settings', array() ); $features = array(); foreach ( array( 'daily', 'online_references', 'vulnerability_feed', 'email', 'login_limit' ) as $name ) { $features[ $name ] = ! empty( $free[ $name ] ); } foreach ( array( 'cloud', 'ai', 'ai_context_v2', 'updates', 'event_scans', 'summaries' ) as $name ) { $features[ 'pro_' . $name ] = defined( 'ANTIVIRUS_AI_PRO_VERSION' ) && ! empty( $pro[ $name ] ); } $failures = array(); foreach ( array( 'scan', 'job', 'analysis', 'notification', 'repair' ) as $kind ) { $statuses = array( 'failed', 'retry', 'incomplete', 'needs_review', 'health_failed' ); if ( 'notification' === $kind ) { $statuses = array_merge( $statuses, array( 'attempting', 'unknown', 'blocked' ) ); } foreach ( $statuses as $status ) { foreach ( $store->list( $kind, $status, 5 ) as $row ) { $failures[] = array( 'kind' => $kind, 'status' => $status, 'correlation_id' => self::correlation( $row['_id'] ?? '' ), 'attempts' => max( 0, min( 100, (int) ( $row['attempts'] ?? $row['failures'] ?? 0 ) ) ), 'remedy' => 'Review the matching local Activity record and setup checks. Raw error text is excluded from this bundle.', ); if ( count( $failures ) >= 25 ) { break 3; } } } } return array( 'schema_version' => 1, 'generated_at' => gmdate( 'c' ), 'classification' => 'Previewable redacted diagnostics; nothing is submitted automatically.', 'product' => array( 'free_version' => self::version( defined( 'ANTIVIRUS_AI_VERSION' ) ? ANTIVIRUS_AI_VERSION : '' ), 'pro_version' => defined( 'ANTIVIRUS_AI_PRO_VERSION' ) ? self::version( ANTIVIRUS_AI_PRO_VERSION ) : 'not_installed', ), 'environment' => array( 'php_version' => self::version( PHP_VERSION ), 'wordpress_version' => self::version( $wp_version ?? '' ), 'multisite' => function_exists( 'is_multisite' ) && is_multisite(), 'sodium' => extension_loaded( 'sodium' ), 'curl' => extension_loaded( 'curl' ), 'zip' => extension_loaded( 'zip' ), ), 'features' => $features, 'checks' => self::checks( $store, $scan ), 'latest_scan' => self::scan( $scan ), 'bundled_rule_version' => self::version( Detector::registry()['version'] ?? '' ), 'recent_failures' => $failures, 'limits' => array( 'At most 25 failure references; this is not a complete event export or security audit.', 'Correlation IDs are SHA256-derived local record references.', 'Source code, database contents, credentials, customer records, site URLs, email addresses, absolute paths and raw error messages are excluded.' ), ); } /** * Collect additional allowlisted finding metadata after owner consent. * * @param array $finding Selected retained finding. * @param bool $consent Explicit evidence-download choice. * @return array */ public static function falsePositive( array $finding, bool $consent ): array { if ( ! $consent ) { throw new \RuntimeException( 'Explicit consent is required to download additional finding evidence.' ); } $registry = Detector::registry(); $ids = array_column( $registry['rules'] ?? array(), 'id' ); $hash = $finding['content_hash'] ?? ''; $evidence = array(); foreach ( array_slice( $finding['evidence'] ?? array(), 0, 20 ) as $entry ) { if ( ! is_array( $entry ) ) { continue; } $item = array(); foreach ( array( 'expected', 'actual', 'expected_hash', 'actual_hash' ) as $key ) { if ( is_string( $entry[ $key ] ?? null ) && preg_match( '/^[a-f0-9]{64}$/D', $entry[ $key ] ) ) { $item[ $key ] = $entry[ $key ]; } } if ( isset( $entry['byte_offset'] ) && is_int( $entry['byte_offset'] ) && $entry['byte_offset'] >= 0 && $entry['byte_offset'] <= 2097152 ) { $item['byte_offset'] = $entry['byte_offset']; } if ( $item ) { $evidence[] = $item; } } return array( 'schema_version' => 1, 'classification' => 'Additional owner-selected false-positive evidence. Hashes may identify content; review before sharing.', 'correlation_id' => self::correlation( $finding['_id'] ?? $finding['id'] ?? '' ), 'detector' => self::choice( $finding['detector_id'] ?? '', $ids ), 'detector_version' => self::version( $finding['detector_version'] ?? '' ), 'component_version' => self::version( $finding['component_version'] ?? '' ), 'policy_version' => max( 0, min( 1000000, (int) ( $finding['policy_version'] ?? 0 ) ) ), 'severity' => self::choice( $finding['severity'] ?? '', array( 'info', 'low', 'medium', 'high', 'critical' ) ), 'confidence' => self::choice( $finding['confidence'] ?? '', array( 'informational', 'suspicious', 'strong', 'confirmed' ) ), 'content_hash' => is_string( $hash ) && preg_match( '/^[a-f0-9]{64}$/D', $hash ) ? $hash : '', 'evidence' => $evidence, 'limits' => array( 'No source excerpts, paths, raw database observations or credentials are included.', 'Nothing is uploaded. Add a minimal reproduction with secrets removed through the configured asynchronous false-positive channel.' ), ); } }