sanitize_key( $page ), 'nlssl_notice' => sanitize_key( $notice ) ), admin_url( 'admin.php' ) ) );
exit;
}
private static function admin_result_key( string $kind ): string {
return 'nlssl_admin_' . sanitize_key( $kind ) . '_' . get_current_user_id();
}
private static function store_admin_result( string $kind, array $result ): void {
NLSSL_Core::write_transient_verified( self::admin_result_key( $kind ), $result, 10 * MINUTE_IN_SECONDS );
}
private static function take_admin_result( string $kind ): array {
$key = self::admin_result_key( $kind );
$value = get_transient( $key );
delete_transient( $key );
return is_array( $value ) ? $value : array();
}
public static function save_settings(): void {
self::require_manager();
check_admin_referer( 'nlssl_save_settings' );
$input = isset( $_POST['settings'] ) && is_array( $_POST['settings'] ) ? map_deep( wp_unslash( $_POST['settings'] ), 'sanitize_textarea_field' ) : array();
$old = NLSSL_Core::settings();
$new = NLSSL_Core::sanitize_settings( $input );
if ( ! NLSSL_Core::write_option_verified( NLSSL_Core::OPTION, $new ) ) { self::redirect( 'nlssl-protection', 'settings_failed' ); }
NLSSL_Core::ensure_schedules();
foreach ( NLSSL_Bots::ip_sources() as $source ) {
if ( $old[ $source['setting'] ] !== $new[ $source['setting'] ] ) { NLSSL_Bots::queue_ip_refresh(); break; }
}
NLSSL_Core::log( 'security_settings_changed', 'warning', 'NeverLast Security Lite security settings changed.', array( 'actor' => get_current_user_id(), 'changed' => array_keys( array_diff_assoc( $new, $old ) ) ) );
$return_page = sanitize_key( self::input_scalar( $_POST, 'return_page', 'nlssl-protection' ) );
if ( ! in_array( $return_page, array( 'nlssl-protection', 'nlssl-alerts' ), true ) ) { $return_page = 'nlssl-protection'; }
self::redirect( $return_page, 'settings_saved' );
}
public static function run_health(): void {
self::require_manager();
check_admin_referer( 'nlssl_run_health' );
$result = NLSSL_Health::run();
self::redirect( 'nlssl-health', ! empty( $result['storage_error'] ) ? 'health_failed' : 'health_completed' );
}
public static function build_baseline(): void {
self::require_manager();
check_admin_referer( 'nlssl_build_baseline' );
$pending = NLSSL_Integrity::baseline_resume_pending();
if ( ! $pending && '1' !== self::input_scalar( $_POST, 'confirm_baseline' ) ) { self::redirect( 'nlssl-health', 'baseline_confirmation_required' ); }
$result = NLSSL_Integrity::build_baseline();
self::store_admin_result( 'baseline', $result );
self::redirect( 'nlssl-health', ! empty( $result['resume_pending'] ) ? 'baseline_progress' : ( ! empty( $result['ok'] ) ? 'baseline_created' : 'baseline_failed' ) );
}
public static function run_integrity(): void {
self::require_manager();
check_admin_referer( 'nlssl_run_integrity' );
$result = NLSSL_Integrity::scan();
self::store_admin_result( 'integrity_action', $result );
self::redirect( 'nlssl-health', self::integrity_notice( $result ) );
}
/**
* A scan has three honest outcomes, not two.
*
* `ok` means completed AND clean, so reading it alone turns "completed, and here are the
* differences you asked me to find" into "did not complete; no clean result was recorded".
* That message sends an administrator hunting for a broken scanner after every ordinary
* plugin update, and it understates the one case that genuinely deserves alarm: a scan that
* stopped early and therefore proves nothing about the files it never reached.
*/
private static function integrity_notice( array $result ): string {
if ( ! empty( $result['resume_pending'] ) ) { return 'integrity_progress'; }
$incomplete = ! empty( $result['truncated'] )
|| ! empty( $result['uploads_truncated'] )
|| ! empty( $result['finding_limit_reached'] )
|| ! empty( $result['storage_error'] );
if ( $incomplete ) { return 'integrity_failed'; }
return ! empty( $result['ok'] ) ? 'integrity_completed' : 'integrity_differences';
}
public static function logout_sessions(): void {
self::require_manager();
check_admin_referer( 'nlssl_logout_sessions' );
$count = NLSSL_Login::logout_all_users();
if ( is_wp_error( $count ) ) { self::redirect( 'nlssl-login', 'sessions_failed' ); }
NLSSL_Core::log( 'all_sessions_destroyed', 'critical', 'All WordPress sessions were manually invalidated.', array( 'actor' => get_current_user_id(), 'users' => $count ) );
wp_clear_auth_cookie();
wp_safe_redirect( wp_login_url( admin_url( 'admin.php?page=nlssl-login' ) ) );
exit;
}
public static function freeze_login(): void {
self::require_manager();
check_admin_referer( 'nlssl_freeze_login' );
$minutes = max( 5, min( 1440, absint( self::input_scalar( $_POST, 'minutes', '60' ) ) ) );
$result = NLSSL_Login::freeze( $minutes );
if ( is_wp_error( $result ) ) { self::redirect( 'nlssl-login', 'nlssl_freeze_partial' === $result->get_error_code() ? 'freeze_partial' : 'freeze_failed' ); }
wp_clear_auth_cookie();
wp_safe_redirect( wp_login_url( admin_url( 'admin.php?page=nlssl-login' ) ) );
exit;
}
public static function unfreeze_login(): void {
self::require_manager();
check_admin_referer( 'nlssl_unfreeze_login' );
self::redirect( 'nlssl-login', NLSSL_Login::unfreeze() ? 'login_unfrozen' : 'unfreeze_failed' );
}
public static function save_emergency_secret(): void {
self::require_manager();
check_admin_referer( 'nlssl_save_emergency_secret' );
// Secrets are hashed and compared byte for byte, like WordPress passwords; sanitizing would silently change them.
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$current = isset( $_POST['current_secret'] ) && is_string( $_POST['current_secret'] ) ? wp_unslash( $_POST['current_secret'] ) : '';
$new = isset( $_POST['new_secret'] ) && is_string( $_POST['new_secret'] ) ? wp_unslash( $_POST['new_secret'] ) : '';
$confirm = isset( $_POST['confirm_secret'] ) && is_string( $_POST['confirm_secret'] ) ? wp_unslash( $_POST['confirm_secret'] ) : '';
// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( ! hash_equals( $new, $confirm ) ) { self::redirect( 'nlssl-alerts', 'secret_mismatch' ); }
$rotation_bucket = 'emergency_secret_rotation_' . substr( hash_hmac( 'sha256', 'user:' . get_current_user_id(), wp_salt( 'nonce' ) ), 0, 24 );
if ( NLSSL_Emergency::configured() && NLSSL_Core::rate_limited( $rotation_bucket, 10, false ) ) { self::redirect( 'nlssl-alerts', 'secret_rate_limited' ); }
$result = NLSSL_Emergency::replace_secret( $current, $new );
if ( is_wp_error( $result ) ) {
if ( 'nlssl_current_secret_invalid' === $result->get_error_code() ) {
$notice = NLSSL_Core::rate_hit( $rotation_bucket, 15 * MINUTE_IN_SECONDS, 9, false ) ? 'secret_rate_limited' : 'current_secret_invalid';
} else {
$notice = 'nlssl_storage_failed' === $result->get_error_code() ? 'secret_storage_failed' : 'secret_invalid';
}
self::redirect( 'nlssl-alerts', $notice );
}
NLSSL_Core::rate_clear( $rotation_bucket, false );
NLSSL_Core::log( 'emergency_secret_changed', 'warning', 'The separate emergency email secret was configured or rotated.', array( 'actor' => get_current_user_id() ) );
self::redirect( 'nlssl-alerts', 'secret_saved' );
}
public static function test_email(): void {
self::require_manager();
check_admin_referer( 'nlssl_test_email' );
$result = NLSSL_Core::send_email_diagnostic( 'NeverLast Security Lite test notification', 'This is an administrator-requested delivery test. No security incident was detected.', 'delivery_test' );
self::store_admin_result( 'mail', $result );
self::redirect( 'nlssl-alerts', ! empty( $result['ok'] ) ? 'email_test_sent' : 'email_test_failed' );
}
public static function repair_storage(): void {
self::require_manager();
check_admin_referer( 'nlssl_repair_storage' );
$result = NLSSL_Core::repair_storage();
if ( ! empty( $result['ok'] ) ) { NLSSL_Health::run(); }
self::store_admin_result( 'storage', $result );
self::redirect( 'nlssl-health', ! empty( $result['ok'] ) ? 'storage_repaired' : 'storage_repair_failed' );
}
public static function integrity_trust(): void {
self::require_manager();
check_admin_referer( 'nlssl_integrity_trust' );
if ( '1' !== self::input_scalar( $_POST, 'confirm_trust' ) ) { self::redirect( 'nlssl-health', 'trust_confirmation_required' ); }
$id = strtolower( sanitize_text_field( self::input_scalar( $_POST, 'finding_id' ) ) );
$result = NLSSL_Integrity::trust_finding( $id );
self::store_admin_result( 'integrity_action', $result );
self::redirect( 'nlssl-health', ! empty( $result['ok'] ) ? 'finding_trusted' : 'integrity_action_failed' );
}
public static function integrity_quarantine(): void {
self::require_manager();
check_admin_referer( 'nlssl_integrity_quarantine' );
if ( '1' !== self::input_scalar( $_POST, 'confirm_quarantine' ) ) { self::redirect( 'nlssl-health', 'quarantine_confirmation_required' ); }
$id = strtolower( sanitize_text_field( self::input_scalar( $_POST, 'finding_id' ) ) );
$result = NLSSL_Integrity::quarantine_finding( $id );
self::store_admin_result( 'integrity_action', $result );
self::redirect( 'nlssl-health', ! empty( $result['ok'] ) ? 'file_quarantined' : 'integrity_action_failed' );
}
public static function integrity_purge(): void {
self::require_manager();
check_admin_referer( 'nlssl_integrity_purge' );
if ( '1' !== self::input_scalar( $_POST, 'confirm_purge' ) ) { self::redirect( 'nlssl-health', 'purge_confirmation_required' ); }
$id = strtolower( sanitize_text_field( self::input_scalar( $_POST, 'quarantine_id' ) ) );
$result = NLSSL_Integrity::purge_quarantine( $id );
self::store_admin_result( 'integrity_action', $result );
self::redirect( 'nlssl-health', ! empty( $result['ok'] ) ? 'file_purged' : 'integrity_action_failed' );
}
/**
* Hands the held artifact to the administrator so the evidence can live where they decide - a
* support ticket, their host, a CERT report - instead of only on the server it was found on.
*
* What is streamed is the stored Base64 form, not the decoded original: an incident responder
* can decode it deliberately, while nobody can re-upload or execute it by accident.
*/
public static function integrity_download(): void {
self::require_manager();
check_admin_referer( 'nlssl_integrity_download' );
$id = strtolower( sanitize_text_field( self::input_scalar( $_POST, 'quarantine_id' ) ) );
$manifest = NLSSL_Integrity::quarantine_manifest( $id );
$path = (string) ( $manifest['stored_path'] ?? '' );
if ( empty( $manifest ) || ! is_file( $path ) || ! NLSSL_Integrity::quarantine_export_ready( $id ) ) { self::redirect( 'nlssl-health', 'integrity_action_failed' ); }
NLSSL_Core::log( 'integrity_file_exported', 'warning', 'An administrator downloaded a quarantined file as evidence.', array( 'path' => (string) ( $manifest['original_path'] ?? '' ), 'actor' => get_current_user_id(), 'quarantine_id' => $id ) );
nocache_headers();
header( 'Content-Type: text/plain; charset=utf-8' );
header( 'Content-Disposition: attachment; filename="neverlast-quarantine-' . $id . '.base64.txt"' );
header( 'X-Content-Type-Options: nosniff' );
NLSSL_Integrity::stream_quarantine_base64( $id ); // Verified above before any response bytes are emitted.
exit;
}
/**
* A held file is a decision the administrator still owes, so NeverLast Security keeps asking rather than
* deleting anything on a timer. Nothing here is dismissible; it disappears when the queue does.
*/
public static function integrity_clear_record(): void {
self::require_manager();
check_admin_referer( 'nlssl_integrity_clear_record' );
$id = strtolower( sanitize_text_field( self::input_scalar( $_POST, 'quarantine_id' ) ) );
$result = NLSSL_Integrity::discard_quarantine_record( $id );
self::store_admin_result( 'integrity_action', $result );
self::redirect( 'nlssl-health', ! empty( $result['ok'] ) ? 'record_cleared' : 'integrity_action_failed' );
}
public static function pending_decision_notice(): void {
if ( ! current_user_can( 'manage_options' ) || ! class_exists( 'NLSSL_Integrity' ) ) { return; }
// WordPress.org guideline 11: show only on the plugin's own screens.
// Health & Integrity already shows the full queue, so no duplicate notice is needed there.
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
$screen_id = $screen && isset( $screen->id ) ? (string) $screen->id : '';
if ( false !== strpos( $screen_id, 'nlssl-health' ) ) { return; }
if ( false === strpos( $screen_id, 'nlssl' ) ) { return; }
$held = NLSSL_Integrity::quarantine_items();
$count = count( $held );
if ( $count < 1 ) { return; }
$missing = count( array_filter( $held, static fn( array $item ): bool => ! empty( $item['held_copy_missing'] ) ) );
echo '
' . esc_html__( 'NeverLast Security Lite: held files await your decision.', 'neverlast-security-lite' ) . ' ';
echo esc_html( sprintf( /* translators: %d: number of held files */ _n( '%d file was removed from uploads and is held as a protected copy.', '%d files were removed from uploads and are held as protected copies.', $count, 'neverlast-security-lite' ), $count ) ) . ' ';
if ( $missing > 0 ) { echo esc_html( sprintf( /* translators: %d: number of held copies that are missing */ _n( '%d held copy is missing and cannot be restored.', '%d held copies are missing and cannot be restored.', $missing, 'neverlast-security-lite' ), $missing ) ) . ' '; }
echo esc_html__( 'Nothing is deleted automatically. Download evidence or permanently delete each one in', 'neverlast-security-lite' ) . ' ' . esc_html__( 'Health & Integrity', 'neverlast-security-lite' ) . '.
';
}
public static function start_audit_window(): void {
self::require_manager();
check_admin_referer( 'nlssl_start_audit_window' );
$started = NLSSL_Bots::start_audit_window();
if ( $started ) {
NLSSL_Core::log( 'site_audit_window_started', 'warning', 'A 15-minute rate-limit exception was started for the administrator source IP.', array( 'actor' => get_current_user_id() ) );
}
self::redirect( 'nlssl-protection', $started ? 'audit_window_started' : 'audit_window_failed' );
}
public static function stop_audit_window(): void {
self::require_manager();
check_admin_referer( 'nlssl_stop_audit_window' );
$stopped = NLSSL_Bots::stop_audit_window();
if ( $stopped ) { NLSSL_Core::log( 'site_audit_window_stopped', 'info', 'The temporary site-audit rate exception was stopped.', array( 'actor' => get_current_user_id() ) ); }
self::redirect( 'nlssl-protection', $stopped ? 'audit_window_stopped' : 'audit_window_stop_failed' );
}
public static function refresh_bot_ip_lists(): void {
self::require_manager();
check_admin_referer( 'nlssl_refresh_bot_ip_lists' );
self::redirect( 'nlssl-protection', NLSSL_Bots::queue_ip_refresh() ? 'bot_ip_refresh_queued' : 'bot_ip_refresh_disabled' );
}
public static function export_report(): void {
self::require_manager();
check_admin_referer( 'nlssl_export_report' );
global $wp_version;
$settings = NLSSL_Core::settings();
$setting_keys = array(
'firewall_enabled', 'firewall_mode', 'block_bad_user_agents', 'upload_guard_enabled',
'public_rate_limit_per_minute', 'verified_bot_rate_limit_bypass', 'login_rate_limit_enabled', 'login_ip_rate_limit_enabled',
'login_max_attempts', 'login_window_minutes', 'login_lockout_minutes', 'login_freeze_minutes',
'generic_login_errors', 'block_admin_username', 'disable_xmlrpc', 'protect_rest_users',
'block_author_enumeration', 'disable_application_passwords', 'two_factor_enabled',
'security_headers_enabled', 'csp_report_only', 'hide_file_editor', 'activity_retention_days',
'critical_email_enabled', 'admin_login_email', 'emergency_email_enabled',
'weekly_summary_enabled', 'integrity_schedule_enabled', 'wporg_checksums_enabled',
);
$protection = array();
foreach ( $setting_keys as $key ) { $protection[ $key ] = $settings[ $key ] ?? null; }
$protection['site_audit_window_active_for_exporter'] = NLSSL_Bots::audit_window_active();
$protection['official_bot_ip_lists'] = NLSSL_Bots::ip_list_status();
$baseline_count = NLSSL_Integrity::baseline_count();
$report = array(
'schema' => 'neverlast-security-lite-report-v1',
'generated_at_utc' => gmdate( 'c' ),
'site_host' => (string) wp_parse_url( home_url(), PHP_URL_HOST ),
'plugin_version' => NLSSL_VERSION,
'wordpress_version' => (string) $wp_version,
'php_version' => PHP_VERSION,
'protection' => $protection,
'alert_recovery' => array(
'recipient_configured' => '' !== NLSSL_Core::alert_email(),
'emergency_secret_configured' => NLSSL_Emergency::configured(),
'login_frozen' => NLSSL_Login::is_frozen(),
),
'health' => NLSSL_Health::latest(),
'integrity' => array(
'baseline_created_at' => (string) get_option( NLSSL_Integrity::BASELINE_TIME_OPTION, '' ),
'baseline_files' => $baseline_count < 0 ? null : $baseline_count,
'baseline_storage_available' => $baseline_count >= 0,
'baseline_stale' => (bool) get_option( NLSSL_Integrity::BASELINE_STALE_OPTION, 0 ),
'baseline_build_pending' => NLSSL_Integrity::baseline_resume_pending(),
'baseline_build_status' => NLSSL_Integrity::baseline_build_status(),
'scan_resume_pending' => NLSSL_Integrity::scan_resume_pending(),
'scan_progress' => NLSSL_Integrity::scan_build_status(),
'latest_comparison' => NLSSL_Integrity::latest(),
),
'event_counts' => self::event_counts(),
'privacy' => 'No email address, IP hash, password, secret, token, nonce or recovery code is included.',
);
$json = wp_json_encode( $report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
if ( ! is_string( $json ) ) { wp_die( esc_html__( 'The security report could not be encoded.', 'neverlast-security-lite' ), 500 ); }
self::download_headers( self::download_name( 'security-report', 'json' ), 'application/json; charset=utf-8' );
echo $json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON download, encoded above.
exit;
}
public static function export_activity(): void {
self::require_manager();
check_admin_referer( 'nlssl_export_activity' );
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Streams the plugin's own activity log for download; the export must contain the rows as they are now.
$rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id,created_at,severity,event_type,message,method,path,ip_masked,context FROM %i ORDER BY id ASC LIMIT 5000', NLSSL_Core::table( 'events' ) ), ARRAY_A );
if ( ! is_array( $rows ) || ! empty( $wpdb->last_error ) ) {
NLSSL_Core::degraded_storage( 'activity_export_read' );
wp_die( esc_html__( 'The activity log could not be read from security storage.', 'neverlast-security-lite' ), 500 );
}
self::download_headers( self::download_name( 'activity-log', 'csv' ), 'text/csv; charset=utf-8' );
$stream = fopen( 'php://output', 'wb' );
if ( false === $stream ) { exit; }
fputcsv( $stream, array( 'id', 'time_utc', 'severity', 'event', 'message', 'method', 'path', 'masked_ip', 'redacted_context' ), ',', '"', '', "\r\n" );
foreach ( $rows as $row ) {
fputcsv(
$stream,
array_map( array( __CLASS__, 'csv_value' ), array(
$row['id'], $row['created_at'], $row['severity'], $row['event_type'], $row['message'],
$row['method'], $row['path'], $row['ip_masked'], $row['context'],
) ),
',', '"', '', "\r\n"
);
}
fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Streams the held evidence file to the administrator without loading it into memory.
exit;
}
public static function clear_reports(): void {
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Counts, empties and re-counts the plugin's own activity log to verify the administrator's clear action. There is no WordPress API for plugin tables, and a cached count would hide a failed delete.
self::require_manager();
check_admin_referer( 'nlssl_clear_reports' );
if ( '1' !== self::input_scalar( $_POST, 'confirm_clear_reports' ) ) { self::redirect( 'nlssl-reports', 'reports_clear_confirmation_required' ); }
global $wpdb;
$table = NLSSL_Core::table( 'events' );
$before = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) );
if ( null === $before || ! empty( $wpdb->last_error ) ) {
NLSSL_Core::degraded_storage( 'reports_clear_count' );
self::store_admin_result( 'reports_clear', array( 'ok' => false, 'reason' => 'events_count', 'message' => __( 'Retained reports could not be cleared because the events table could not be verified.', 'neverlast-security-lite' ) ) );
self::redirect( 'nlssl-reports', 'reports_clear_failed' );
}
$options_ok = true;
foreach ( array( 'nlssl_last_health', NLSSL_Integrity::RESULT_OPTION ) as $option ) {
delete_option( $option );
$stored = NLSSL_Core::persisted_option( $option, null );
$options_ok = $options_ok && ! is_wp_error( $stored ) && null === $stored;
}
$deleted = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $table ) );
$after = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) );
$events_ok = false !== $deleted && null !== $after && empty( $wpdb->last_error ) && 0 === (int) $after;
$ok = $options_ok && $events_ok;
if ( ! $ok ) { NLSSL_Core::degraded_storage( 'reports_clear_verify' ); }
$result = array(
'ok' => $ok,
'events_removed' => $events_ok ? (int) $before : max( 0, (int) $before - (int) $after ),
'baseline_preserved' => true,
'trust_preserved' => true,
'quarantine_preserved' => true,
'message' => $ok
? __( 'All retained report data was cleared. The approved baseline, trusted hashes, quarantine, settings and authentication controls were preserved.', 'neverlast-security-lite' )
: __( 'Report-data clearing was only partially verified. The baseline, trusted hashes and quarantine were not targeted. Repair storage and review Reports before retrying.', 'neverlast-security-lite' ),
);
self::store_admin_result( 'reports_clear', $result );
self::redirect( 'nlssl-reports', $ok ? 'reports_cleared' : 'reports_clear_failed' );
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
}
private static function event_counts(): array {
global $wpdb;
$counts = array( 'critical' => 0, 'warning' => 0, 'info' => 0, 'total' => 0, 'storage_available' => true );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Severity totals from the plugin's own activity log for the Reports page; they must reflect the table as it is now.
$rows = $wpdb->get_results( $wpdb->prepare( 'SELECT severity, COUNT(*) AS total FROM %i GROUP BY severity', NLSSL_Core::table( 'events' ) ), ARRAY_A );
if ( ! is_array( $rows ) || ! empty( $wpdb->last_error ) ) {
NLSSL_Core::degraded_storage( 'event_counts_read' );
$counts['storage_available'] = false;
return $counts;
}
foreach ( $rows as $row ) {
$severity = sanitize_key( (string) ( $row['severity'] ?? '' ) );
if ( isset( $counts[ $severity ] ) ) { $counts[ $severity ] = max( 0, (int) $row['total'] ); }
}
$counts['total'] = $counts['critical'] + $counts['warning'] + $counts['info'];
return $counts;
}
private static function download_headers( string $filename, string $content_type ): void {
while ( ob_get_level() > 0 ) { ob_end_clean(); }
nocache_headers();
header( 'Content-Type: ' . $content_type );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'X-Content-Type-Options: nosniff' );
header( 'Referrer-Policy: no-referrer' );
}
private static function download_name( string $kind, string $extension ): string {
$host = sanitize_file_name( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
if ( '' === $host ) { $host = 'wordpress-site'; }
return sanitize_file_name( $host . '-plugin-lite-' . $kind . '-' . gmdate( 'Ymd-His' ) . '.' . $extension );
}
private static function csv_value( $value ): string {
$value = is_scalar( $value ) ? (string) $value : '';
return preg_match( '/^[=+\-@ \r]/', $value ) ? "'" . $value : $value;
}
private static function header( string $title, string $description ): void {
if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to view this page.', 'neverlast-security-lite' ), 403 ); }
echo '' . esc_html( $title ) . '
' . esc_html( $description ) . '
';
self::notice();
}
private static function footer(): void {
echo '
' . esc_html__( 'NeverLast Security Lite operates inside WordPress. It does not replace hosting, operating-system, CDN or off-site backup security.', 'neverlast-security-lite' ) . '
';
}
private static function notice(): void {
$key = sanitize_key( self::input_scalar( $_GET, 'nlssl_notice' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Chooses which fixed message to show after a redirect; changes nothing.
$messages = array(
'settings_saved' => __( 'Settings saved.', 'neverlast-security-lite' ),
'settings_failed' => __( 'Settings could not be verified in storage. Repair the database and retry.', 'neverlast-security-lite' ),
'freeze_failed' => __( 'Login Freeze was not confirmed. No completed emergency action was reported; repair storage and retry.', 'neverlast-security-lite' ),
'freeze_partial' => __( 'Login is frozen, but existing sessions or challenges may remain active. Repair storage and retry the freeze action.', 'neverlast-security-lite' ),
'unfreeze_failed' => __( 'Login Freeze removal could not be verified. Repair storage before retrying recovery.', 'neverlast-security-lite' ),
'sessions_failed' => __( 'Session invalidation was incomplete or could not be verified. Existing sessions may remain active.', 'neverlast-security-lite' ),
'secret_storage_failed' => __( 'The emergency-secret save was not confirmed. Repair storage and verify which secret is active before using emergency links.', 'neverlast-security-lite' ),
'health_completed' => __( 'Health check completed.', 'neverlast-security-lite' ),
'health_failed' => __( 'The health check ran, but its result could not be verified in storage. Repair the database and retry.', 'neverlast-security-lite' ),
'baseline_created' => __( 'The reviewed local baseline was created.', 'neverlast-security-lite' ),
'baseline_progress' => __( 'Baseline progress was preserved. NeverLast Security will continue automatically when safe; you can also use Continue baseline build. A commit-only pause never discards the verified staging rows or previous approved baseline.', 'neverlast-security-lite' ),
'baseline_failed' => __( 'Baseline creation did not complete. The exact failure and storage status are shown below.', 'neverlast-security-lite' ),
'baseline_confirmation_required' => __( 'Confirm that the current files are expected before creating a baseline.', 'neverlast-security-lite' ),
'integrity_completed' => __( 'Read-only integrity scan completed.', 'neverlast-security-lite' ),
'integrity_progress' => __( 'Integrity scan progress was saved. Upload inspection will continue automatically; you can also continue it now.', 'neverlast-security-lite' ),
'integrity_differences' => __( 'The integrity scan completed and recorded differences that need review. Nothing was changed; the findings are listed below.', 'neverlast-security-lite' ),
'integrity_failed' => __( 'The integrity scan stopped before covering everything; no clean result was recorded for the part it did not reach.', 'neverlast-security-lite' ),
'login_unfrozen' => __( 'Emergency Login Freeze was lifted.', 'neverlast-security-lite' ),
'secret_saved' => __( 'The separate emergency email secret was saved.', 'neverlast-security-lite' ),
'secret_mismatch' => __( 'The new emergency secret and its confirmation did not match.', 'neverlast-security-lite' ),
'secret_invalid' => __( 'Use an emergency secret between 6 and 128 characters without leading, trailing or control characters.', 'neverlast-security-lite' ),
'current_secret_invalid' => __( 'The current emergency secret was not valid. Nothing was changed.', 'neverlast-security-lite' ),
'secret_rate_limited' => __( 'Emergency-secret rotation is temporarily locked after repeated invalid current-secret attempts. Try again later.', 'neverlast-security-lite' ),
'email_test_sent' => __( 'WordPress accepted the test email for delivery. Check the configured mailbox and mail transport logs.', 'neverlast-security-lite' ),
'email_test_failed' => __( 'WordPress mail delivery rejected the test email. The transport diagnostic is shown below.', 'neverlast-security-lite' ),
'setup_started' => __( 'Authenticator enrollment started.', 'neverlast-security-lite' ),
'already_configured' => __( 'This account already has a verified authenticator. Finish recovery-code acknowledgement or disable TOTP with a valid code before enrolling a replacement.', 'neverlast-security-lite' ),
'invalid_setup_code' => __( 'The authenticator code was not valid.', 'neverlast-security-lite' ),
'enabled' => __( 'TOTP verified. Save the recovery codes and acknowledge them to finish activation.', 'neverlast-security-lite' ),
'recovery_confirmed' => __( 'TOTP is now active for this administrator.', 'neverlast-security-lite' ),
'codes_confirmation_required' => __( 'TOTP remains inactive until the generated recovery codes are stored and acknowledged.', 'neverlast-security-lite' ),
'disabled' => __( 'TOTP was disabled for this administrator.', 'neverlast-security-lite' ),
'codes_regenerated' => __( 'New recovery codes were generated. Previous codes no longer work.', 'neverlast-security-lite' ),
'admin_reset' => __( 'The selected administrator TOTP state was reset.', 'neverlast-security-lite' ),
'crypto_unavailable' => __( 'OpenSSL authenticated encryption is unavailable.', 'neverlast-security-lite' ),
'invalid_disable_code' => __( 'TOTP was not disabled because the verification code was invalid.', 'neverlast-security-lite' ),
'invalid_regenerate_code' => __( 'Recovery codes were not changed because the TOTP code was invalid.', 'neverlast-security-lite' ),
'invalid_admin_reset_credentials' => __( 'The administrator TOTP reset was rejected because your current password was not valid.', 'neverlast-security-lite' ),
'admin_reset_rate_limited' => __( 'Administrator TOTP reset is temporarily locked after repeated reauthentication failures. Try again later.', 'neverlast-security-lite' ),
'totp_rate_limited' => __( 'TOTP maintenance is temporarily locked after repeated invalid verification codes. Sign in again later or use a different administrator recovery account.', 'neverlast-security-lite' ),
'totp_storage_failed' => __( 'The TOTP change could not be verified in security storage. No completed change was reported; repair the database and review the account state before retrying.', 'neverlast-security-lite' ),
'totp_generation_failed' => __( 'Secure TOTP material could not be generated. Nothing was activated; review the PHP cryptographic random source and retry.', 'neverlast-security-lite' ),
'audit_window_started' => __( 'The 15-minute Lighthouse and local audit window is active for this source IP.', 'neverlast-security-lite' ),
'audit_window_stopped' => __( 'The temporary Lighthouse and local audit window was stopped.', 'neverlast-security-lite' ),
'audit_window_stop_failed' => __( 'The temporary audit window could not be confirmed as stopped. Repair security storage or wait for its 15-minute expiry.', 'neverlast-security-lite' ),
'audit_window_failed' => __( 'The audit window could not start because the source IP or transient storage could not be verified.', 'neverlast-security-lite' ),
'bot_ip_refresh_queued' => __( 'Official IP list refresh queued in WP-Cron. Refresh this page after the worker runs; repeated attempts are limited to once per five minutes.', 'neverlast-security-lite' ),
'bot_ip_refresh_disabled' => __( 'Select and save at least one official IP list before requesting a refresh. WP-Cron must be available.', 'neverlast-security-lite' ),
'storage_repaired' => __( 'NeverLast Security storage schema and write/read verification completed successfully.', 'neverlast-security-lite' ),
'storage_repair_failed' => __( 'NeverLast Security storage repair did not complete. The detailed failure is shown below.', 'neverlast-security-lite' ),
'finding_trusted' => __( 'Trust saved and verified. The trusted finding was reconciled from the current scan view; any future file-content change makes it visible again.', 'neverlast-security-lite' ),
'trust_confirmation_required' => __( 'Review confirmation is required before trusting an exact file hash.', 'neverlast-security-lite' ),
'file_quarantined' => __( 'The selected suspicious upload was moved to NeverLast Security quarantine.', 'neverlast-security-lite' ),
'file_purged' => __( 'The held copy was permanently deleted. Nothing was restored to the site.', 'neverlast-security-lite' ),
'record_cleared' => __( 'The stale quarantine record was cleared. The file itself was already gone and could not be recovered.', 'neverlast-security-lite' ),
'purge_confirmation_required' => __( 'Confirm the permanent deletion. A deleted copy cannot be recovered, so download it first if you may need the evidence.', 'neverlast-security-lite' ),
'integrity_action_failed' => __( 'The requested integrity action was refused or could not be verified. Details are shown below.', 'neverlast-security-lite' ),
'quarantine_confirmation_required' => __( 'Explicit confirmation is required before quarantining a file.', 'neverlast-security-lite' ),
'reports_cleared' => __( 'All retained report data was cleared. Baseline, trust, quarantine and security settings were preserved.', 'neverlast-security-lite' ),
'reports_clear_failed' => __( 'Report data could not be cleared completely or verified. Review the detailed result below.', 'neverlast-security-lite' ),
'reports_clear_confirmation_required' => __( 'Explicit confirmation is required before clearing retained report data.', 'neverlast-security-lite' ),
);
if ( isset( $messages[ $key ] ) ) {
$success = in_array( $key, array( 'finding_trusted', 'file_quarantined', 'storage_repaired', 'baseline_created', 'baseline_replaced', 'reports_cleared' ), true );
$class = str_ends_with( $key, '_failed' ) || str_ends_with( $key, '_required' ) ? 'notice-error' : ( str_ends_with( $key, '_partial' ) ? 'notice-warning' : ( $success ? 'notice-success' : 'notice-info' ) );
echo '' . esc_html( $messages[ $key ] ) . '
';
}
}
public static function dashboard(): void {
self::header( __( 'NeverLast Security Lite', 'neverlast-security-lite' ), __( 'Essential local protection with explicit evidence and conservative defaults.', 'neverlast-security-lite' ) );
$health = NLSSL_Health::latest();
$score = isset( $health['score'] ) ? (int) $health['score'] : null;
$status = null === $score ? __( 'Not measured', 'neverlast-security-lite' ) : ( $score >= 85 ? __( 'Protected basics', 'neverlast-security-lite' ) : ( $score >= 60 ? __( 'Attention required', 'neverlast-security-lite' ) : __( 'Critical review', 'neverlast-security-lite' ) ) );
$baseline_count = NLSSL_Integrity::baseline_count();
echo '';
self::metric( __( 'Health score', 'neverlast-security-lite' ), null === $score ? '—' : $score . '/100', $status, 'metric.health_score' );
self::metric( __( 'Firewall', 'neverlast-security-lite' ), NLSSL_Core::setting( 'firewall_enabled', 1 ) ? ucfirst( (string) NLSSL_Core::setting( 'firewall_mode', 'balanced' ) ) : __( 'Off', 'neverlast-security-lite' ), __( 'WordPress request layer', 'neverlast-security-lite' ), 'metric.firewall' );
self::metric( __( 'Local baseline', 'neverlast-security-lite' ), $baseline_count < 0 ? __( 'Storage unavailable', 'neverlast-security-lite' ) : ( $baseline_count > 0 ? __( 'Available', 'neverlast-security-lite' ) : __( 'Not created', 'neverlast-security-lite' ) ), get_option( NLSSL_Integrity::BASELINE_STALE_OPTION, 0 ) ? __( 'Review after update', 'neverlast-security-lite' ) : __( 'Manual approval only', 'neverlast-security-lite' ), 'metric.baseline' );
self::metric( __( 'Administrator TOTP', 'neverlast-security-lite' ), NLSSL_Two_Factor::enabled_for( get_current_user_id() ) ? __( 'Active', 'neverlast-security-lite' ) : __( 'Not active', 'neverlast-security-lite' ), __( 'Current account', 'neverlast-security-lite' ), 'metric.totp' );
echo '
';
echo '' . esc_html__( 'Priority actions', 'neverlast-security-lite' ) . '
';
if ( empty( $health ) ) {
echo '' . esc_html__( 'Run the first health check to receive three prioritized actions.', 'neverlast-security-lite' ) . '
';
} elseif ( empty( $health['top_actions'] ) ) {
echo '' . esc_html__( 'No health warning is currently prioritized.', 'neverlast-security-lite' ) . '
';
} else {
echo '';
foreach ( $health['top_actions'] as $action ) { echo '- ' . esc_html( $action['title'] ) . '' . esc_html( $action['recommendation'] ) . '
'; }
echo '
';
}
echo '' . esc_html__( 'Open Health & Integrity', 'neverlast-security-lite' ) . '
';
echo '
' . esc_html__( 'Protection boundary', 'neverlast-security-lite' ) . '
';
echo '- ' . esc_html__( 'Local WAF, upload guard and login controls run inside WordPress.', 'neverlast-security-lite' ) . '
';
echo '- ' . esc_html__( 'Integrity findings are read-only and require administrator interpretation.', 'neverlast-security-lite' ) . '
';
echo '- ' . esc_html__( 'No telemetry, licence request or NeverLast API call is made.', 'neverlast-security-lite' ) . '
';
echo '- ' . esc_html__( 'No malware-free or vulnerability-free guarantee is made.', 'neverlast-security-lite' ) . '
';
self::footer();
}
private static function metric( string $label, string $value, string $detail, string $help ): void {
echo '' . esc_html( $label ) . '' . esc_html( $value ) . '' . esc_html( $detail ) . '
';
}
public static function protection(): void {
self::header( __( 'Protection', 'neverlast-security-lite' ), __( 'Use Monitor for compatibility learning, then choose Balanced after reviewing activity.', 'neverlast-security-lite' ) );
$s = NLSSL_Core::settings();
echo '';
echo '' . esc_html__( 'Official IP list status', 'neverlast-security-lite' ) . '
' . esc_html__( 'Only GET/HEAD requests can skip the general rate counter. WAF and login protection remain active. Feeds are authenticated through vendor HTTPS, not signed manifests. Failed downloads retain the previous valid list for up to seven days; after expiry no exception is granted.', 'neverlast-security-lite' ) . '
';
$labels = array( 'disabled' => __( 'Disabled', 'neverlast-security-lite' ), 'ready' => __( 'Ready', 'neverlast-security-lite' ), 'waiting' => __( 'Waiting for download', 'neverlast-security-lite' ), 'expired' => __( 'Expired; no exception', 'neverlast-security-lite' ), 'invalid' => __( 'Invalid stored list; no exception', 'neverlast-security-lite' ) );
echo '| ' . esc_html__( 'Service', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Status', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Networks', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Last successful download', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Latest attempt', 'neverlast-security-lite' ) . ' |
';
$results = array( 'ready' => __( 'Success', 'neverlast-security-lite' ), 'network_error' => __( 'Connection failed; previous list retained', 'neverlast-security-lite' ), 'http_error' => __( 'HTTP response rejected; previous list retained', 'neverlast-security-lite' ), 'invalid_list' => __( 'Invalid list rejected; previous list retained', 'neverlast-security-lite' ), 'storage_error' => __( 'Downloaded list could not be verified in storage', 'neverlast-security-lite' ) );
foreach ( NLSSL_Bots::ip_list_status() as $status ) {
echo '| ' . esc_html( $status['name'] ) . ' | ' . esc_html( $labels[ $status['state'] ] ) . ' | ' . esc_html( (string) $status['count'] ) . ' | ' . esc_html( $status['fetched_at'] ? gmdate( 'Y-m-d H:i:s', $status['fetched_at'] ) . ' UTC' : '-' ) . ' | ' . esc_html( $results[ $status['last_result'] ] ?? '-' ) . ' |
';
}
echo '
';
self::action_form( 'nlssl_refresh_bot_ip_lists', 'nlssl_refresh_bot_ip_lists', __( 'Queue official IP list refresh', 'neverlast-security-lite' ) );
echo '
';
$audit_active = NLSSL_Bots::audit_window_active();
echo '' . esc_html__( 'Lighthouse and local site audits', 'neverlast-security-lite' ) . '
';
echo '' . esc_html__( 'Lighthouse can run from the administrator browser, CLI or another machine, so its User-Agent is not a trustworthy identity. Start this window immediately before a local audit. It bypasses only the general anonymous rate counter for the source IP seen by WordPress; all WAF rules remain active.', 'neverlast-security-lite' ) . '
';
echo '' . esc_html__( 'Status:', 'neverlast-security-lite' ) . ' ' . ( $audit_active ? '' . esc_html__( 'Active for this source IP', 'neverlast-security-lite' ) . '' : esc_html__( 'Inactive', 'neverlast-security-lite' ) ) . '
';
echo '' . esc_html__( 'Do not use this window when REMOTE_ADDR represents a shared proxy or CDN address, because other visitors sharing that address would also skip only the general rate counter for 15 minutes.', 'neverlast-security-lite' ) . '
';
if ( $audit_active ) { self::action_form( 'nlssl_stop_audit_window', 'nlssl_stop_audit_window', __( 'Stop audit window', 'neverlast-security-lite' ) ); }
else { self::action_form( 'nlssl_start_audit_window', 'nlssl_start_audit_window', __( 'Start 15-minute audit window', 'neverlast-security-lite' ), true ); }
echo '
';
echo '' . esc_html__( 'Legitimate crawler and tool registry', 'neverlast-security-lite' ) . '
';
echo '' . esc_html__( 'Recognition is not trust. Automatic exceptions are granted only where NeverLast Security can verify the provider. The remaining entries document common legitimate traffic and the evidence still required.', 'neverlast-security-lite' ) . '
';
echo '| ' . esc_html__( 'Service', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Purpose', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Verification', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Handling', 'neverlast-security-lite' ) . ' |
';
foreach ( NLSSL_Bots::registry() as $bot ) {
echo '| ' . esc_html( $bot['name'] ) . ' | ' . esc_html( $bot['purpose'] ) . ' | ' . esc_html( $bot['verification'] ) . ' | ' . esc_html( $bot['handling'] ) . ' |
';
}
echo '
';
self::footer();
}
private static function settings_section( string $title, array $fields, array $settings ): void {
echo '' . esc_html( $title ) . '
';
}
public static function health(): void {
self::header( __( 'Health & Integrity', 'neverlast-security-lite' ), __( 'Configuration checks, local file evidence and explicit remediation. NeverLast Security never quarantines or trusts a finding automatically.', 'neverlast-security-lite' ) );
$health = NLSSL_Health::latest();
$storage = NLSSL_Core::storage_health();
$storage_action = self::take_admin_result( 'storage' );
$baseline_action = self::take_admin_result( 'baseline' );
$integrity_action = self::take_admin_result( 'integrity_action' );
echo '' . esc_html__( 'Security health', 'neverlast-security-lite' ) . '
' . esc_html__( 'Update information uses WordPress cached update state.', 'neverlast-security-lite' ) . '
';
self::action_form( 'nlssl_run_health', 'nlssl_run_health', __( 'Run health check', 'neverlast-security-lite' ), true );
echo '
';
if ( empty( $storage['ok'] ) ) {
echo '' . esc_html__( 'NeverLast Security storage is degraded.', 'neverlast-security-lite' ) . ' ';
if ( ! empty( $storage['missing'] ) ) { echo esc_html( __( 'Missing or invalid schema items: ', 'neverlast-security-lite' ) . implode( ', ', (array) $storage['missing'] ) ); }
if ( ! empty( $storage['degraded']['reason'] ) ) { echo ' ' . esc_html( __( 'Recent failure: ', 'neverlast-security-lite' ) . sanitize_key( (string) $storage['degraded']['reason'] ) ); }
echo '
';
self::action_form( 'nlssl_repair_storage', 'nlssl_repair_storage', __( 'Repair NeverLast Security storage', 'neverlast-security-lite' ), true );
echo '
';
}
if ( $storage_action ) { self::diagnostic_box( __( 'Storage repair result', 'neverlast-security-lite' ), $storage_action ); }
if ( empty( $health ) ) { echo '' . esc_html__( 'No health result is available yet.', 'neverlast-security-lite' ) . '
'; }
else {
echo '' . esc_html( (string) $health['score'] ) . '' . esc_html__( 'out of 100', 'neverlast-security-lite' ) . '
';
echo '| ' . esc_html__( 'Check', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Result', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Evidence and next step', 'neverlast-security-lite' ) . ' |
';
foreach ( $health['checks'] as $check ) { echo '' . esc_html( $check['title'] ) . ' ' . esc_html( ucfirst( $check['severity'] ) ) . ' | ' . esc_html( ucfirst( $check['status'] ) ) . ' | ' . esc_html( $check['summary'] ) . ' ' . esc_html( $check['recommendation'] ) . ' |
'; }
echo '
';
}
echo '';
$latest = NLSSL_Integrity::latest();
$baseline_count = NLSSL_Integrity::baseline_count();
echo '' . esc_html__( 'Integrity evidence and remediation', 'neverlast-security-lite' ) . '
' . esc_html__( 'A baseline proves change since approval, not vendor authenticity. Remediation requires an explicit administrator action.', 'neverlast-security-lite' ) . '
';
if ( NLSSL_Integrity::has_baseline() ) { self::action_form( 'nlssl_run_integrity', 'nlssl_run_integrity', NLSSL_Integrity::scan_resume_pending() ? __( 'Continue local comparison', 'neverlast-security-lite' ) : __( 'Run local comparison', 'neverlast-security-lite' ), true ); }
echo '
';
echo '' . esc_html__( 'Baseline:', 'neverlast-security-lite' ) . ' ' . ( $baseline_count < 0 ? esc_html__( 'storage unavailable - baseline status unknown', 'neverlast-security-lite' ) : ( $baseline_count > 0 ? esc_html( sprintf( /* translators: %1$s: date and time the baseline was approved, %2$s: number of files in the baseline */ __( '%1$s - %2$s files recorded', 'neverlast-security-lite' ), (string) get_option( NLSSL_Integrity::BASELINE_TIME_OPTION, __( 'available', 'neverlast-security-lite' ) ), number_format_i18n( $baseline_count ) ) ) : esc_html__( 'not created', 'neverlast-security-lite' ) ) ) . '
';
echo '' . esc_html__( 'Scheduled local comparison:', 'neverlast-security-lite' ) . ' ' . esc_html( NLSSL_Core::setting( 'integrity_schedule_enabled', 1 ) ? __( 'Enabled', 'neverlast-security-lite' ) : __( 'Disabled', 'neverlast-security-lite' ) ) . '
';
if ( get_option( NLSSL_Integrity::BASELINE_STALE_OPTION, 0 ) ) { echo '' . esc_html__( 'A component update occurred after baseline approval. Run a scan, review the package changes, then approve a replacement baseline only if expected.', 'neverlast-security-lite' ) . '
'; }
if ( $baseline_action ) { self::diagnostic_box( __( 'Baseline operation', 'neverlast-security-lite' ), $baseline_action ); }
if ( $integrity_action ) { self::diagnostic_box( __( 'Integrity action', 'neverlast-security-lite' ), $integrity_action ); }
$baseline_pending = NLSSL_Integrity::baseline_resume_pending();
$baseline_status = $baseline_pending ? NLSSL_Integrity::baseline_build_status() : array();
if ( $baseline_pending ) {
$phase = sanitize_key( (string) ( $baseline_status['phase'] ?? 'collect' ) );
$commit_reason = sanitize_key( (string) ( $baseline_status['commit_reason'] ?? '' ) );
$notice_class = ! empty( $baseline_status['manual_resume_only'] ) ? 'notice-warning' : 'notice-info';
$title = ! empty( $baseline_status['manual_resume_only'] ) ? __( 'Baseline build paused at final commit.', 'neverlast-security-lite' ) : __( 'Baseline build in progress.', 'neverlast-security-lite' );
echo '' . esc_html( $title ) . ' ' . esc_html( sprintf( /* translators: %1$s: build phase name, %2$d: number of staged files, %3$d: number of re-verified files */ __( 'Phase: %1$s; staged files: %2$d; verified: %3$d. The previously approved baseline remains active until final atomic commit.', 'neverlast-security-lite' ), $phase, (int) ( $baseline_status['files'] ?? 0 ), (int) ( $baseline_status['verified'] ?? 0 ) ) );
if ( $commit_reason ) { echo ' ' . esc_html__( 'Commit reason:', 'neverlast-security-lite' ) . ' ' . esc_html( $commit_reason ) . '.'; }
echo '
';
}
echo '';
if ( ! empty( $latest ) ) {
echo '' . esc_html__( 'Latest scan', 'neverlast-security-lite' ) . '
' . esc_html( (string) ( $latest['message'] ?? '' ) ) . '
';
echo '' . esc_html( sprintf( /* translators: %1$s: number of files checked, %2$s: number of uploads checked, %3$s: number of trusted findings hidden */ __( 'Files checked: %1$s; uploads checked: %2$s; trusted exact-hash findings hidden: %3$s.', 'neverlast-security-lite' ), number_format_i18n( (int) ( $latest['files_checked'] ?? 0 ) ), number_format_i18n( (int) ( $latest['uploads_files_checked'] ?? 0 ) ), number_format_i18n( (int) ( $latest['trusted_count'] ?? 0 ) ) ) ) . '
';
if ( ! empty( $latest['resume_pending'] ) ) {
if ( 'collect' === (string) ( $latest['scan_phase'] ?? '' ) || 'code_resume_pending' === (string) ( $latest['reason'] ?? '' ) ) {
echo '' . esc_html__( 'Code inventory progress:', 'neverlast-security-lite' ) . ' ' . esc_html( sprintf( /* translators: %d: number of tracked files staged so far */ __( '%d tracked file(s) staged so far. Progress is persisted and resumes from the last completed entry.', 'neverlast-security-lite' ), (int) ( $latest['files_checked'] ?? 0 ) ) ) . '
';
} else {
echo '' . esc_html__( 'Upload scan progress:', 'neverlast-security-lite' ) . ' ' . esc_html( sprintf( /* translators: %d: number of upload files inspected so far */ __( '%d upload file(s) inspected so far. Progress is persisted and resumes from the last completed entry.', 'neverlast-security-lite' ), (int) ( $latest['uploads_files_checked'] ?? 0 ) ) ) . '
';
}
}
if ( ! empty( $latest['reason'] ) ) { echo '' . esc_html__( 'Incomplete reason:', 'neverlast-security-lite' ) . ' ' . esc_html( sanitize_key( (string) $latest['reason'] ) ) . '
'; }
if ( ! empty( $latest['changes'] ) ) {
echo '';
foreach ( $latest['changes'] as $finding ) {
$id = (string) ( $finding['id'] ?? '' ); $classification = (string) ( $finding['classification'] ?? $finding['type'] ?? 'review' ); $confidence = (string) ( $finding['confidence'] ?? 'medium' );
echo '
' . esc_html( (string) $finding['path'] ) . '';
if ( ! empty( $finding['file_hash'] ) ) { echo '
SHA-256 ' . esc_html( substr( (string) $finding['file_hash'], 0, 16 ) ) . '…'; }
echo '
' . esc_html( (string) $finding['severity'] ) . ' ' . esc_html( $classification ) . '
' . esc_html__( 'Confidence:', 'neverlast-security-lite' ) . ' ' . esc_html( $confidence ) . '
';
echo '' . esc_html__( 'Evidence', 'neverlast-security-lite' ) . '';
$observations = ! empty( $finding['observations'] ) && is_array( $finding['observations'] ) ? $finding['observations'] : array( $finding );
foreach ( $observations as $obs ) {
echo '
' . esc_html( (string) ( $obs['type'] ?? $finding['type'] ?? 'review' ) ) . ' — ' . esc_html( (string) ( $obs['evidence'] ?? '' ) );
if ( ! empty( $obs['signal_id'] ) ) { echo '
' . esc_html__( 'Signal:', 'neverlast-security-lite' ) . ' ' . esc_html( (string) $obs['signal_id'] ) . ''; }
if ( isset( $obs['line'] ) && (int) $obs['line'] > 0 ) { echo '
' . esc_html( sprintf( /* translators: %d: line number */ __( 'line %d', 'neverlast-security-lite' ), (int) $obs['line'] ) ) . ''; }
elseif ( isset( $obs['offset'] ) && (int) $obs['offset'] >= 0 ) { echo '
' . esc_html( sprintf( /* translators: %d: byte offset within the file */ __( 'byte offset %d', 'neverlast-security-lite' ), (int) $obs['offset'] ) ) . ''; }
if ( ! empty( $obs['coverage'] ) ) { echo '
' . esc_html( __( 'Inspection coverage: ', 'neverlast-security-lite' ) . (string) $obs['coverage'] ) . ''; }
if ( ! empty( $obs['snippet'] ) ) { echo '
' . esc_html( (string) $obs['snippet'] ) . '
'; }
echo '
';
}
echo '
';
if ( ! empty( $finding['provenance'] ) && is_array( $finding['provenance'] ) ) {
$prov = $finding['provenance'];
echo '' . esc_html__( 'Provenance:', 'neverlast-security-lite' ) . ' ' . esc_html( (string) ( $prov['source'] ?? 'local_baseline' ) ) . ' /
' . esc_html( (string) ( $prov['status'] ?? 'unverified' ) ) . '';
if ( ! empty( $prov['component'] ) ) { echo ' — ' . esc_html( (string) $prov['component'] ); }
if ( ! empty( $prov['baseline_version'] ) || ! empty( $prov['version'] ) ) { echo '
' . esc_html( (string) ( $prov['baseline_version'] ?? '?' ) . ' → ' . (string) ( $prov['version'] ?? '?' ) ) . ''; }
if ( 'disabled' === (string) ( $prov['lookup'] ?? '' ) ) { echo '
' . esc_html__( 'Not compared with WordPress.org: that optional check is off. Turn on "Verify changed core and plugin files with WordPress.org checksums" under Protection to confirm whether this file matches the official release.', 'neverlast-security-lite' ) . ''; }
if ( ! empty( $finding['package_files'] ) && is_array( $finding['package_files'] ) ) { echo '
' . esc_html( sprintf( /* translators: %d: number of grouped file changes */ __( '%d grouped file change(s)', 'neverlast-security-lite' ), count( $finding['package_files'] ) ) ) . '
' . esc_html( implode( "\n", array_slice( $finding['package_files'], 0, 50 ) ) ) . '
'; }
echo '
';
}
$actions = (array) ( $finding['available_actions'] ?? array() );
if ( $actions ) {
echo '' . esc_html__( 'Available actions:', 'neverlast-security-lite' ) . ' ';
if ( preg_match( '/^[a-f0-9]{64}$/', $id ) && in_array( 'trust_exact_hash', $actions, true ) ) { self::finding_action_form( 'nlssl_integrity_trust', 'nlssl_integrity_trust', $id, __( 'Trust exact hash', 'neverlast-security-lite' ), 'confirm_trust', __( 'I reviewed this exact file hash and want NeverLast Security to suppress only this finding until the file content changes.', 'neverlast-security-lite' ) ); }
if ( preg_match( '/^[a-f0-9]{64}$/', $id ) && in_array( 'quarantine', $actions, true ) ) { self::finding_action_form( 'nlssl_integrity_quarantine', 'nlssl_integrity_quarantine', $id, __( 'Quarantine file', 'neverlast-security-lite' ), 'confirm_quarantine', __( 'I understand this removes the file from uploads and holds a protected copy that I must later export or delete.', 'neverlast-security-lite' ), true ); }
echo '
';
} else { echo '' . esc_html__( 'No automatic destructive action is available for this finding. Core/plugin/theme source and baseline-only changes require provenance review.', 'neverlast-security-lite' ) . '
'; }
echo '';
}
echo '
';
}
}
if ( empty( $latest ) && NLSSL_Integrity::has_baseline() ) { echo '' . esc_html__( 'No comparison has been run since this baseline was created. Use Run local comparison to record a visible result.', 'neverlast-security-lite' ) . '
'; }
$quarantine = NLSSL_Integrity::quarantine_items();
if ( $quarantine ) {
echo '' . esc_html__( 'NeverLast Security quarantine', 'neverlast-security-lite' ) . '
' . esc_html__( 'Files here were quarantined only after an explicit administrator action. Direct restoration of potentially executable files is disabled: export evidence for offline inspection or permanently delete it.', 'neverlast-security-lite' ) . '
';
echo '| ' . esc_html__( 'Original path', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Quarantined', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Action', 'neverlast-security-lite' ) . ' |
';
foreach ( $quarantine as $item ) { echo '' . esc_html( (string) ( $item['original_path'] ?? '' ) ) . ' SHA-256 ' . esc_html( substr( (string) ( $item['hash'] ?? '' ), 0, 16 ) ) . '… | ' . esc_html( (string) ( $item['quarantined_at'] ?? '' ) ) . ' | '; if ( ! empty( $item['held_copy_missing'] ) ) { echo '' . esc_html__( 'Held copy missing.', 'neverlast-security-lite' ) . ' ' . esc_html__( 'NeverLast Security never removes a held copy on its own. A host antivirus, a backup restore or a manual cleanup deleted it, so this file cannot be restored.', 'neverlast-security-lite' ) . ' '; self::clear_record_action_form( (string) ( $item['id'] ?? '' ) ); } else { self::download_action_form( (string) ( $item['id'] ?? '' ) ); self::purge_action_form( (string) ( $item['id'] ?? '' ) ); } echo ' |
'; }
echo '
';
}
echo '' . esc_html__( 'The comparison is local. A finding is evidence for review, not automatically a malware verdict. Exact-hash trust is revoked automatically by any content change.', 'neverlast-security-lite' ) . '
';
self::footer();
}
private static function diagnostic_box( string $title, array $result ): void {
echo '' . esc_html( $title ) . '';
if ( isset( $result['ok'] ) ) { echo ' — ' . esc_html( ! empty( $result['ok'] ) ? __( 'Success', 'neverlast-security-lite' ) : __( 'Failed', 'neverlast-security-lite' ) ); }
if ( ! empty( $result['code'] ) ) { echo '
' . esc_html__( 'Code:', 'neverlast-security-lite' ) . ' ' . esc_html( sanitize_key( (string) $result['code'] ) ) . ''; }
if ( ! empty( $result['reason'] ) ) { echo '
' . esc_html__( 'Reason:', 'neverlast-security-lite' ) . ' ' . esc_html( sanitize_key( (string) $result['reason'] ) ) . ''; }
if ( ! empty( $result['missing'] ) ) { echo '
' . esc_html__( 'Schema:', 'neverlast-security-lite' ) . ' ' . esc_html( implode( ', ', array_map( 'sanitize_text_field', (array) $result['missing'] ) ) ) . ''; }
if ( ! empty( $result['path'] ) ) { echo '
' . esc_html__( 'Path:', 'neverlast-security-lite' ) . ' ' . esc_html( (string) $result['path'] ) . ''; }
if ( ! empty( $result['hash'] ) ) { echo '
' . esc_html__( 'SHA-256:', 'neverlast-security-lite' ) . ' ' . esc_html( substr( (string) $result['hash'], 0, 20 ) ) . '…'; }
if ( ! empty( $result['message'] ) ) { echo '
' . esc_html( (string) $result['message'] ) . '
'; }
if ( ! empty( $result['timestamp'] ) ) { echo '
' . esc_html__( 'Time UTC:', 'neverlast-security-lite' ) . ' ' . esc_html( (string) $result['timestamp'] ) . ''; }
echo '
';
}
private static function finding_action_form( string $action, string $nonce, string $id, string $label, string $confirm_name = '', string $confirm_text = '', bool $danger = false ): void {
echo '';
}
private static function clear_record_action_form( string $id ): void {
if ( ! preg_match( '/^[a-f0-9]{64}$/', $id ) ) { return; }
echo '';
}
private static function purge_action_form( string $id ): void {
if ( ! preg_match( '/^[a-f0-9]{64}$/', $id ) ) { return; }
echo '';
}
private static function download_action_form( string $id ): void {
if ( ! preg_match( '/^[a-f0-9]{64}$/', $id ) ) { return; }
echo '';
}
private static function action_form( string $action, string $nonce, string $label, bool $primary = false ): void {
echo '';
}
public static function login(): void {
self::header( __( 'Login & TOTP', 'neverlast-security-lite' ), __( 'Protect administrator access and keep a tested recovery route.', 'neverlast-security-lite' ) );
$user_id = get_current_user_id();
$enabled = NLSSL_Two_Factor::enabled_for( $user_id );
$configured = NLSSL_Two_Factor::configured_for( $user_id );
$awaiting = NLSSL_Two_Factor::awaiting_recovery_confirmation( $user_id );
$pending = NLSSL_Two_Factor::pending_secret( $user_id );
$codes = NLSSL_Two_Factor::recovery_codes_once( $user_id );
echo '' . esc_html__( 'Authenticator app', 'neverlast-security-lite' ) . '
';
echo '' . esc_html__( 'Keep recovery codes offline before rotating WordPress AUTH_KEY/AUTH_SALT. NeverLast Security encrypts the TOTP seed from the authentication salt, so a salt rotation intentionally makes the existing seed unreadable and requires recovery plus re-enrollment.', 'neverlast-security-lite' ) . '
';
if ( ! NLSSL_Two_Factor::available() ) { echo '' . esc_html__( 'OpenSSL authenticated encryption is required before enrollment.', 'neverlast-security-lite' ) . '
'; }
elseif ( $configured && ! $awaiting ) {
echo '' . esc_html( $enabled ? __( 'TOTP is active for this account.', 'neverlast-security-lite' ) : __( 'An authenticator is configured, but login enforcement is disabled in Protection.', 'neverlast-security-lite' ) ) . ' ' . esc_html( sprintf( /* translators: %d: number of unused recovery codes */ _n( '%d recovery code remains.', '%d recovery codes remain.', NLSSL_Two_Factor::backup_count( $user_id ), 'neverlast-security-lite' ), NLSSL_Two_Factor::backup_count( $user_id ) ) ) . '
';
self::totp_maintenance_forms();
} elseif ( $awaiting ) {
echo '' . esc_html__( 'The app was verified, but login enforcement waits until recovery codes are acknowledged.', 'neverlast-security-lite' ) . '
';
self::totp_maintenance_forms();
} elseif ( '' !== $pending ) {
echo '' . esc_html__( 'Add this secret to Google Authenticator, Microsoft Authenticator, 1Password or another TOTP app.', 'neverlast-security-lite' ) . '
' . esc_html( $pending ) . '
';
echo '' . esc_html( NLSSL_Two_Factor::otpauth_uri( $user_id, $pending ) ) . '
';
echo '';
} else {
echo '' . esc_html__( 'Enrollment is voluntary and applies only to this administrator account.', 'neverlast-security-lite' ) . '
';
self::action_form( 'nlssl_totp_start', 'nlssl_totp_start', __( 'Start TOTP setup', 'neverlast-security-lite' ), true );
}
if ( ! empty( $codes ) ) {
echo '' . esc_html__( 'Recovery codes: shown once', 'neverlast-security-lite' ) . '
' . esc_html__( 'Store these offline. Each code works once.', 'neverlast-security-lite' ) . '
' . esc_html( implode( "\n", $codes ) ) . '
';
echo '
';
}
if ( $awaiting && NLSSL_Two_Factor::backup_count( $user_id ) > 0 ) {
if ( empty( $codes ) ) { echo '' . esc_html__( 'Recovery codes are not shown again after refreshing. If you did not store them, use your current app code to replace them before confirming.', 'neverlast-security-lite' ) . '
'; }
echo '';
}
echo '';
echo '
' . esc_html__( 'Emergency access', 'neverlast-security-lite' ) . '
';
if ( NLSSL_Login::is_frozen() ) {
echo '' . esc_html__( 'Emergency Login Freeze is active.', 'neverlast-security-lite' ) . '
';
self::action_form( 'nlssl_unfreeze_login', 'nlssl_unfreeze_login', __( 'Lift login freeze', 'neverlast-security-lite' ), true );
} else {
echo '' . esc_html__( 'Freeze invalidates every WordPress session, including yours, and blocks new logins temporarily. A completed administrator password reset lifts the freeze; TOTP still applies. The constant NLSSL_DISABLE_LOGIN_FREEZE can be used as an offline break-glass override.', 'neverlast-security-lite' ) . '
';
echo '';
}
echo '
' . esc_html__( 'End all sessions without freezing future logins.', 'neverlast-security-lite' ) . '
';
self::action_form( 'nlssl_logout_sessions', 'nlssl_logout_sessions', __( 'End all WordPress sessions', 'neverlast-security-lite' ) );
echo ' ';
echo '' . esc_html__( 'Administrator recovery', 'neverlast-security-lite' ) . '
' . esc_html__( 'A different administrator can reset TOTP for a locked-out account. Self-reset without a valid code is intentionally unavailable.', 'neverlast-security-lite' ) . '
';
foreach ( get_users( array( 'role' => 'administrator' ) ) as $administrator ) {
if ( (int) $administrator->ID === $user_id || ! NLSSL_Two_Factor::enabled_for( (int) $administrator->ID ) ) { continue; }
echo '';
}
echo '
';
self::footer();
}
private static function totp_maintenance_forms(): void {
echo '';
echo '
';
}
public static function alerts(): void {
self::header( __( 'Alerts & Recovery', 'neverlast-security-lite' ), __( 'Choose the mailbox and configure the optional secret-gated response link.', 'neverlast-security-lite' ) );
$s = NLSSL_Core::settings();
echo '';
echo '' . esc_html__( 'Delivery test', 'neverlast-security-lite' ) . '
' . esc_html__( 'This asks the existing WordPress mail transport to send one harmless test message. Success means WordPress accepted it, not that the mailbox received it.', 'neverlast-security-lite' ) . '
';
self::action_form( 'nlssl_test_email', 'nlssl_test_email', __( 'Send test email', 'neverlast-security-lite' ), true );
$mail_result = self::take_admin_result( 'mail' );
if ( $mail_result ) { self::diagnostic_box( __( 'Last delivery diagnostic', 'neverlast-security-lite' ), $mail_result ); }
echo '';
echo '
' . esc_html__( 'Separate emergency secret', 'neverlast-security-lite' ) . '
';
echo '' . esc_html__( 'Status:', 'neverlast-security-lite' ) . ' ' . esc_html( NLSSL_Emergency::configured() ? __( 'Configured', 'neverlast-security-lite' ) : __( 'Not configured', 'neverlast-security-lite' ) ) . '
';
echo '' . esc_html__( 'This is not your WordPress password. Store it offline. Changing it requires the current secret after the first setup.', 'neverlast-security-lite' ) . '
';
echo '' . esc_html__( 'Choose 6 to 128 characters. No uppercase, number or symbol is required. A longer unique secret phrase is recommended.', 'neverlast-security-lite' ) . '
';
echo ' ';
self::footer();
}
public static function reports(): void {
self::header( __( 'Reports', 'neverlast-security-lite' ), __( 'Download bounded local evidence for review or support without exporting secret values or IP hashes.', 'neverlast-security-lite' ) );
$clear_result = self::take_admin_result( 'reports_clear' );
$counts = self::event_counts();
echo '';
self::metric( __( 'Retained events', 'neverlast-security-lite' ), $counts['storage_available'] ? number_format_i18n( $counts['total'] ) : __( 'Unavailable', 'neverlast-security-lite' ), __( 'Maximum 5,000 rows', 'neverlast-security-lite' ), 'metric.retained_events' );
self::metric( __( 'Critical', 'neverlast-security-lite' ), $counts['storage_available'] ? number_format_i18n( $counts['critical'] ) : __( 'Unavailable', 'neverlast-security-lite' ), __( 'Locally retained', 'neverlast-security-lite' ), 'metric.critical_events' );
$count = NLSSL_Integrity::baseline_count();
self::metric( __( 'Baseline files', 'neverlast-security-lite' ), $count < 0 ? __( 'Unavailable', 'neverlast-security-lite' ) : number_format_i18n( $count ), __( 'Approved local state', 'neverlast-security-lite' ), 'metric.baseline_files' );
self::metric( __( 'Latest comparison', 'neverlast-security-lite' ), NLSSL_Integrity::latest() ? __( 'Available', 'neverlast-security-lite' ) : __( 'Not run', 'neverlast-security-lite' ), __( 'Read-only evidence', 'neverlast-security-lite' ), 'metric.latest_comparison' );
echo '
';
echo '
' . esc_html__( 'Security report', 'neverlast-security-lite' ) . '
' . esc_html__( 'JSON snapshot of settings, health, baseline state, latest integrity result and event totals. Email addresses and secret values are excluded.', 'neverlast-security-lite' ) . '
';
self::action_form( 'nlssl_export_report', 'nlssl_export_report', __( 'Download JSON report', 'neverlast-security-lite' ), true );
echo '' . esc_html__( 'Activity log', 'neverlast-security-lite' ) . '
' . esc_html__( 'CSV export of up to 5,000 retained events. It includes masked IP display values and redacted context, never the stored one-way IP hash.', 'neverlast-security-lite' ) . '
';
self::action_form( 'nlssl_export_activity', 'nlssl_export_activity', __( 'Download CSV log', 'neverlast-security-lite' ), true );
echo ' ';
if ( $clear_result ) { self::diagnostic_box( __( 'Clear reports result', 'neverlast-security-lite' ), $clear_result ); }
echo '' . esc_html__( 'Clear retained report data', 'neverlast-security-lite' ) . '
' . esc_html__( 'This removes the Activity Log plus the latest Health and Integrity report snapshots. It does not remove the approved baseline, trusted hashes, quarantine, settings, TOTP or recovery state.', 'neverlast-security-lite' ) . '
';
echo '';
self::footer();
}
public static function activity(): void {
self::header( __( 'Activity', 'neverlast-security-lite' ), __( 'Recent security-relevant events with bounded retention and redacted context.', 'neverlast-security-lite' ) );
global $wpdb;
// Read-only list filters, like the filters on WordPress's own list screens; they change nothing.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$severity = sanitize_key( self::input_scalar( $_GET, 'severity' ) );
$search = sanitize_text_field( self::input_scalar( $_GET, 's' ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$where = array( '1=1' ); $values = array();
if ( in_array( $severity, array( 'critical', 'warning', 'info' ), true ) ) { $where[] = 'severity = %s'; $values[] = $severity; }
if ( '' !== $search ) { $where[] = '(event_type LIKE %s OR message LIKE %s)'; $like = '%' . $wpdb->esc_like( $search ) . '%'; $values[] = $like; $values[] = $like; }
// Every WHERE fragment above is a fixed literal; the table, the user's filters and the limit are bound.
$sql = 'SELECT * FROM %i WHERE ' . implode( ' AND ', $where ) . ' ORDER BY id DESC LIMIT %d';
$rows = $wpdb->get_results( $wpdb->prepare( $sql, array_merge( array( NLSSL_Core::table( 'events' ) ), $values, array( 200 ) ) ), ARRAY_A ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- $sql is built only from fixed fragments; all values are bound by prepare(). Lists the plugin's own activity log with the administrator's filters; results must reflect the table as it is now.
$storage_error = ! is_array( $rows ) || ! empty( $wpdb->last_error );
if ( $storage_error ) { NLSSL_Core::degraded_storage( 'activity_page_read' ); $rows = array(); }
echo '';
self::action_form( 'nlssl_export_activity', 'nlssl_export_activity', __( 'Download retained log (CSV)', 'neverlast-security-lite' ) );
echo '| ' . esc_html__( 'Time UTC', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Severity', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Event', 'neverlast-security-lite' ) . ' | ' . esc_html__( 'Request', 'neverlast-security-lite' ) . ' |
';
foreach ( $rows as $row ) {
echo '| ' . esc_html( $row['created_at'] ) . ' | ' . esc_html( $row['severity'] ) . ' | ' . esc_html( $row['event_type'] ) . ' ' . esc_html( $row['message'] ) . ' | ' . esc_html( trim( $row['method'] . ' ' . $row['path'] ) ) . ' ' . esc_html( $row['ip_masked'] ) . ' |
';
}
if ( empty( $rows ) ) { echo '| ' . esc_html( $storage_error ? __( 'Security storage is unavailable; activity could not be read.', 'neverlast-security-lite' ) : __( 'No matching events.', 'neverlast-security-lite' ) ) . ' |
'; }
echo '
';
self::footer();
}
}