1, 'firewall_mode' => 'balanced', 'block_bad_user_agents' => 1, 'upload_guard_enabled' => 1, 'public_rate_limit_per_minute' => 0, 'verified_bot_rate_limit_bypass' => 0, 'verified_bot_duck_enabled' => 0, 'verified_bot_ahrefs_enabled' => 0, 'verified_bot_uptime_enabled' => 0, 'allowed_paths' => '', 'login_rate_limit_enabled' => 1, 'login_ip_rate_limit_enabled' => 0, 'login_max_attempts' => 5, 'login_window_minutes' => 15, 'login_lockout_minutes' => 30, 'login_freeze_minutes' => 60, 'generic_login_errors' => 1, 'block_admin_username' => 0, 'disable_xmlrpc' => 1, 'protect_rest_users' => 1, 'block_author_enumeration' => 1, 'disable_application_passwords'=> 1, 'admin_login_email' => 0, 'two_factor_enabled' => 1, 'security_headers_enabled' => 1, 'csp_report_only' => 0, 'hide_file_editor' => 1, 'activity_retention_days' => 7, 'critical_email_enabled' => 1, 'alert_email' => '', 'emergency_email_enabled' => 0, 'weekly_summary_enabled' => 0, 'integrity_schedule_enabled' => 1, 'wporg_checksums_enabled' => 0, 'delete_data_on_uninstall' => 0, ); } public static function settings(): array { $value = get_option( self::OPTION, array() ); return array_merge( self::defaults(), is_array( $value ) ? $value : array() ); } public static function setting( string $key, $fallback = null ) { $settings = self::settings(); return array_key_exists( $key, $settings ) ? $settings[ $key ] : $fallback; } public static function sanitize_settings( array $input ): array { $current = self::settings(); $booleans = array( 'firewall_enabled', 'block_bad_user_agents', 'upload_guard_enabled', 'verified_bot_rate_limit_bypass', 'verified_bot_duck_enabled', 'verified_bot_ahrefs_enabled', 'verified_bot_uptime_enabled', 'login_rate_limit_enabled', 'login_ip_rate_limit_enabled', 'generic_login_errors', 'block_admin_username', 'disable_xmlrpc', 'protect_rest_users', 'block_author_enumeration', 'disable_application_passwords', 'admin_login_email', 'two_factor_enabled', 'security_headers_enabled', 'csp_report_only', 'hide_file_editor', 'critical_email_enabled', 'weekly_summary_enabled', 'integrity_schedule_enabled', 'wporg_checksums_enabled', 'delete_data_on_uninstall', 'emergency_email_enabled', ); foreach ( $booleans as $key ) { if ( array_key_exists( $key, $input ) && is_scalar( $input[ $key ] ) ) { $current[ $key ] = empty( (string) $input[ $key ] ) ? 0 : 1; } } if ( array_key_exists( 'firewall_mode', $input ) && is_scalar( $input['firewall_mode'] ) ) { $mode = sanitize_key( (string) $input['firewall_mode'] ); $current['firewall_mode'] = in_array( $mode, array( 'monitor', 'balanced', 'strict' ), true ) ? $mode : 'balanced'; } foreach ( array( 'login_max_attempts' => array( 2, 20 ), 'login_window_minutes' => array( 1, 120 ), 'login_lockout_minutes' => array( 1, 1440 ), 'login_freeze_minutes' => array( 5, 1440 ), 'public_rate_limit_per_minute' => array( 0, 1000 ), 'activity_retention_days' => array( 1, 90 ), ) as $key => $bounds ) { if ( array_key_exists( $key, $input ) && is_scalar( $input[ $key ] ) ) { $current[ $key ] = self::bounded_int( $input[ $key ], $bounds[0], $bounds[1] ); } } if ( array_key_exists( 'allowed_paths', $input ) && is_scalar( $input['allowed_paths'] ) ) { $current['allowed_paths'] = self::sanitize_paths( (string) $input['allowed_paths'] ); } if ( array_key_exists( 'alert_email', $input ) && is_scalar( $input['alert_email'] ) ) { $email = sanitize_email( (string) $input['alert_email'] ); $current['alert_email'] = is_email( $email ) ? $email : ''; } return $current; } private static function bounded_int( $value, int $min, int $max ): int { return max( $min, min( $max, absint( $value ) ) ); } private static function sanitize_paths( string $raw ): string { $paths = array(); foreach ( preg_split( '/\R/', $raw ) ?: array() as $line ) { $line = trim( wp_unslash( $line ) ); if ( '' === $line || false !== strpos( $line, '..' ) ) { continue; } $path = '/' . ltrim( wp_parse_url( $line, PHP_URL_PATH ) ?: $line, '/' ); $path = preg_replace( '#/+#', '/', $path ); if ( is_string( $path ) && strlen( $path ) <= 180 ) { $paths[] = untrailingslashit( $path ) ?: '/'; } } return implode( "\n", array_values( array_unique( $paths ) ) ); } public static function activate( bool $network_wide = false ): void { if ( $network_wide || is_multisite() ) { wp_die( esc_html__( 'NeverLast Security Lite currently supports single-site WordPress only. Multisite activation is not supported.', 'neverlast-security-lite' ) ); return; } if ( self::pro_is_active() ) { if ( ! function_exists( 'deactivate_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } deactivate_plugins( plugin_basename( NLSSL_FILE ) ); wp_die( esc_html__( 'NeverLast Security Lite cannot be activated while NeverLast Security Pro is active.', 'neverlast-security-lite' ) ); } self::create_tables(); if ( false === get_option( self::OPTION, false ) ) { add_option( self::OPTION, self::defaults(), '', false ); } if ( self::storage_health()['schema_ok'] ) { update_option( 'nlssl_db_version', self::DB_VERSION, false ); } add_filter( 'cron_schedules', array( __CLASS__, 'cron_schedules' ) ); self::ensure_schedules(); remove_filter( 'cron_schedules', array( __CLASS__, 'cron_schedules' ) ); self::log( 'plugin_activated', 'info', 'NeverLast Security Lite was activated.' ); } private static function create_tables(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); $events = self::table( 'events' ); $rates = self::table( 'rate_limits' ); $challenges = self::table( '2fa_challenges' ); $baseline = self::table( 'file_baseline' ); $baseline_staging = self::table( 'file_baseline_staging' ); dbDelta( "CREATE TABLE {$events} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, created_at datetime NOT NULL, event_type varchar(64) NOT NULL, severity varchar(16) NOT NULL, message text NOT NULL, ip_hash char(64) NOT NULL DEFAULT '', ip_masked varchar(45) NOT NULL DEFAULT '', method varchar(10) NOT NULL DEFAULT '', path varchar(255) NOT NULL DEFAULT '', context longtext NULL, PRIMARY KEY (id), KEY created_at (created_at), KEY event_type (event_type), KEY severity (severity) ) {$charset};" ); dbDelta( "CREATE TABLE {$rates} ( rate_key char(64) NOT NULL, bucket varchar(96) NOT NULL, hits int(10) unsigned NOT NULL DEFAULT 0, expires_at bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (rate_key), KEY expires_at (expires_at), KEY bucket (bucket) ) {$charset};" ); dbDelta( "CREATE TABLE {$challenges} ( token_hash char(64) NOT NULL, user_id bigint(20) unsigned NOT NULL, created_at bigint(20) unsigned NOT NULL, expires_at bigint(20) unsigned NOT NULL, attempts tinyint(3) unsigned NOT NULL DEFAULT 0, consumed_at bigint(20) unsigned NOT NULL DEFAULT 0, remember_me tinyint(1) unsigned NOT NULL DEFAULT 0, state_hash char(64) NOT NULL DEFAULT '', redirect_to text NULL, PRIMARY KEY (token_hash), KEY user_id (user_id), KEY expires_at (expires_at) ) {$charset};" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Checks whether the plugin's baseline table already exists before migrating it; no WordPress API reports custom tables. $baseline_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $baseline ) ) ) === $baseline; if ( $baseline_exists && ! self::migrate_baseline_path_key( $baseline ) ) { self::degraded_storage( 'baseline_path_key_migration' ); } else { dbDelta( "CREATE TABLE {$baseline} ( path_hash char(64) NOT NULL, file_path varchar(512) NOT NULL, file_hash char(64) NOT NULL, file_size bigint(20) unsigned NOT NULL DEFAULT 0, modified_at bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (path_hash), KEY file_path (file_path(191)) ) ENGINE=InnoDB {$charset};" ); } dbDelta( "CREATE TABLE {$baseline_staging} ( job_id char(64) NOT NULL, path_hash char(64) NOT NULL, file_path varchar(512) NOT NULL, file_hash char(64) NOT NULL, file_size bigint(20) unsigned NOT NULL DEFAULT 0, modified_at bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (job_id,path_hash), KEY job_id (job_id), KEY file_path (file_path(191)) ) {$charset};" ); } private static function migrate_baseline_path_key( string $table ): bool { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time, verified schema migration of the plugin's own baseline table: backfill a hash column and move the primary key. WordPress has no API for altering custom tables, and dbDelta() cannot change a primary key. global $wpdb; if ( false !== strpos( $table, '`' ) ) { return false; } $columns = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ), 0 ); if ( ! is_array( $columns ) || ! empty( $wpdb->last_error ) ) { return false; } if ( ! in_array( 'path_hash', $columns, true ) ) { if ( false === $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD COLUMN path_hash char(64) NULL AFTER file_path', $table ) ) ) { return false; } } // Existing Lite baselines were keyed on file_path(191). Backfill a full-path SHA-256 // before changing the key so long paths that share a 191-character prefix can // coexist safely in future baselines. SHA2() returns the same lowercase hex form // as PHP hash('sha256', ...). if ( false === $wpdb->query( $wpdb->prepare( "UPDATE %i SET path_hash = SHA2(file_path, 256) WHERE path_hash IS NULL OR path_hash = ''", $table ) ) ) { return false; } $missing = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE path_hash IS NULL OR path_hash = ''", $table ) ); if ( null === $missing || ! empty( $wpdb->last_error ) || (int) $missing > 0 ) { return false; } $indexes = $wpdb->get_results( $wpdb->prepare( 'SHOW INDEX FROM %i', $table ), ARRAY_A ); if ( ! is_array( $indexes ) || ! empty( $wpdb->last_error ) ) { return false; } $primary_columns = array(); $has_path_index = false; foreach ( $indexes as $index ) { if ( 'PRIMARY' === (string) ( $index['Key_name'] ?? '' ) ) { $primary_columns[ (int) ( $index['Seq_in_index'] ?? 0 ) ] = (string) ( $index['Column_name'] ?? '' ); } if ( 'file_path' === (string) ( $index['Key_name'] ?? '' ) ) { $has_path_index = true; } } ksort( $primary_columns ); $primary_columns = array_values( $primary_columns ); if ( array( 'path_hash' ) !== $primary_columns ) { $rekeyed = $primary_columns ? $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i DROP PRIMARY KEY, ADD PRIMARY KEY (path_hash)', $table ) ) : $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD PRIMARY KEY (path_hash)', $table ) ); if ( false === $rekeyed ) { return false; } } if ( ! $has_path_index && false === $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD KEY file_path (file_path(191))', $table ) ) ) { return false; } if ( false === $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i MODIFY path_hash char(64) NOT NULL', $table ) ) ) { return false; } return true; // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange } public static function boot(): void { if ( self::$booted ) { return; } self::$booted = true; if ( is_multisite() ) { add_action( 'admin_notices', array( __CLASS__, 'multisite_notice' ) ); add_action( 'network_admin_notices', array( __CLASS__, 'multisite_notice' ) ); return; } if ( self::pro_is_active() ) { add_action( 'admin_notices', array( __CLASS__, 'conflict_notice' ) ); return; } self::maybe_upgrade(); add_filter( 'cron_schedules', array( __CLASS__, 'cron_schedules' ) ); add_action( 'init', array( __CLASS__, 'ensure_schedules' ), 20 ); add_action( 'nlssl_daily_maintenance', array( __CLASS__, 'daily_maintenance' ) ); add_action( 'nlssl_weekly_summary', array( __CLASS__, 'weekly_summary' ) ); add_action( NLSSL_Integrity::UPLOAD_RESUME_HOOK, array( 'NLSSL_Integrity', 'resume_scheduled_scan' ) ); add_action( NLSSL_Integrity::BASELINE_RESUME_HOOK, array( 'NLSSL_Integrity', 'resume_scheduled_baseline' ) ); add_action( 'send_headers', array( __CLASS__, 'security_headers' ) ); add_action( 'admin_init', array( __CLASS__, 'privacy_policy' ) ); self::register_activity_hooks(); NLSSL_Bots::boot(); NLSSL_Firewall::boot(); NLSSL_Login::boot(); NLSSL_Emergency::boot(); NLSSL_Two_Factor::boot(); NLSSL_Admin::boot(); } private static function maybe_upgrade(): void { $upgrade = self::DB_VERSION !== (string) get_option( 'nlssl_db_version', '' ); if ( ! $upgrade && get_transient( 'nlssl_schema_checked' ) ) { return; } $health = self::storage_health(); if ( ! $upgrade && $health['schema_ok'] ) { set_transient( 'nlssl_schema_checked', 1, HOUR_IN_SECONDS ); return; } if ( get_transient( 'nlssl_schema_repair_backoff' ) ) { return; } set_transient( 'nlssl_schema_repair_backoff', 1, 10 * MINUTE_IN_SECONDS ); self::create_tables(); if ( self::storage_health()['schema_ok'] ) { delete_transient( 'nlssl_storage_degraded' ); self::$storage_degraded = false; update_option( 'nlssl_db_version', self::DB_VERSION, false ); set_transient( 'nlssl_schema_checked', 1, HOUR_IN_SECONDS ); } } public static function repair_storage(): array { delete_transient( 'nlssl_schema_repair_backoff' ); self::$storage_degraded = false; self::create_tables(); $health = self::storage_health(); if ( empty( $health['schema_ok'] ) ) { return array( 'ok' => false, 'reason' => 'schema', 'missing' => (array) ( $health['missing'] ?? array() ) ); } $probe = 'nlssl_storage_probe_' . substr( hash( 'sha256', wp_generate_password( 32, true, true ) . microtime( true ) ), 0, 20 ); $value = array( 'at' => time(), 'token' => wp_generate_password( 20, false, false ) ); if ( ! self::write_option_verified( $probe, $value ) ) { // The write may have reached the table even though it could not be confirmed. Uninstall removes any probe // left behind by prefix. delete_option( $probe ); return array( 'ok' => false, 'reason' => 'option_probe_write', 'missing' => array() ); } delete_option( $probe ); $deleted = self::persisted_option( $probe, null ); if ( is_wp_error( $deleted ) || null !== $deleted ) { self::degraded_storage( 'option_probe_delete' ); return array( 'ok' => false, 'reason' => 'option_probe_delete', 'missing' => array() ); } delete_transient( 'nlssl_storage_degraded' ); self::$storage_degraded = false; update_option( 'nlssl_db_version', self::DB_VERSION, false ); set_transient( 'nlssl_schema_checked', 1, HOUR_IN_SECONDS ); return array( 'ok' => true, 'reason' => '', 'missing' => array() ); } public static function multisite_notice(): void { if ( ! current_user_can( 'activate_plugins' ) ) { return; } echo '

' . esc_html__( 'NeverLast Security Lite is paused on WordPress multisite. This release supports single-site installations only.', 'neverlast-security-lite' ) . '

'; } public static function storage_health(): array { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verifies that the plugin's own tables, columns and indexes exist. No WordPress API describes custom table schema, and a cached answer would defeat a health check. global $wpdb; $missing = array(); $required = array( 'events' => array( 'id', 'created_at', 'event_type', 'severity', 'message', 'ip_hash', 'ip_masked', 'method', 'path', 'context' ), 'rate_limits' => array( 'rate_key', 'bucket', 'hits', 'expires_at' ), '2fa_challenges' => array( 'token_hash', 'user_id', 'created_at', 'expires_at', 'attempts', 'consumed_at', 'remember_me', 'state_hash', 'redirect_to' ), 'file_baseline' => array( 'path_hash', 'file_path', 'file_hash', 'file_size', 'modified_at' ), 'file_baseline_staging' => array( 'job_id', 'path_hash', 'file_path', 'file_hash', 'file_size', 'modified_at' ), ); foreach ( $required as $name => $required_columns ) { $table = self::table( $name ); $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); if ( $found !== $table ) { $missing[] = $name; continue; } if ( false !== strpos( $table, '`' ) ) { $missing[] = $name . '.identifier'; continue; } $columns = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ), 0 ); if ( ! is_array( $columns ) || ! empty( $wpdb->last_error ) ) { $missing[] = $name . '.columns'; continue; } foreach ( array_diff( $required_columns, $columns ) as $column ) { $missing[] = $name . '.' . $column; } if ( in_array( $name, array( 'file_baseline', 'file_baseline_staging' ), true ) && in_array( 'path_hash', $columns, true ) ) { $indexes = $wpdb->get_results( $wpdb->prepare( 'SHOW INDEX FROM %i', $table ), ARRAY_A ); if ( ! is_array( $indexes ) || ! empty( $wpdb->last_error ) ) { $missing[] = $name . '.indexes'; continue; } $primary = array(); foreach ( $indexes as $index ) { if ( 'PRIMARY' === (string) ( $index['Key_name'] ?? '' ) ) { $primary[ (int) ( $index['Seq_in_index'] ?? 0 ) ] = (string) ( $index['Column_name'] ?? '' ); } } ksort( $primary ); $expected_primary = 'file_baseline_staging' === $name ? array( 'job_id', 'path_hash' ) : array( 'path_hash' ); if ( $expected_primary !== array_values( $primary ) ) { $missing[] = $name . '.primary'; } } } if ( $missing ) { self::degraded_storage( 'schema' ); } $degraded = get_transient( 'nlssl_storage_degraded' ); return array( 'ok' => empty( $missing ) && ! self::$storage_degraded && ! $degraded, 'schema_ok' => empty( $missing ), 'missing' => $missing, 'degraded' => $degraded ?: self::$storage_degraded ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } public static function degraded_storage( string $reason ): void { self::$storage_degraded = true; if ( 'schema' === $reason ) { delete_transient( 'nlssl_schema_checked' ); } if ( ! get_transient( 'nlssl_storage_degraded' ) ) { delete_transient( 'nlssl_schema_checked' ); set_transient( 'nlssl_storage_degraded', array( 'reason' => $reason, 'at' => time() ), HOUR_IN_SECONDS ); } } public static function clear_degraded_storage( array $reasons = array() ): bool { $current = get_transient( 'nlssl_storage_degraded' ); $current_reason = is_array( $current ) ? sanitize_key( (string) ( $current['reason'] ?? '' ) ) : ''; if ( $reasons && $current && ! in_array( $current_reason, array_map( 'sanitize_key', $reasons ), true ) ) { return false; } delete_transient( 'nlssl_storage_degraded' ); self::$storage_degraded = false; if ( self::storage_health()['schema_ok'] ) { set_transient( 'nlssl_schema_checked', 1, HOUR_IN_SECONDS ); } return true; } public static function deactivate(): void { self::log( 'plugin_deactivated', 'info', 'NeverLast Security Lite was deactivated.' ); wp_clear_scheduled_hook( 'nlssl_daily_maintenance' ); wp_clear_scheduled_hook( 'nlssl_weekly_summary' ); wp_clear_scheduled_hook( NLSSL_Integrity::UPLOAD_RESUME_HOOK ); delete_option( NLSSL_Integrity::UPLOAD_STATE_OPTION ); delete_option( NLSSL_Integrity::SCAN_STATE_OPTION ); wp_clear_scheduled_hook( NLSSL_Integrity::BASELINE_RESUME_HOOK ); NLSSL_Integrity::clear_baseline_build_state( true ); if ( function_exists( 'wp_unschedule_hook' ) ) { wp_unschedule_hook( NLSSL_Bots::CRON_HOOK ); } delete_transient( NLSSL_Bots::QUEUE_KEY ); wp_clear_scheduled_hook( NLSSL_Bots::IP_CRON_HOOK ); delete_transient( 'nlssl_bot_ip_refresh_cooldown' ); NLSSL_Bots::stop_audit_window(); } public static function pro_is_active(): bool { // The Pro edition is a separate installed product with its own folder name; this literal must // match that folder exactly or Lite stops detecting Pro running alongside it. $target = 'neverlast-sentinel-security-pro/neverlast-sentinel-security-pro.php'; $active = (array) get_option( 'active_plugins', array() ); if ( in_array( $target, $active, true ) ) { return true; } if ( is_multisite() ) { $network = (array) get_site_option( 'active_sitewide_plugins', array() ); return isset( $network[ $target ] ); } return false; } public static function conflict_notice(): void { if ( ! current_user_can( 'activate_plugins' ) ) { return; } echo '

' . esc_html__( 'NeverLast Security Lite is paused because NeverLast Security Pro is active. Deactivate one of the two products.', 'neverlast-security-lite' ) . '

'; } public static function cron_schedules( array $schedules ): array { if ( ! isset( $schedules['nlssl_weekly'] ) ) { $schedules['nlssl_weekly'] = array( 'interval' => WEEK_IN_SECONDS, 'display' => 'NeverLast Security Lite weekly' ); } return $schedules; } public static function ensure_schedules(): void { if ( ! wp_next_scheduled( 'nlssl_daily_maintenance' ) ) { if ( false === wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'nlssl_daily_maintenance' ) ) { self::degraded_storage( 'cron_daily_schedule' ); } } if ( ! wp_next_scheduled( 'nlssl_weekly_summary' ) ) { if ( false === wp_schedule_event( time() + DAY_IN_SECONDS, 'nlssl_weekly', 'nlssl_weekly_summary' ) ) { self::degraded_storage( 'cron_weekly_schedule' ); } } NLSSL_Integrity::ensure_baseline_resume_schedule(); } public static function daily_maintenance(): void { NLSSL_Bots::refresh_ip_lists(); self::cleanup(); $health = NLSSL_Health::run(); if ( self::setting( 'integrity_schedule_enabled', 1 ) && NLSSL_Integrity::has_baseline() ) { $integrity = NLSSL_Integrity::scan(); if ( ! empty( $integrity['critical_count'] ) ) { self::alert( 'integrity-critical', 'NeverLast Security Lite detected critical file changes', 'Review the Health & Integrity page. NeverLast Security Lite did not modify any file.' ); } } if ( (int) ( $health['critical_count'] ?? 0 ) > 0 ) { self::alert( 'health-critical', 'NeverLast Security Lite found a critical configuration issue', 'Review the NeverLast Security Lite health report in WordPress administration.' ); } } public static function weekly_summary(): void { if ( ! self::setting( 'weekly_summary_enabled', 0 ) ) { return; } global $wpdb; $since = gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Totals from the plugin's own activity log for the weekly email; they must reflect the table as it is now. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT severity, COUNT(*) AS total FROM %i WHERE created_at >= %s GROUP BY severity', self::table( 'events' ), $since ), ARRAY_A ); if ( ! is_array( $rows ) || ! empty( $wpdb->last_error ) ) { self::degraded_storage( 'weekly_summary_read' ); return; } $counts = array( 'critical' => 0, 'warning' => 0, 'info' => 0 ); foreach ( $rows as $row ) { $counts[ (string) $row['severity'] ] = (int) $row['total']; } $body = sprintf( "Weekly NeverLast Security Lite summary for %s\n\nCritical: %d\nWarnings: %d\nInformation: %d\n\nOpen WordPress administration for details.", home_url(), $counts['critical'], $counts['warning'], $counts['info'] ); self::send_email( 'NeverLast Security Lite weekly security summary', $body, 'weekly_summary' ); } public static function security_headers(): void { if ( ! self::setting( 'security_headers_enabled', 1 ) || headers_sent() ) { return; } // Never replace a policy already supplied by WordPress, the host or another // integration at PHP level. NeverLast Security supplies a baseline only when absent. self::header_if_absent( 'X-Content-Type-Options', 'nosniff' ); self::header_if_absent( 'X-Frame-Options', 'SAMEORIGIN' ); self::header_if_absent( 'Referrer-Policy', 'strict-origin-when-cross-origin' ); self::header_if_absent( 'Permissions-Policy', 'geolocation=(), microphone=(), camera=()' ); if ( self::setting( 'csp_report_only', 0 ) ) { self::header_if_absent( 'Content-Security-Policy-Report-Only', "default-src 'self' https: data: blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" ); } } private static function header_if_absent( string $name, string $value ): void { $needle = strtolower( $name ) . ':'; foreach ( headers_list() as $header ) { if ( str_starts_with( strtolower( trim( (string) $header ) ), $needle ) ) { return; } } header( $name . ': ' . $value ); } public static function privacy_policy(): void { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } wp_add_privacy_policy_content( 'NeverLast Security Lite', '

' . esc_html__( 'NeverLast Security Lite stores security events locally in the WordPress database. It records a masked IP address, a salted IP hash, request method/path and bounded event context. It does not transmit this security-event information to NeverLast or another external service.', 'neverlast-security-lite' ) . '

' . '

' . esc_html__( 'If an administrator explicitly enables an official crawler IP list, the site server periodically makes an HTTPS request to that provider to refresh public network ranges. The provider can observe the server source IP and normal HTTP request metadata; visitor security-event records, credentials and NeverLast Security settings are not included.', 'neverlast-security-lite' ) . '

' ); } private static function register_activity_hooks(): void { add_action( 'wp_login', array( __CLASS__, 'event_login' ), 10, 2 ); add_action( 'wp_logout', array( __CLASS__, 'event_logout' ) ); add_action( 'user_register', array( __CLASS__, 'event_user_created' ) ); add_action( 'deleted_user', array( __CLASS__, 'event_user_deleted' ) ); add_action( 'set_user_role', array( __CLASS__, 'event_role_changed' ), 10, 3 ); add_action( 'activated_plugin', array( __CLASS__, 'event_plugin_activated' ), 10, 2 ); add_action( 'deactivated_plugin', array( __CLASS__, 'event_plugin_deactivated' ), 10, 2 ); add_action( 'deleted_plugin', array( __CLASS__, 'event_plugin_deleted' ), 10, 2 ); add_action( 'switch_theme', array( __CLASS__, 'event_theme_switched' ), 10, 3 ); add_action( 'updated_option', array( __CLASS__, 'event_critical_option' ), 10, 3 ); add_action( 'upgrader_process_complete', array( __CLASS__, 'event_update' ), 10, 2 ); } public static function event_login( string $login, WP_User $user ): void { self::log( 'login_success', 'info', 'Successful login.', array( 'user_id' => $user->ID, 'administrator' => user_can( $user, 'manage_options' ) ) ); } public static function event_logout(): void { self::log( 'logout', 'info', 'User logged out.', array( 'user_id' => get_current_user_id() ) ); } public static function event_user_created( int $user_id ): void { self::log( 'user_created', 'warning', 'A user account was created.', array( 'user_id' => $user_id ) ); } public static function event_user_deleted( int $user_id ): void { self::log( 'user_deleted', 'warning', 'A user account was deleted.', array( 'user_id' => $user_id ) ); } public static function event_role_changed( int $user_id, string $role, array $old_roles ): void { self::log( 'user_role_changed', 'warning', 'A user role changed.', array( 'user_id' => $user_id, 'new_role' => $role, 'old_roles' => $old_roles ) ); } public static function event_plugin_activated( string $plugin, bool $network_wide ): void { self::log( 'plugin_activated', 'warning', 'A plugin was activated.', array( 'plugin' => $plugin, 'network' => $network_wide ) ); } public static function event_plugin_deactivated( string $plugin, bool $network_wide ): void { self::log( 'plugin_deactivated', 'warning', 'A plugin was deactivated.', array( 'plugin' => $plugin, 'network' => $network_wide ) ); } public static function event_plugin_deleted( string $plugin, bool $deleted ): void { if ( $deleted ) { self::log( 'plugin_deleted', 'warning', 'A plugin was deleted.', array( 'plugin' => $plugin ) ); } } public static function event_theme_switched( string $new_name, WP_Theme $new_theme, WP_Theme $old_theme ): void { self::log( 'theme_switched', 'warning', 'The active theme changed.', array( 'new_theme' => $new_theme->get_stylesheet(), 'old_theme' => $old_theme->get_stylesheet() ) ); } public static function event_critical_option( string $option, $old_value, $new_value ): void { $watched = array( 'siteurl', 'home', 'users_can_register', 'default_role', 'permalink_structure', 'admin_email' ); if ( in_array( $option, $watched, true ) && $old_value !== $new_value ) { self::log( 'critical_option_changed', 'warning', 'A security-relevant WordPress option changed.', array( 'option' => $option, 'actor' => get_current_user_id() ) ); } } public static function event_update( $upgrader, array $details ): void { $type = sanitize_key( (string) ( $details['type'] ?? 'unknown' ) ); self::log( 'component_updated', 'warning', 'WordPress completed an update operation.', array( 'type' => $type, 'action' => sanitize_key( (string) ( $details['action'] ?? '' ) ) ) ); if ( in_array( $type, array( 'core', 'plugin', 'theme' ), true ) && ! NLSSL_Integrity::mark_stale() ) { self::log( 'integrity_baseline_stale_failed', 'critical', 'The integrity baseline could not be marked stale after an update.', array( 'type' => $type ) ); } } public static function table( string $name ): string { global $wpdb; $allowed = array( 'events', 'rate_limits', '2fa_challenges', 'file_baseline', 'file_baseline_staging' ); if ( ! in_array( $name, $allowed, true ) ) { throw new InvalidArgumentException( 'Unknown NeverLast Security Lite table.' ); } return $wpdb->prefix . 'nlssl_' . $name; } public static function log( string $event, string $severity, string $message, array $context = array() ): void { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Writes to and trims the plugin's own activity log; an insert and a bounded delete have nothing to cache. global $wpdb; $severity = in_array( $severity, array( 'critical', 'warning', 'info' ), true ) ? $severity : 'info'; $group = 'event_' . sanitize_key( $event ); // Global event-type throttles keep distributed sources from allocating per-IP log counters. if ( self::rate_hit( $group, MINUTE_IN_SECONDS, 20, false ) ) { return; } $inserted = $wpdb->insert( self::table( 'events' ), array( 'created_at' => gmdate( 'Y-m-d H:i:s' ), 'event_type' => sanitize_key( $event ), 'severity' => $severity, 'message' => sanitize_text_field( $message ), 'ip_hash' => self::ip_hash(), 'ip_masked' => self::masked_ip(), 'method' => substr( sanitize_key( (string) ( $_SERVER['REQUEST_METHOD'] ?? '' ) ), 0, 10 ), 'path' => substr( self::request_path(), 0, 255 ), 'context' => wp_json_encode( self::redact_context( $context ) ), ), array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); if ( false === $inserted ) { self::degraded_storage( 'events_write' ); return; } $id = (int) ( $wpdb->insert_id ?? 0 ); if ( $id > self::MAX_EVENT_ROWS ) { // An indexed, bounded trim runs on insertion, independent of traffic-driven WP-Cron. if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id <= %d LIMIT 200', self::table( 'events' ), $id - self::MAX_EVENT_ROWS ) ) ) { self::degraded_storage( 'events_trim' ); } } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } private static function redact_context( array $context, int $depth = 0 ): array { if ( $depth > 2 ) { return array(); } $out = array(); foreach ( array_slice( $context, 0, 30, true ) as $key => $value ) { $key = sanitize_key( (string) $key ); if ( preg_match( '/pass|secret|token|nonce|cookie|authorization|license|recovery|code/i', $key ) ) { $out[ $key ] = '[redacted]'; continue; } if ( is_array( $value ) ) { $out[ $key ] = self::redact_context( $value, $depth + 1 ); } elseif ( is_bool( $value ) || is_int( $value ) || is_float( $value ) ) { $out[ $key ] = $value; } else { $out[ $key ] = substr( sanitize_text_field( (string) $value ), 0, 300 ); } } return $out; } public static function request_path(): string { // Only the path is kept, for exact comparisons and bounded, escaped logging. Sanitizing would remove percent // escapes and characters an attacker sent, so a logged path would no longer show what was requested. $uri = (string) wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $path = wp_parse_url( $uri, PHP_URL_PATH ); return is_string( $path ) && '' !== $path ? $path : '/'; } public static function client_ip(): string { $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ); return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : ''; } public static function ip_hash(): string { $ip = self::client_ip(); return '' === $ip ? '' : hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) ); } public static function masked_ip(): string { $ip = self::client_ip(); if ( false !== filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { $parts = explode( '.', $ip ); $parts[3] = '0'; return implode( '.', $parts ); } if ( false !== filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { $packed = inet_pton( $ip ); if ( false !== $packed ) { return inet_ntop( substr( $packed, 0, 8 ) . str_repeat( "\0", 8 ) ) ?: ''; } } return ''; } public static function persisted_option( string $name, $default = false ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads the stored option value directly on purpose: after a write, security code must confirm what is persisted, which get_option() may answer from the object cache. $row = $wpdb->get_row( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ), ARRAY_A ); if ( ! empty( $wpdb->last_error ) ) { self::degraded_storage( 'option_read' ); return new WP_Error( 'nlssl_storage_failed', __( 'Security storage could not be verified. No success was confirmed.', 'neverlast-security-lite' ) ); } return is_array( $row ) ? maybe_unserialize( $row['option_value'] ) : $default; } public static function write_option_verified( string $name, $value ): bool { update_option( $name, $value, false ); $stored = self::persisted_option( $name, new stdClass() ); // WordPress stores scalar options as strings and can return false for an unchanged value. $ok = ! is_wp_error( $stored ) && ( is_scalar( $value ) ? ( is_scalar( $stored ) && (string) $stored === (string) $value ) : $stored === $value ); if ( ! $ok ) { self::degraded_storage( 'option_write' ); wp_cache_delete( $name, 'options' ); wp_cache_delete( 'alloptions', 'options' ); } return $ok; } public static function write_transient_verified( string $name, $value, int $expiration ): bool { // set_transient() can legitimately return false when an equivalent value already // exists. Verify the observable value instead of trusting that return code. set_transient( $name, $value, max( 1, $expiration ) ); $stored = get_transient( $name ); return is_scalar( $value ) ? is_scalar( $stored ) && (string) $stored === (string) $value : $stored === $value; } private static function fallback_option( string $bucket ): string { return match ( self::rate_class( $bucket ) ) { 'login' => 'nlssl_login_rate_fallback', 'emergency' => 'nlssl_emergency_rate_fallback', 'event' => 'nlssl_event_rate_fallback', 'public' => 'nlssl_public_rate_fallback', default => 'nlssl_rate_fallback', }; } private static function rate_class( string $bucket ): string { $bucket = sanitize_key( $bucket ); if ( str_starts_with( $bucket, 'login_' ) ) { return 'login'; } if ( 'emergency_token_claim' === $bucket ) { return 'claim'; } if ( str_starts_with( $bucket, 'emergency_' ) ) { return 'emergency'; } if ( str_starts_with( $bucket, 'event_' ) ) { return 'event'; } if ( 'public_request' === $bucket ) { return 'public'; } return 'general'; } /** * The first row beyond this function's share of the rate table, or null while there is room. Each capacity * class has its own complete, fixed statement, so no SQL is assembled at runtime. */ private static function rate_capacity_overflow( string $table, string $class ): ?string { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Capacity check on the plugin's own rate-limit table; a cached answer could let the table grow past its bound. global $wpdb; $login = $wpdb->esc_like( 'login_' ) . '%'; $emergency = $wpdb->esc_like( 'emergency_' ) . '%'; $event = $wpdb->esc_like( 'event_' ) . '%'; $offset = self::MAX_RATE_ROWS; $overflow = match ( $class ) { 'login' => $wpdb->get_var( $wpdb->prepare( 'SELECT rate_key FROM %i WHERE bucket LIKE %s LIMIT 1 OFFSET %d', $table, $login, $offset ) ), 'emergency' => $wpdb->get_var( $wpdb->prepare( 'SELECT rate_key FROM %i WHERE bucket LIKE %s AND bucket <> %s LIMIT 1 OFFSET %d', $table, $emergency, 'emergency_token_claim', $offset ) ), 'event' => $wpdb->get_var( $wpdb->prepare( 'SELECT rate_key FROM %i WHERE bucket LIKE %s LIMIT 1 OFFSET %d', $table, $event, $offset ) ), 'public' => $wpdb->get_var( $wpdb->prepare( 'SELECT rate_key FROM %i WHERE bucket = %s LIMIT 1 OFFSET %d', $table, 'public_request', $offset ) ), 'claim' => $wpdb->get_var( $wpdb->prepare( 'SELECT rate_key FROM %i WHERE bucket = %s LIMIT 1 OFFSET %d', $table, 'emergency_token_claim', $offset ) ), default => $wpdb->get_var( $wpdb->prepare( 'SELECT rate_key FROM %i WHERE bucket NOT LIKE %s AND bucket NOT LIKE %s AND bucket NOT LIKE %s AND bucket <> %s LIMIT 1 OFFSET %d', $table, $login, $emergency, $event, 'public_request', $offset ) ), }; return null === $overflow ? null : (string) $overflow; // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } private static function rate_key( string $bucket, bool $bind_ip = true ): string { $identity = $bind_ip ? self::client_ip() : 'global'; return hash_hmac( 'sha256', $bucket . '|' . $identity, wp_salt( 'nonce' ) ); } public static function rate_hit( string $bucket, int $window_seconds, int $maximum, bool $bind_ip = true ): bool { $state = self::rate_hit_state( $bucket, $window_seconds, $maximum, $bind_ip ); if ( null !== $state ) { $option = self::fallback_option( $bucket ); $rows = get_option( $option, array() ); $key = self::rate_key( $bucket, $bind_ip ); if ( 'nlssl_rate_fallback' !== $option && ( ! is_array( $rows ) || ! isset( $rows[ $key ] ) ) ) { $rows = get_option( 'nlssl_rate_fallback', array() ); } if ( is_array( $rows ) && isset( $rows[ $key ] ) && (int) $rows[ $key ]['expires'] >= time() ) { return self::fallback_rate( $bucket, $window_seconds, $maximum, $bind_ip, true ) || $state; } return $state; } return self::fallback_rate( $bucket, $window_seconds, $maximum, $bind_ip, true ); } private static function rate_hit_state( string $bucket, int $window_seconds, int $maximum, bool $bind_ip ): ?bool { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic counters in the plugin's own rate-limit table for login lockouts, emergency links and request limits. They must be read and written fresh on every request; a cached value would let an attacker past a limit. global $wpdb; $table = self::table( 'rate_limits' ); $key = self::rate_key( $bucket, $bind_ip ); $now = time(); $expires = $now + max( 1, $window_seconds ); $written = $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (rate_key, bucket, hits, expires_at) VALUES (%s, %s, 1, %d) ON DUPLICATE KEY UPDATE hits = IF(expires_at < %d, 1, hits + 1), expires_at = IF(expires_at < %d, %d, expires_at)', $table, $key, substr( sanitize_key( $bucket ), 0, 96 ), $expires, $now, $now, $expires ) ); if ( false === $written ) { self::degraded_storage( 'rate_write' ); return null; } if ( 1 === $written ) { if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires_at < %d LIMIT 64', $table, $now ) ) ) { self::degraded_storage( 'rate_prune' ); return null; } // Each security function owns its capacity; public/event churn cannot starve login or emergency response. $overflow = self::rate_capacity_overflow( $table, self::rate_class( $bucket ) ); if ( ! empty( $wpdb->last_error ) ) { self::degraded_storage( 'rate_capacity_read' ); return null; } if ( null !== $overflow ) { // Never evict an existing live security lock or emergency-token claim to admit a new identity. if ( false === $wpdb->delete( $table, array( 'rate_key' => $key ), array( '%s' ) ) ) { self::degraded_storage( 'rate_capacity_delete' ); } self::degraded_storage( 'rate_capacity' ); return null; } } $hits = $wpdb->get_var( $wpdb->prepare( 'SELECT hits FROM %i WHERE rate_key = %s', $table, $key ) ); if ( null === $hits || ! empty( $wpdb->last_error ) ) { self::degraded_storage( 'rate_read' ); return null; } return (int) $hits > $maximum; // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } public static function rate_limited( string $bucket, int $minimum = 1, bool $bind_ip = true ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads a lockout counter from the plugin's own rate-limit table; a cached value would let an attacker past a limit. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT hits, expires_at FROM %i WHERE rate_key = %s', self::table( 'rate_limits' ), self::rate_key( $bucket, $bind_ip ) ), ARRAY_A ); if ( ! empty( $wpdb->last_error ) ) { self::degraded_storage( 'rate_read' ); return self::fallback_rate( $bucket, 1, max( 1, $minimum ) - 1, $bind_ip, false, true ); } $fallback = self::fallback_rate( $bucket, 1, max( 1, $minimum ) - 1, $bind_ip, false ); return $fallback || ( is_array( $row ) && (int) $row['expires_at'] >= time() && (int) $row['hits'] >= max( 1, $minimum ) ); } private static function fallback_rate( string $bucket, int $window, int $maximum, bool $bind_ip, bool $increment, bool $storage_failed = false ): bool { $login = 'login' === self::rate_class( $bucket ); $key = self::rate_key( $bucket, $bind_ip ); $option = self::fallback_option( $bucket ); $capacity = $login ? self::MAX_LOGIN_FALLBACK_ROWS : self::MAX_FALLBACK_ROWS; $lock = $option . '_lock'; if ( $increment && ! self::acquire_lock( $lock, 10 ) ) { self::degraded_storage( 'fallback_lock' ); return true; } try { $rows = get_option( $option, array() ); $rows = is_array( $rows ) ? $rows : array(); $now = time(); foreach ( $rows as $id => $row ) { if ( ! is_array( $row ) || (int) ( $row['expires'] ?? 0 ) < $now ) { unset( $rows[ $id ] ); } } // Never make a corrupted/legacy over-capacity fallback look less restrictive by // silently discarding live rows before evaluating the requested key. if ( count( $rows ) > $capacity ) { self::degraded_storage( 'fallback_capacity' ); return true; } if ( 'nlssl_rate_fallback' !== $option && ! isset( $rows[ $key ] ) ) { $legacy = get_option( 'nlssl_rate_fallback', array() ); if ( is_array( $legacy ) && isset( $legacy[ $key ] ) && count( $rows ) < $capacity ) { $rows[ $key ] = $legacy[ $key ]; } } if ( ! $increment ) { return isset( $rows[ $key ] ) ? (int) $rows[ $key ]['hits'] > $maximum : ( $storage_failed || ( $login && count( $rows ) >= $capacity ) ); } if ( ! isset( $rows[ $key ] ) ) { if ( count( $rows ) >= $capacity ) { self::degraded_storage( 'fallback_capacity' ); return true; } $rows[ $key ] = array( 'hits' => 0, 'expires' => $now + max( 1, $window ) ); } $rows[ $key ]['hits']++; if ( ! self::write_option_verified( $option, $rows ) ) { self::degraded_storage( 'fallback_write' ); return true; } // Emergency attempts and verification queues require durable primary storage, not this degraded fallback. return ! $login || (int) $rows[ $key ]['hits'] > $maximum; } finally { if ( $increment ) { self::release_lock( $lock ); } } } public static function acquire_lock( string $name, int $ttl ): bool { global $wpdb; $now = time(); try { $value = ( $now + max( 1, $ttl ) ) . ':' . bin2hex( random_bytes( 8 ) ); } catch ( Throwable $error ) { self::degraded_storage( 'lock_entropy' ); return false; } if ( add_option( $name, $value, '', false ) ) { self::$locks[ $name ] = $value; return true; } $old = (string) get_option( $name, '' ); $expires = (int) explode( ':', $old, 2 )[0]; if ( $expires > 0 && $expires <= $now ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Reclaims an expired lock only if it still holds the value just read (compare-and-delete). WordPress has no compare-and-delete API, and this must not be answered from the object cache. $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $name, $old ) ); if ( false === $deleted ) { self::degraded_storage( 'lock_reclaim' ); return false; } if ( 0 === $deleted || 1 === $deleted ) { wp_cache_delete( $name, 'options' ); if ( add_option( $name, $value, '', false ) ) { self::$locks[ $name ] = $value; return true; } } } return false; } public static function release_lock( string $name ): void { global $wpdb; if ( ! isset( self::$locks[ $name ] ) ) { return; } $value = self::$locks[ $name ]; unset( self::$locks[ $name ] ); // A worker that outlived its lease must never release a newer worker's lock. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Deletes the lock only if this process still owns it (compare-and-delete), so a slow worker never releases a newer worker's lock; WordPress has no API for that. $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $name, $value ) ); if ( 1 === $deleted ) { wp_cache_delete( $name, 'options' ); } if ( false === $deleted ) { self::degraded_storage( 'lock_release' ); } } public static function rate_clear( string $bucket, bool $bind_ip = true ): void { global $wpdb; $key = self::rate_key( $bucket, $bind_ip ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Removes a counter from the plugin's own rate-limit table, for example after a successful login; it must take effect immediately. if ( false === $wpdb->delete( self::table( 'rate_limits' ), array( 'rate_key' => $key ), array( '%s' ) ) ) { self::degraded_storage( 'rate_clear_primary' ); } $options = array( self::fallback_option( $bucket ) ); if ( 'nlssl_rate_fallback' !== $options[0] ) { $options[] = 'nlssl_rate_fallback'; } foreach ( $options as $option ) { if ( self::acquire_lock( $option . '_lock', 10 ) ) { try { $rows = get_option( $option, array() ); if ( is_array( $rows ) && isset( $rows[ $key ] ) ) { unset( $rows[ $key ] ); if ( ! self::write_option_verified( $option, $rows ) ) { self::degraded_storage( 'rate_clear_fallback' ); } } } finally { self::release_lock( $option . '_lock' ); } } } } public static function alert( string $key, string $subject, string $body ): bool { if ( ! self::setting( 'critical_email_enabled', 1 ) ) { return false; } return self::notification( $key, $subject, $body ); } public static function notification( string $key, string $subject, $body ): bool { $transient = 'nlssl_alert_' . md5( $key ); $retry = 'nlssl_alert_retry_' . md5( $key ); $lock = 'nlssl_alert_lock_' . md5( $key ); if ( get_transient( $transient ) || get_transient( $retry ) || ! self::acquire_lock( $lock, 60 ) ) { return false; } try { if ( get_transient( $transient ) || get_transient( $retry ) ) { return false; } $sent = false; if ( '' !== self::alert_email() ) { $body = is_callable( $body ) ? $body() : $body; $sent = self::send_email( $subject, (string) $body, 'critical_alert' ); } else { self::log( 'email_notification_failed', 'warning', 'No valid security notification recipient is configured.' ); } $throttled = $sent ? self::write_transient_verified( $transient, 1, 30 * MINUTE_IN_SECONDS ) : self::write_transient_verified( $retry, 1, MINUTE_IN_SECONDS ); if ( ! $throttled ) { self::degraded_storage( 'notification_throttle_write' ); return false; } return $sent; } catch ( Throwable $error ) { if ( ! self::write_transient_verified( $retry, 1, MINUTE_IN_SECONDS ) ) { self::degraded_storage( 'notification_retry_write' ); } self::log( 'email_notification_failed', 'warning', 'Security notification delivery failed.' ); return false; } finally { self::release_lock( $lock ); } } public static function alert_email(): string { $configured = sanitize_email( (string) self::setting( 'alert_email', '' ) ); if ( is_email( $configured ) ) { return $configured; } $fallback = sanitize_email( (string) get_option( 'admin_email', '' ) ); return is_email( $fallback ) ? $fallback : ''; } public static function send_email( string $subject, string $body, string $category = 'notification' ): bool { $result = self::send_email_diagnostic( $subject, $body, $category ); return ! empty( $result['ok'] ); } public static function mail_transport_state(): array { $native_mail = function_exists( 'mail' ); $pre_wp_mail = function_exists( 'has_filter' ) && false !== has_filter( 'pre_wp_mail' ); $phpmailer_hook = function_exists( 'has_action' ) && false !== has_action( 'phpmailer_init' ); $pluggable_override = false; if ( function_exists( 'wp_mail' ) && class_exists( 'ReflectionFunction' ) ) { try { $reflection = new ReflectionFunction( 'wp_mail' ); $file = wp_normalize_path( (string) $reflection->getFileName() ); $core_file = wp_normalize_path( ABSPATH . WPINC . '/pluggable.php' ); $pluggable_override = '' !== $file && $file !== $core_file; } catch ( Throwable $error ) { $pluggable_override = false; } } /* * phpmailer_init is informational only. A logging/diagnostics plugin can * attach a listener without configuring SMTP/API delivery. Counting that * hook as a transport caused false PASS results when PHP mail() was disabled. */ $available = $native_mail || $pre_wp_mail || $pluggable_override; return array( 'available' => $available, 'native_mail' => $native_mail, 'pre_wp_mail' => $pre_wp_mail, 'phpmailer_hook' => $phpmailer_hook, 'phpmailer_hook_is_transport' => false, 'pluggable_override' => $pluggable_override, ); } public static function send_email_diagnostic( string $subject, string $body, string $category = 'notification' ): array { $category = sanitize_key( $category ); $recipient = self::alert_email(); $result = array( 'ok' => false, 'code' => '', 'message' => '', 'timestamp' => gmdate( 'c' ), ); if ( '' === $recipient ) { $result['code'] = 'no_recipient'; $result['message'] = __( 'No valid security notification recipient is configured.', 'neverlast-security-lite' ); self::email_log( false, $category, $result['message'] ); return $result; } $transport = self::mail_transport_state(); if ( empty( $transport['available'] ) ) { $result['code'] = 'mail_transport_unavailable'; $result['message'] = __( 'PHP mail() is unavailable and no WordPress SMTP/API mail transport hook or wp_mail override is active. Configure an SMTP/API mail transport or enable server mail delivery before retrying.', 'neverlast-security-lite' ); self::email_log( false, $category, $result['message'] ); return $result; } $mail_failure = null; $listener = static function ( $error ) use ( &$mail_failure ): void { if ( is_wp_error( $error ) ) { $codes = $error->get_error_codes(); $messages = $error->get_error_messages(); $mail_failure = array( 'code' => sanitize_key( (string) ( $codes[0] ?? 'wp_mail_failed' ) ), 'message' => sanitize_text_field( (string) ( $messages[0] ?? 'WordPress mail transport reported a failure.' ) ), ); } }; if ( function_exists( 'add_action' ) ) { add_action( 'wp_mail_failed', $listener, PHP_INT_MAX, 1 ); } try { $site = home_url(); $host = (string) wp_parse_url( $site, PHP_URL_HOST ); if ( '' === $host ) { $host = 'WordPress'; } $sent = (bool) wp_mail( $recipient, '[' . $host . '] ' . $subject, $body . "\n\nSite: " . $site . "\nTime UTC: " . gmdate( 'Y-m-d H:i:s' ) ); $result['ok'] = $sent; if ( $sent ) { $result['code'] = 'accepted'; $result['message'] = __( 'WordPress accepted the message for delivery.', 'neverlast-security-lite' ); } elseif ( is_array( $mail_failure ) ) { $result['code'] = (string) $mail_failure['code']; $result['message'] = substr( (string) $mail_failure['message'], 0, 800 ); } else { $result['code'] = 'wp_mail_false'; $result['message'] = __( 'wp_mail() returned false without a detailed transport error. Check the active SMTP/mail plugin and server mail logs.', 'neverlast-security-lite' ); } } catch ( Throwable $error ) { $message = substr( sanitize_text_field( $error->getMessage() ), 0, 800 ); if ( ! function_exists( 'mail' ) && preg_match( '/undefined function(?:\s+[^\s]+)?\s*mail\s*\(\s*\)/i', $message ) ) { $result['code'] = 'mail_transport_unavailable'; $result['message'] = __( 'PHP mail() is unavailable and WordPress fell through to the native PHPMailer mail transport. Configure an SMTP/API transport or enable server mail delivery before retrying.', 'neverlast-security-lite' ); } else { $result['code'] = 'mail_exception'; $result['message'] = $message; if ( '' === $result['message'] ) { $result['message'] = __( 'The WordPress mail transport raised an exception.', 'neverlast-security-lite' ); } } } finally { if ( function_exists( 'remove_action' ) ) { remove_action( 'wp_mail_failed', $listener, PHP_INT_MAX ); } } self::email_log( ! empty( $result['ok'] ), $category, ! empty( $result['ok'] ) ? 'A security email was accepted by WordPress mail delivery.' : 'WordPress mail delivery rejected a security email: ' . $result['code'] ); return $result; } private static function email_log( bool $sent, string $category, string $message ): void { try { self::log( $sent ? 'email_notification_sent' : 'email_notification_failed', $sent ? 'info' : 'warning', $message, array( 'category' => $category ) ); } catch ( Throwable $error ) { // Delivery outcome must not be replaced by a fatal error when audit storage or // an integration around it is unhealthy. The next storage-health pass will // surface persistent database faults independently. } } public static function cleanup(): void { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Daily retention cleanup of the plugin's own tables: activity log age and size cap, expired rate limits and two-factor challenges. Bounded deletes with nothing to cache. global $wpdb; $days = max( 1, min( 90, (int) self::setting( 'activity_retention_days', 7 ) ) ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created_at < %s', self::table( 'events' ), $cutoff ) ) ) { self::degraded_storage( 'cleanup_events_age' ); } $cutoff_id = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i ORDER BY id DESC LIMIT 1 OFFSET 4999', self::table( 'events' ) ) ); if ( ! empty( $wpdb->last_error ) ) { self::degraded_storage( 'cleanup_events_read' ); } elseif ( $cutoff_id && false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id < %d', self::table( 'events' ), (int) $cutoff_id ) ) ) { self::degraded_storage( 'cleanup_events_capacity' ); } if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires_at < %d', self::table( 'rate_limits' ), time() ) ) ) { self::degraded_storage( 'cleanup_rates' ); } if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires_at < %d OR consumed_at > 0', self::table( '2fa_challenges' ), time() - HOUR_IN_SECONDS ) ) ) { self::degraded_storage( 'cleanup_challenges' ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } }