Security_Events_Entity::get_table_name(), 'singular' => Security_Events_Entity::get_table_name(), 'ajax' => false, ) ); } /** * Register the submenu page. * * @return void * * @since 5.2.0 */ public static function menu_add(): void { $hook = \add_submenu_page( Logs_List::MENU_SLUG, \esc_html__( 'Security', '0-day-analytics' ), \esc_html__( 'Security', '0-day-analytics' ), 'manage_options', self::MENU_SLUG, array( Security_View::class, 'render' ), 7 ); self::add_screen_options( $hook ); \add_filter( 'manage_' . $hook . '_columns', array( self::class, 'manage_columns' ) ); \add_action( 'load-' . $hook, array( Settings::class, 'aadvana_common_help' ) ); \add_action( 'load-' . $hook, array( self::class, 'process_actions_load' ) ); } /** * Handle bulk and row actions during the early page load hook. * * @return void * * @since 5.2.0 */ public static function process_actions_load(): void { if ( ! \current_user_can( 'manage_options' ) ) { return; } $table = new self(); $table->handle_table_actions(); } /** * Returns the screen per page title. * * @return string * * @since 5.2.0 */ private static function get_screen_per_page_title(): string { return \__( 'Number of security events to show', '0-day-analytics' ); } /** * Adds columns to the screen options screen. * * @param array $columns Array of column names. * * @return array * * @since 5.2.0 */ public static function manage_columns( $columns ): array { if ( empty( self::$admin_columns ) ) { $admin_columns = Security_Events_Entity::get_column_names_admin(); $table_columns = array( 'cb' => '', ); self::$admin_columns = \array_merge( $table_columns, $admin_columns, $columns ); } return self::$admin_columns; } /** * Prepares the list of items for displaying. * * @since 5.2.0 */ public function prepare_items() { $per_page = self::get_screen_option_per_page(); $current_page = $this->get_pagenum(); $offset = ( $current_page > 1 ) ? $per_page * ( $current_page - 1 ) : 0; $search_string = self::escaped_search_input(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $event_type = isset( $_REQUEST['event_type'] ) ? \sanitize_text_field( \wp_unslash( $_REQUEST['event_type'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $severity = isset( $_REQUEST['severity'] ) ? \sanitize_text_field( \wp_unslash( $_REQUEST['severity'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $orderby = ( isset( $_GET['orderby'] ) && '' !== trim( (string) $_GET['orderby'] ) ) ? \esc_sql( \sanitize_text_field( \wp_unslash( $_GET['orderby'] ) ) ) : 'last_attempt_at'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $order = ( isset( $_GET['order'] ) && '' !== trim( (string) $_GET['order'] ) ) ? \esc_sql( \sanitize_text_field( \wp_unslash( $_GET['order'] ) ) ) : 'DESC'; $items = $this->fetch_table_data( array( 'search_string' => $search_string, 'offset' => $offset, 'per_page' => $per_page, 'orderby' => $orderby, 'order' => $order, 'event_type' => $event_type, 'severity' => $severity, ) ); $columns = self::manage_columns( array() ); $hidden = \get_user_option( 'manage' . WP_Helper::get_wp_screen()->id . 'columnshidden', false ); if ( ! $hidden ) { $hidden = array(); } $sortable = $this->get_sortable_columns(); $this->_column_headers = array( $columns, $hidden, $sortable ); $this->items = $items; $this->set_pagination_args( array( 'total_items' => $this->count, 'per_page' => $per_page, 'total_pages' => ceil( $this->count / $per_page ), ) ); } /** * Get a list of columns. * * @since 5.2.0 * * @return array */ public function get_columns() { return self::manage_columns( array() ); } /** * Get sortable columns. * * @since 5.2.0 * * @return array */ public function get_sortable_columns() { return array( 'created_at' => array( 'created_at', false ), 'event_type' => array( 'event_type', false ), 'severity' => array( 'severity', false ), 'description' => array( 'description', false ), 'ip_hash' => array( 'ip_hash', false ), 'attempt_count' => array( 'attempt_count', false ), 'last_attempt_at' => array( 'last_attempt_at', true ), ); } /** * Text displayed when no user data is available. * * @since 5.2.0 * * @return void */ public function no_items() { \esc_html_e( 'No security events recorded.', '0-day-analytics' ); } /** * Fetch table data. * * @param array $args Arguments for query. * * @return array * * @since 5.2.0 */ public function fetch_table_data( array $args = array() ) { $parsed_args = \wp_parse_args( $args, array( 'offset' => 0, 'search_string' => self::escaped_search_input(), 'per_page' => self::get_screen_option_per_page(), 'orderby' => 'created_at', 'order' => 'DESC', 'event_type' => '', 'severity' => '', ) ); $total = 0; $result = Security_Events_Entity::get_list_data( array( 'search_string' => $parsed_args['search_string'], 'event_type' => ( '' !== $parsed_args['event_type'] ) ? $parsed_args['event_type'] : null, 'severity' => ( '' !== $parsed_args['severity'] ) ? $parsed_args['severity'] : null, 'orderby' => $parsed_args['orderby'], 'order' => $parsed_args['order'], 'per_page' => (int) $parsed_args['per_page'], 'offset' => (int) $parsed_args['offset'], ), $total ); $this->count = $total; return $result; } /** * Render a column when no column specific method exists. * * @param array $item Row data. * @param string $column_name Column name. * * @return mixed * * @since 5.2.0 */ public function column_default( $item, $column_name ) { switch ( $column_name ) { case 'created_at': $query_args_view_data = array(); $query_args_view_data['_wpnonce'] = \wp_create_nonce( 'bulk-' . $this->_args['plural'] ); $delete_url = \add_query_arg( array( 'action' => 'delete', 'advan_' . Security_Events_Entity::get_table_name() => $item['id'], self::SEARCH_INPUT => self::escaped_search_input(), '_wpnonce' => $query_args_view_data['_wpnonce'], ) ); $actions['delete'] = '' . \esc_html__( 'Delete', '0-day-analytics' ) . ''; $time_format = 'g:i a'; // created_at is already a MySQL datetime string, convert to timestamp. $created_at_ts = strtotime( $item['created_at'] ); if ( false === $created_at_ts ) { return \esc_html( $item['created_at'] ); } $event_datetime_utc = \gmdate( 'Y-m-d H:i:s', $created_at_ts ); $timezone_local = \wp_timezone(); $event_local = \get_date_from_gmt( $event_datetime_utc, 'Y-m-d' ); $today_local = ( new \DateTimeImmutable( 'now', $timezone_local ) )->format( 'Y-m-d' ); $tomorrow_local = ( new \DateTimeImmutable( 'tomorrow', $timezone_local ) )->format( 'Y-m-d' ); $yesterday_local = ( new \DateTimeImmutable( 'yesterday', $timezone_local ) )->format( 'Y-m-d' ); if ( \get_date_from_gmt( $event_datetime_utc, 'P' ) !== \get_date_from_gmt( 'now', 'P' ) ) { $time_format .= ' (P)'; } $event_time_local = \get_date_from_gmt( $event_datetime_utc, $time_format ); if ( $event_local === $today_local ) { $date = sprintf( /* translators: %s: Time */ \__( 'Today at %s', '0-day-analytics' ), $event_time_local ); } elseif ( $event_local === $tomorrow_local ) { $date = sprintf( /* translators: %s: Time */ \__( 'Tomorrow at %s', '0-day-analytics' ), $event_time_local ); } elseif ( $event_local === $yesterday_local ) { $date = sprintf( /* translators: %s: Time */ \__( 'Yesterday at %s', '0-day-analytics' ), $event_time_local ); } else { $date = sprintf( /* translators: 1: Date, 2: Time */ \__( '%1$s at %2$s', '0-day-analytics' ), \get_date_from_gmt( $event_datetime_utc, 'F jS' ), $event_time_local ); } $time = sprintf( '', \esc_attr( gmdate( 'c', $created_at_ts ) ), \esc_html( $date ) ); $until = $created_at_ts - time(); if ( $until < 0 ) { $ago = sprintf( /* translators: %s: Time period */ \__( '%s ago', '0-day-analytics' ), WP_Helper::interval( abs( $until ) ) ); return sprintf( ' %s
%s', \esc_html( $ago ), $time ) . $this->row_actions( $actions ); } elseif ( 0 === $until ) { $in = \__( 'Now', '0-day-analytics' ); } else { $in = sprintf( /* translators: %s: Time period */ \__( 'In %s', '0-day-analytics' ), WP_Helper::interval( $until ) ); } return sprintf( ' %s
%s', \esc_html( $in ), $time ) . $this->row_actions( $actions ); case 'event_type': return self::event_type_badge( $item['event_type'] ); case 'severity': return self::severity_badge( $item['severity'] ); case 'description': $desc = \esc_html( $item['description'] ); // Replace "user #ID" references with linked usernames. $desc = \preg_replace_callback( '/user\s#(\d+)/', static function ( $matches ) { $user_id = (int) $matches[1]; if ( $user_id < 1 ) { return $matches[0]; } $user = \get_userdata( $user_id ); if ( ! $user instanceof \WP_User ) { return $matches[0]; } $edit_url = \get_edit_user_link( $user_id ); $display = \esc_html( $user->display_name ?: $user->user_login ); return '' . $display . ''; }, $desc ); // Replace "username (#ID)" references with linked usernames. $desc = \preg_replace_callback( '/(?<=:\s|for\s)(\S+)\s\(#(\d+)\)/', static function ( $matches ) { $user_id = (int) $matches[2]; if ( $user_id < 1 ) { return $matches[0]; } $user = \get_userdata( $user_id ); if ( ! $user instanceof \WP_User ) { return $matches[0]; } $edit_url = \get_edit_user_link( $user_id ); $display = \esc_html( $user->display_name ?: $user->user_login ); return '' . $display . ' (#' . $user_id . ')'; }, $desc ); // For blocked requests, append the request details from metadata. if ( isset( $item['event_type'] ) && Security_Events_Entity::EVENT_BLOCKED === $item['event_type'] && ! empty( $item['metadata'] ) ) { $meta = \json_decode( $item['metadata'], true ); if ( \is_array( $meta ) && ! empty( $meta['request_uri'] ) ) { // Normalize the URI for display — handles percent-encoded and // multi-byte (UTF-8/UTF-16) sequences in legacy stored events. $raw_uri = $meta['request_uri']; $raw_uri = str_replace( "\0", '', $raw_uri ); $prev = ''; $loops = 3; while ( $raw_uri !== $prev && $loops-- > 0 ) { $prev = $raw_uri; $raw_uri = rawurldecode( $raw_uri ); } $raw_uri = str_replace( array( "\xFF\xFE", "\xFE\xFF" ), '', $raw_uri ); $raw_uri = preg_replace_callback( '/%u([0-9A-Fa-f]{4})/', static function ( $m ) { $cp = hexdec( $m[1] ); if ( $cp < 0x80 ) { return chr( $cp ); } elseif ( $cp < 0x800 ) { return chr( 0xC0 | ( $cp >> 6 ) ) . chr( 0x80 | ( $cp & 0x3F ) ); } return chr( 0xE0 | ( $cp >> 12 ) ) . chr( 0x80 | ( ( $cp >> 6 ) & 0x3F ) ) . chr( 0x80 | ( $cp & 0x3F ) ); }, $raw_uri ) ?? $raw_uri; $raw_uri = str_replace( '\\', '/', $raw_uri ); if ( function_exists( 'mb_convert_encoding' ) ) { $raw_uri = mb_convert_encoding( $raw_uri, 'UTF-8', 'UTF-8' ); } $uri = \esc_html( $raw_uri ); $method = ! empty( $meta['request_method'] ) ? \esc_html( $meta['request_method'] ) : 'GET'; // Check if the request looks like a REST API endpoint. $is_rest = false !== \strpos( $meta['request_uri'], '/wp-json/' ) || false !== \strpos( $meta['request_uri'], 'rest_route=' ); $request_label = '' . $method . ' ' . $uri . ''; // if ( $is_rest && Settings::get_option( 'requests_module_enabled' ) ) { // // Link to the requests module filtered by this URI. // $requests_url = \add_query_arg( // array( // 'page' => Requests_List::REQUESTS_MENU_SLUG, // 's' => \rawurlencode( $meta['request_uri'] ), // ), // \admin_url( 'admin.php' ) // ); // $request_label .= ' 🔗'; // } $desc .= '
' . $request_label . ''; } } return '' . $desc . ''; case 'ip_hash': if ( ! empty( $item['ip_hash'] ) ) { return '' . \esc_html( substr( $item['ip_hash'], 0, 16 ) . '…' ) . ''; } return '—'; case 'metadata': if ( empty( $item['metadata'] ) || '[]' === $item['metadata'] || '{}' === $item['metadata'] ) { return '—'; } $decoded = json_decode( $item['metadata'], true ); if ( ! is_array( $decoded ) || empty( $decoded ) ) { return '—'; } $parts = array(); foreach ( $decoded as $key => $value ) { $display = is_scalar( $value ) ? (string) $value : wp_json_encode( $value ); if ( strlen( $display ) > 80 ) { $display = substr( $display, 0, 80 ) . '…'; } $parts[] = '' . \esc_html( $key ) . ': ' . \esc_html( $display ); } return '
' . implode( '
', $parts ) . '
'; case 'attempt_count': $count = isset( $item['attempt_count'] ) ? (int) $item['attempt_count'] : 1; if ( $count <= 1 ) { return '1'; } $window_minutes = (int) ( Security_Events_Entity::AGGREGATION_WINDOW / 60 ); $label = sprintf( /* translators: %1$s: number of attempts, %2$d: aggregation window in minutes */ \__( '%1$s attempts within %2$d mins', '0-day-analytics' ), number_format_i18n( $count ), $window_minutes ); // Show last attempt time as tooltip when count > 1. $tooltip = ''; if ( ! empty( $item['last_attempt_at'] ) && '0000-00-00 00:00:00' !== $item['last_attempt_at'] ) { $last_ts = strtotime( $item['last_attempt_at'] ); if ( false !== $last_ts ) { $tooltip = sprintf( /* translators: %s: date/time string */ \__( 'Last attempt: %s', '0-day-analytics' ), \get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $last_ts ), 'M j, g:i a' ) ); } } return sprintf( '%s', \esc_attr( $tooltip ), \esc_html( $label ) ); default: return isset( $item[ $column_name ] ) ? \esc_html( (string) $item[ $column_name ] ) : ''; } } /** * Get value for checkbox column. * * @param object|array $item Row data. * * @return string * * @since 5.2.0 */ protected function column_cb( $item ) { return sprintf( '' . '', (int) $item['id'], /* translators: %s: column name */ sprintf( \__( 'Select %s', '0-day-analytics' ), 'id' ), \esc_attr( Security_Events_Entity::get_table_name() ) ); } /** * Returns bulk actions. * * @return array * * @since 5.2.0 */ public function get_bulk_actions() { return array( 'delete' => \__( 'Delete Records', '0-day-analytics' ), ); } /** * Process actions triggered by the user. * * @since 5.2.0 */ protected function handle_table_actions() { if ( ! \is_user_logged_in() || ! \current_user_can( 'manage_options' ) ) { return; } $field_name = 'advan_' . Security_Events_Entity::get_table_name(); // Single row delete (scalar value, not array from bulk checkboxes). if ( isset( $_REQUEST['action'] ) && 'delete' === $_REQUEST['action'] ) { if ( isset( $_REQUEST[ $field_name ] ) && ! \is_array( $_REQUEST[ $field_name ] ) ) { \check_admin_referer( 'bulk-' . $this->_args['plural'] ); $id = \absint( $_REQUEST[ $field_name ] ); if ( $id > 0 ) { Security_Events_Entity::delete_by_id( $id ); } $redirect = \remove_query_arg( array( 'action', $field_name, '_wpnonce' ) ); \wp_safe_redirect( $redirect ); exit; } } // Bulk delete. $action = $this->current_action(); $entries = isset( $_REQUEST[ $field_name ] ) && \is_array( $_REQUEST[ $field_name ] ) ? \array_map( 'absint', $_REQUEST[ $field_name ] ) : array(); if ( 'delete' === $action && ! empty( $entries ) ) { \check_admin_referer( 'bulk-' . $this->_args['plural'] ); Security_Events_Entity::delete_by_ids( $entries ); $redirect = \remove_query_arg( array( 'action', 'action2', $field_name, '_wpnonce' ) ); \wp_safe_redirect( $redirect ); exit; } } /** * Generates content for a single row of the table. * * @param object|array $item The current item. * * @since 5.2.0 */ public function single_row( $item ) { $classes = 'sec-severity-' . \esc_attr( $item['severity'] ?? 'medium' ); echo ''; $this->single_row_columns( $item ); echo ''; } /** * Generates the table navigation above or below the table. * * @param string $which Top or bottom. * * @since 5.2.0 */ public function display_tablenav( $which ) { if ( 'top' === $which ) { \wp_nonce_field( 'bulk-' . $this->_args['plural'] ); ?>
has_items() ) { ?>
bulk_actions( $which ); ?>
extra_tablenav( $which ); $this->pagination( $which ); ?>
\__( 'All Event Types', '0-day-analytics' ), Security_Events_Entity::EVENT_DDOS => \__( 'DDoS', '0-day-analytics' ), Security_Events_Entity::EVENT_DOS => \__( 'DoS', '0-day-analytics' ), Security_Events_Entity::EVENT_BRUTE_FORCE => \__( 'Brute Force', '0-day-analytics' ), Security_Events_Entity::EVENT_PRIV_ESCALATION => \__( 'Priv. Escalation', '0-day-analytics' ), Security_Events_Entity::EVENT_ROLE_CHANGE => \__( 'Role Change', '0-day-analytics' ), Security_Events_Entity::EVENT_OPTION_CHANGE => \__( 'Option Change', '0-day-analytics' ), Security_Events_Entity::EVENT_FILE_EDIT => \__( 'File Edit', '0-day-analytics' ), Security_Events_Entity::EVENT_USER_ENUM => \__( 'User Enum.', '0-day-analytics' ), Security_Events_Entity::EVENT_BLOCKED => \__( 'Blocked', '0-day-analytics' ), Security_Events_Entity::EVENT_INCIDENT => \__( 'Incident', '0-day-analytics' ), 'plugin_update' => \__( 'Plugin Update', '0-day-analytics' ), 'plugin_delete' => \__( 'Plugin Delete', '0-day-analytics' ), 'theme_switch' => \__( 'Theme Switch', '0-day-analytics' ), 'core_update' => \__( 'Core Update', '0-day-analytics' ), 'user_login' => \__( 'User Login', '0-day-analytics' ), 'user_logout' => \__( 'User Logout', '0-day-analytics' ), 'user_login_failed' => \__( 'Login Failed', '0-day-analytics' ), 'user_deleted' => \__( 'User Deleted', '0-day-analytics' ), 'user_password_reset' => \__( 'Password Reset', '0-day-analytics' ), 'post_status_change' => \__( 'Post Status', '0-day-analytics' ), ); $severities = array( '' => \__( 'All Severities', '0-day-analytics' ), Security_Events_Entity::SEVERITY_LOW => \__( 'Low', '0-day-analytics' ), Security_Events_Entity::SEVERITY_MEDIUM => \__( 'Medium', '0-day-analytics' ), Security_Events_Entity::SEVERITY_HIGH => \__( 'High', '0-day-analytics' ), Security_Events_Entity::SEVERITY_CRITICAL => \__( 'Critical', '0-day-analytics' ), ); ?>
'security-query-submit' ) ); ?>
count > 0 ) { ?>
array( '#f0f0f1', '#50575e' ), 'medium' => array( '#fcf9e8', '#996800' ), 'high' => array( '#fce5cd', '#b32d2e' ), 'critical' => array( '#d63638', '#fff' ), ); $pair = $colors[ $severity ] ?? $colors['medium']; return sprintf( '%s', \esc_attr( $pair[0] ), \esc_attr( $pair[1] ), \esc_html( strtoupper( $severity ) ) ); } /** * Render an event type badge. * * @param string $type Event type. * * @return string HTML badge. * * @since 5.2.0 */ public static function event_type_badge( string $type ): string { $label = self::get_event_type_label( $type ); return sprintf( '%s', \esc_html( $label ) ); } /** * Get human-readable event type label. * * @param string $type Event type. * * @return string * * @since 5.2.0 */ public static function get_event_type_label( string $type ): string { $labels = array( 'ddos' => \__( 'DDoS', '0-day-analytics' ), 'dos' => \__( 'DoS', '0-day-analytics' ), 'brute_force' => \__( 'Brute Force', '0-day-analytics' ), 'privilege_escalation' => \__( 'Priv. Escalation', '0-day-analytics' ), 'role_change' => \__( 'Role Change', '0-day-analytics' ), 'option_change' => \__( 'Option Change', '0-day-analytics' ), 'file_edit' => \__( 'File Edit', '0-day-analytics' ), 'user_enumeration' => \__( 'User Enum.', '0-day-analytics' ), 'blocked' => \__( 'Blocked', '0-day-analytics' ), 'incident' => \__( 'Incident', '0-day-analytics' ), 'plugin_update' => \__( 'Plugin Update', '0-day-analytics' ), 'plugin_delete' => \__( 'Plugin Delete', '0-day-analytics' ), 'theme_switch' => \__( 'Theme Switch', '0-day-analytics' ), 'core_update' => \__( 'Core Update', '0-day-analytics' ), 'user_login' => \__( 'User Login', '0-day-analytics' ), 'user_logout' => \__( 'User Logout', '0-day-analytics' ), 'user_login_failed' => \__( 'Login Failed', '0-day-analytics' ), 'user_deleted' => \__( 'User Deleted', '0-day-analytics' ), 'user_password_reset' => \__( 'Password Reset', '0-day-analytics' ), 'post_status_change' => \__( 'Post Status', '0-day-analytics' ), ); return $labels[ $type ] ?? ucfirst( str_replace( '_', ' ', $type ) ); } } }