';
}
/** 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 '';
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' ) . '
' . 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' ) . '
' . 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__( 'Try a different filter or run a scan. An empty list alone does not establish complete or clean coverage.', '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' ) . '
' . 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' ) . '
' . 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' ) . '
' . esc_html__( 'Start with scans, sign-in limits and alerts. Online checks are optional and have separate permissions below.', 'shieldlume-security' ) . '
'; }
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__( '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' ) . '
' . 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' ) . '
' . 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__( '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' ) . '
' . 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' ) . '
';
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 '
' . 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__( '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__( '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' ) . '
' . 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' ) . '