__( 'DDoS attack detected', '0-day-analytics' ), 'brute_force' => __( 'Brute force campaign detected', '0-day-analytics' ), 'escalation' => __( 'Privilege escalation chain detected', '0-day-analytics' ), ); $message = $labels[ $incident_type ] ?? sprintf( /* translators: %s: incident type */ __( 'Security incident: %s', '0-day-analytics' ), $incident_type ); if ( ! empty( $context['reason'] ) ) { $message .= ' — ' . sanitize_text_field( $context['reason'] ); } self::queue_notification( array( 'message' => $message, 'type' => 'error', 'module' => 'security', 'duration' => 0, // Sticky — critical must be dismissed manually. ) ); } /** * Handle WP mail failure. * * @param \WP_Error $error The mail error. * * @return void * * @since 5.4.0 */ public static function on_mail_failed( $error ): void { if ( ! $error instanceof \WP_Error ) { return; } $message = sprintf( /* translators: %s: error message */ __( 'Email failed to send: %s', '0-day-analytics' ), $error->get_error_message() ); self::queue_notification( array( 'message' => $message, 'type' => 'warning', 'module' => 'mail', 'duration' => 8000, ) ); } /** * Handle failed login with high severity. * * Only queues notification when severity escalates to HIGH or CRITICAL. * * @param string $username Username attempted. * @param \WP_Error $error Error object. * * @return void * * @since 5.4.0 */ public static function on_login_failed( string $username, $error ): void { if ( ! Settings::get_option( 'login_auditor_module_enabled' ) ) { return; } // Replicate the severity logic from Login_Auth_Auditor. $ip_hash = Rate_Limiter::get_ip_hash(); $failed_count = self::get_failed_login_count( $ip_hash ); if ( $failed_count >= 10 ) { $message = sprintf( /* translators: 1: username, 2: attempt count */ __( 'Critical: %1$d failed login attempts from same IP (user: %2$s)', '0-day-analytics' ), $failed_count + 1, $username ); self::queue_notification( array( 'message' => $message, 'type' => 'error', 'module' => 'login-audit', 'duration' => 0, // Sticky for critical. ) ); } elseif ( $failed_count >= 5 ) { $message = sprintf( /* translators: 1: attempt count, 2: username */ __( 'High severity: %1$d failed login attempts from same IP (user: %2$s)', '0-day-analytics' ), $failed_count + 1, $username ); self::queue_notification( array( 'message' => $message, 'type' => 'warning', 'module' => 'login-audit', 'duration' => 10000, ) ); } } /** * Handle successful login after previous failed attempts (suspicious). * * @param string $user_login Username. * @param \WP_User $user User object. * * @return void * * @since 5.4.0 */ public static function on_login_success( string $user_login, $user ): void { if ( ! Settings::get_option( 'login_auditor_module_enabled' ) ) { return; } if ( ! $user instanceof \WP_User ) { return; } $ip_hash = Rate_Limiter::get_ip_hash(); $failed_count = self::get_failed_login_count( $ip_hash ); // Alert when a login succeeds after multiple failures — could be compromised. if ( $failed_count >= 3 ) { $is_privileged = ! empty( array_intersect( $user->roles, array( 'administrator', 'editor', 'super_admin' ) ) ); if ( $is_privileged ) { $message = sprintf( /* translators: 1: username, 2: attempt count */ __( 'Privileged user "%1$s" logged in after %2$d failed attempts from same IP', '0-day-analytics' ), $user_login, $failed_count ); self::queue_notification( array( 'message' => $message, 'type' => 'warning', 'module' => 'login-audit', 'duration' => 10000, ) ); } } } /** * Check the error log for new fatal/error entries. * * Runs on admin_init — checks if there are new critical log entries * since the last check. * * @return void * * @since 5.4.0 */ public static function check_error_log(): void { if ( ! \current_user_can( 'manage_options' ) ) { return; } $transient_key = 'advan_last_fatal_notif_ts'; $last_check = (int) \get_site_transient( $transient_key ); $now = time(); // Only check every 60 seconds to avoid hammering the log file. if ( $last_check > 0 && ( $now - $last_check ) < 60 ) { return; } // Get the most recent error log entries. if ( ! class_exists( '\ADVAN\Lists\Logs_List' ) ) { return; } $events = Logs_List::get_error_items( false, false, true ); if ( empty( $events ) ) { \set_site_transient( $transient_key, $now, self::TTL ); return; } $critical_severities = array( 'fatal', 'fatal error', 'parse error', 'parse', 'core error', 'compile error', 'recoverable error', 'error', ); $notified = 0; foreach ( $events as $event ) { // Only process events newer than last check. if ( $last_check > 0 && ! empty( $event['timestamp'] ) && $event['timestamp'] <= $last_check ) { break; } $severity = strtolower( trim( $event['severity'] ?? '' ) ); if ( in_array( $severity, $critical_severities, true ) ) { $message = sprintf( /* translators: 1: severity, 2: error message */ __( 'PHP %1$s: %2$s', '0-day-analytics' ), ucfirst( $severity ), wp_strip_all_tags( html_entity_decode( (string) ( $event['message'] ?? '' ) ) ) ); // Truncate long messages. if ( strlen( $message ) > 200 ) { $message = substr( $message, 0, 197 ) . '...'; } self::queue_notification( array( 'message' => $message, 'type' => 'error', 'module' => 'error-log', 'duration' => 0, // Sticky for fatals. 'time' => ( $event['timestamp'] ?? $now ) * 1000, // JS expects ms. ) ); ++$notified; if ( $notified >= 5 ) { break; // Cap to avoid flooding. } } } // Update the timestamp after processing so next check skips these. \set_site_transient( $transient_key, $now, self::TTL ); } /** * Output pending notifications as inline JS in admin footer. * * @return void * * @since 5.4.0 */ public static function output_notifications(): void { if ( ! \current_user_can( 'manage_options' ) ) { return; } $notifications = self::get_pending_notifications(); // Also consume the global queue (events fired before user auth). $global_key = self::TRANSIENT_PREFIX . 'global'; $global_notifs = \get_site_transient( $global_key ); if ( is_array( $global_notifs ) && ! empty( $global_notifs ) ) { $notifications = array_merge( $notifications, $global_notifs ); \delete_site_transient( $global_key ); } if ( empty( $notifications ) ) { return; } // Clear after retrieval — each notification shows only once. self::clear_pending_notifications(); ?> self::MAX_PENDING ) { $pending = array_slice( $pending, -self::MAX_PENDING ); } self::save_pending_notifications( $pending ); } /** * Get pending notifications from storage. * * Uses site transients for multi-admin visibility. * * @return array * * @since 5.4.0 */ private static function get_pending_notifications(): array { $key = self::get_transient_key(); $value = \get_site_transient( $key ); return is_array( $value ) ? $value : array(); } /** * Save pending notifications. * * @param array $notifications Array of notification data. * * @return void * * @since 5.4.0 */ private static function save_pending_notifications( array $notifications ): void { \set_site_transient( self::get_transient_key(), $notifications, self::TTL ); } /** * Clear all pending notifications. * * @return void * * @since 5.4.0 */ private static function clear_pending_notifications(): void { \delete_site_transient( self::get_transient_key() ); } /** * Get the transient key for the current user. * * @return string * * @since 5.4.0 */ private static function get_transient_key(): string { $user_id = \get_current_user_id(); if ( 0 === $user_id ) { // For non-logged-in context (hooks firing before auth), use a global key. return self::TRANSIENT_PREFIX . 'global'; } return self::TRANSIENT_PREFIX . $user_id; } /** * Get cached failed login count for an IP (lightweight lookup). * * @param string $ip_hash Hashed IP address. * * @return int * * @since 5.4.0 */ private static function get_failed_login_count( string $ip_hash ): int { $cache_key = 'advan_login_fails_' . $ip_hash; $count = \wp_cache_get( $cache_key, 'advan_login_audit' ); if ( false !== $count ) { return (int) $count; } // Fallback: query the transient the Rate_Limiter uses. $transient_key = 'advan_rate_' . substr( $ip_hash, 0, 12 ); $data = \get_site_transient( $transient_key ); if ( is_array( $data ) && isset( $data['count'] ) ) { return (int) $data['count']; } return 0; } } }