ID : 0; // Self-promotion is always suspicious. $is_self_promotion = ( $current_user_id === $user_id && $is_escalation ); $severity = Security_Events_Entity::SEVERITY_MEDIUM; if ( $is_self_promotion ) { $severity = Security_Events_Entity::SEVERITY_CRITICAL; } elseif ( $is_escalation ) { $severity = Security_Events_Entity::SEVERITY_HIGH; } $ip_hash = Rate_Limiter::get_ip_hash(); Security_Events_Entity::log_event( $is_self_promotion ? Security_Events_Entity::EVENT_PRIV_ESCALATION : Security_Events_Entity::EVENT_ROLE_CHANGE, $severity, $ip_hash, sprintf( 'User #%d role changed from [%s] to [%s] by user #%d%s', $user_id, implode( ', ', $old_roles ), $new_role, $current_user_id, $is_self_promotion ? ' (SELF-PROMOTION)' : '' ), array( 'target_user_id' => $user_id, 'old_roles' => $old_roles, 'new_role' => $new_role, 'self_promotion' => $is_self_promotion, ) ); } /** * Detect role addition. * * @param int $user_id User ID. * @param string $role Role added. * * @return void * * @since 5.2.0 */ public static function on_role_added( int $user_id, string $role ): void { if ( ! in_array( $role, self::PRIVILEGED_ROLES, true ) ) { return; } $current_user = \wp_get_current_user(); $current_user_id = $current_user instanceof \WP_User ? $current_user->ID : 0; $is_self = $current_user_id === $user_id; Security_Events_Entity::log_event( $is_self ? Security_Events_Entity::EVENT_PRIV_ESCALATION : Security_Events_Entity::EVENT_ROLE_CHANGE, $is_self ? Security_Events_Entity::SEVERITY_CRITICAL : Security_Events_Entity::SEVERITY_HIGH, Rate_Limiter::get_ip_hash(), sprintf( 'Privileged role "%s" added to user #%d by user #%d%s', $role, $user_id, $current_user_id, $is_self ? ' (SELF-PROMOTION)' : '' ), array( 'target_user_id' => $user_id, 'actor_user_id' => $current_user_id, 'role_added' => $role, ) ); } /** * Detect role removal (potential downgrade attack on other admins). * * @param int $user_id User ID. * @param string $role Role removed. * * @return void * * @since 5.2.0 */ public static function on_role_removed( int $user_id, string $role ): void { if ( ! in_array( $role, self::PRIVILEGED_ROLES, true ) ) { return; } $current_user = \wp_get_current_user(); $current_user_id = $current_user instanceof \WP_User ? $current_user->ID : 0; Security_Events_Entity::log_event( Security_Events_Entity::EVENT_ROLE_CHANGE, Security_Events_Entity::SEVERITY_HIGH, Rate_Limiter::get_ip_hash(), sprintf( 'Privileged role "%s" removed from user #%d by user #%d', $role, $user_id, $current_user_id ), array( 'target_user_id' => $user_id, 'actor_user_id' => $current_user_id, 'role_removed' => $role, ) ); } /** * Monitor user_meta updates for capability manipulation. * * Detects direct capability injection via update_user_meta() bypassing * the standard role assignment API. * * @param int $meta_id Meta ID. * @param int $user_id User ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. * * @return void * * @since 5.2.0 */ public static function on_user_meta_update( int $meta_id, int $user_id, string $meta_key, $meta_value ): void { self::check_capability_meta( $user_id, $meta_key, $meta_value, 'update' ); } /** * Monitor user_meta additions for capability injection. * * @param int $user_id User ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. * * @return void * * @since 5.2.0 */ public static function on_user_meta_add( int $user_id, string $meta_key, $meta_value ): void { self::check_capability_meta( $user_id, $meta_key, $meta_value, 'add' ); } /** * Check if a meta update is manipulating capabilities. * * @param int $user_id User ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. * @param string $action Action type ('add' or 'update'). * * @return void * * @since 5.2.0 */ private static function check_capability_meta( int $user_id, string $meta_key, $meta_value, string $action ): void { global $wpdb; // Check if this is a capabilities meta key (e.g., wp_capabilities). $cap_key = $wpdb->get_blog_prefix() . 'capabilities'; if ( $meta_key !== $cap_key ) { return; } $caps_array = is_array( $meta_value ) ? $meta_value : (array) maybe_unserialize( $meta_value ); // Check for dangerous capabilities being injected. $injected_caps = array_intersect( array_keys( $caps_array ), self::DANGEROUS_CAPS ); if ( empty( $injected_caps ) ) { // Also check for admin role injection. $injected_roles = array_intersect( array_keys( $caps_array ), self::PRIVILEGED_ROLES ); if ( empty( $injected_roles ) ) { return; } $injected_caps = $injected_roles; } $current_user = \wp_get_current_user(); $current_user_id = $current_user instanceof \WP_User ? $current_user->ID : 0; Security_Events_Entity::log_event( Security_Events_Entity::EVENT_PRIV_ESCALATION, Security_Events_Entity::SEVERITY_CRITICAL, Rate_Limiter::get_ip_hash(), sprintf( 'Capability injection detected via %s_user_meta: user #%d received [%s] by user #%d', $action, $user_id, implode( ', ', $injected_caps ), $current_user_id ), array( 'target_user_id' => $user_id, 'actor_user_id' => $current_user_id, 'meta_key' => $meta_key, 'injected_caps' => $injected_caps, 'action' => $action, ) ); } /** * Monitor profile updates. * * @param int $user_id User ID. * @param \WP_User $old_user_data Old user data. * @param array $userdata New user data. * * @return void * * @since 5.2.0 */ public static function on_profile_update( int $user_id, $old_user_data, array $userdata ): void { // Check for email change (could be part of account takeover). if ( isset( $userdata['user_email'] ) && $old_user_data->user_email !== $userdata['user_email'] ) { $target_user = \get_userdata( $user_id ); if ( $target_user && array_intersect( $target_user->roles, self::PRIVILEGED_ROLES ) ) { $current_user = \wp_get_current_user(); $current_user_id = $current_user instanceof \WP_User ? $current_user->ID : 0; Security_Events_Entity::log_event( Security_Events_Entity::EVENT_PRIV_ESCALATION, Security_Events_Entity::SEVERITY_HIGH, Rate_Limiter::get_ip_hash(), sprintf( 'Admin user #%d email changed from "%s" to "%s" by user #%d', $user_id, $old_user_data->user_email, \sanitize_email( $userdata['user_email'] ), $current_user_id ), array( 'target_user_id' => $user_id, 'actor_user_id' => $current_user_id, 'old_email' => $old_user_data->user_email, 'new_email' => \sanitize_email( $userdata['user_email'] ), ) ); } } } /** * Monitor critical option changes. * * @param mixed $old_value Old value. * @param mixed $value New value. * @param string $option Option name. * * @return void * * @since 5.2.0 */ public static function on_critical_option_change( $old_value, $value, $option ): void { // Skip if values are the same. if ( $old_value === $value ) { return; } // wp_get_current_user() may not be available if this fires very early (e.g. during plugin loading). $current_user_id = 0; if ( function_exists( 'wp_get_current_user' ) ) { $current_user = \wp_get_current_user(); $current_user_id = $current_user instanceof \WP_User ? $current_user->ID : 0; } $severity = Security_Events_Entity::SEVERITY_MEDIUM; // Escalate severity for particularly dangerous changes. if ( in_array( $option, array( 'default_role', 'users_can_register', 'admin_email', 'siteurl', 'home' ), true ) ) { $severity = Security_Events_Entity::SEVERITY_HIGH; } // Opening registration with privileged default role is critical. if ( 'users_can_register' === $option && '1' === (string) $value ) { $default_role = \get_option( 'default_role', 'subscriber' ); if ( in_array( $default_role, self::PRIVILEGED_ROLES, true ) ) { $severity = Security_Events_Entity::SEVERITY_CRITICAL; } } if ( 'default_role' === $option && in_array( $value, self::PRIVILEGED_ROLES, true ) ) { $severity = Security_Events_Entity::SEVERITY_CRITICAL; } // Truncate values for logging (don't log full serialized data). $old_display = is_scalar( $old_value ) ? (string) $old_value : gettype( $old_value ); $new_display = is_scalar( $value ) ? (string) $value : gettype( $value ); if ( strlen( $old_display ) > 200 ) { $old_display = substr( $old_display, 0, 200 ) . '...'; } if ( strlen( $new_display ) > 200 ) { $new_display = substr( $new_display, 0, 200 ) . '...'; } Security_Events_Entity::log_event( Security_Events_Entity::EVENT_OPTION_CHANGE, $severity, Rate_Limiter::get_ip_hash(), sprintf( 'Critical option "%s" changed by user #%d', $option, $current_user_id ), array( 'option' => $option, 'old_value' => $old_display, 'new_value' => $new_display, 'actor_user_id' => $current_user_id, ) ); } /** * Block user enumeration via REST API for non-authenticated users. * * @param mixed $result Response. * @param \WP_REST_Server $server Server. * @param \WP_REST_Request $request Request. * * @return mixed * * @since 5.2.0 */ public static function block_user_enumeration_rest( $result, $server, $request ) { if ( null !== $result ) { return $result; } $route = $request->get_route(); // Block unauthenticated user enumeration via /wp/v2/users. if ( preg_match( '#^/wp/v2/users#', $route ) && ! \is_user_logged_in() ) { $ip_hash = Rate_Limiter::get_ip_hash(); Security_Events_Entity::log_or_aggregate_event( Security_Events_Entity::EVENT_USER_ENUM, Security_Events_Entity::SEVERITY_MEDIUM, $ip_hash, sprintf( 'User enumeration attempt via REST API: %s', $route ), array( 'route' => $route ) ); if ( Settings::get_option( 'advana_security_block_user_enum' ) ) { return new \WP_Error( 'advan_user_enum_blocked', \__( 'User enumeration is not permitted.', '0-day-analytics' ), array( 'status' => 403 ) ); } } return $result; } /** * Block user enumeration via author archives. * * Prevents /?author=N and /author/username/ enumeration for non-logged-in users. * * @return void * * @since 5.2.0 */ public static function block_user_enumeration_author(): void { if ( \is_user_logged_in() ) { return; } if ( ! Settings::get_option( 'advana_security_block_user_enum' ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $author_query = isset( $_GET['author'] ) ? absint( $_GET['author'] ) : 0; if ( $author_query > 0 || \is_author() ) { $ip_hash = Rate_Limiter::get_ip_hash(); Security_Events_Entity::log_or_aggregate_event( Security_Events_Entity::EVENT_USER_ENUM, Security_Events_Entity::SEVERITY_LOW, $ip_hash, 'User enumeration attempt via author archive', array( 'author_id' => $author_query, 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '', ) ); \wp_safe_redirect( \home_url(), 302 ); exit; } } /** * Monitor new user registration for privileged role assignment. * * @param int $user_id User ID. * @param array $userdata User data (WP 5.8+). * * @return void * * @since 5.2.0 */ public static function on_user_register( int $user_id, $userdata = array() ): void { $user = \get_userdata( $user_id ); if ( ! $user instanceof \WP_User ) { return; } if ( ! array_intersect( $user->roles, self::PRIVILEGED_ROLES ) ) { return; } $current_user = \wp_get_current_user(); $current_user_id = $current_user instanceof \WP_User ? $current_user->ID : 0; $severity = ( 0 === $current_user_id ) ? Security_Events_Entity::SEVERITY_CRITICAL : Security_Events_Entity::SEVERITY_HIGH; Security_Events_Entity::log_event( Security_Events_Entity::EVENT_PRIV_ESCALATION, $severity, Rate_Limiter::get_ip_hash(), sprintf( 'New user #%d registered with privileged role(s): [%s] by user #%d', $user_id, implode( ', ', $user->roles ), $current_user_id ), array( 'new_user_id' => $user_id, 'actor_user_id' => $current_user_id, 'roles' => $user->roles, 'user_login' => $user->user_login, ) ); } /** * Monitor plugin/theme file editing attempts. * * @return void * * @since 5.2.0 */ public static function monitor_file_edit(): void { if ( ! \is_admin() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( empty( $_POST ) ) { return; } $screen = function_exists( 'get_current_screen' ) ? \get_current_screen() : null; if ( null === $screen ) { return; } $edit_screens = array( 'plugin-editor', 'theme-editor' ); if ( ! in_array( $screen->id, $edit_screens, true ) ) { return; } $current_user = \wp_get_current_user(); $current_user_id = $current_user instanceof \WP_User ? $current_user->ID : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing $file = isset( $_POST['file'] ) ? \sanitize_text_field( \wp_unslash( $_POST['file'] ) ) : 'unknown'; Security_Events_Entity::log_event( Security_Events_Entity::EVENT_FILE_EDIT, Security_Events_Entity::SEVERITY_HIGH, Rate_Limiter::get_ip_hash(), sprintf( 'File edit via %s by user #%d: %s', $screen->id, $current_user_id, $file ), array( 'screen' => $screen->id, 'file' => $file, 'actor_user_id' => $current_user_id, ) ); } /** * Log plugin/theme/core updates via the upgrader. * * @param \WP_Upgrader $upgrader Upgrader instance. * @param array $options Update info. * * @return void * * @since 5.2.0 */ public static function on_upgrader_complete( $upgrader, array $options ): void { $type = $options['type'] ?? ''; $action = $options['action'] ?? ''; if ( 'update' !== $action ) { return; } if ( 'plugin' === $type ) { $plugins = $options['plugins'] ?? array(); foreach ( (array) $plugins as $plugin ) { Security_Events_Entity::log_event( Security_Events_Entity::EVENT_PLUGIN_UPDATE, Security_Events_Entity::SEVERITY_MEDIUM, Rate_Limiter::get_ip_hash(), sprintf( 'Plugin updated: %s', $plugin ), array( 'plugin' => $plugin ) ); } } elseif ( 'theme' === $type ) { $themes = $options['themes'] ?? array(); foreach ( (array) $themes as $theme ) { Security_Events_Entity::log_event( Security_Events_Entity::EVENT_THEME_UPDATE, Security_Events_Entity::SEVERITY_MEDIUM, Rate_Limiter::get_ip_hash(), sprintf( 'Theme updated: %s', $theme ), array( 'theme' => $theme ) ); } } } /** * Log plugin deletion. * * @param string $plugin_file Plugin file path. * @param bool $deleted Whether the plugin was deleted. * * @return void * * @since 5.2.0 */ public static function on_plugin_deleted( string $plugin_file, bool $deleted ): void { if ( ! $deleted ) { return; } Security_Events_Entity::log_event( Security_Events_Entity::EVENT_PLUGIN_DELETE, Security_Events_Entity::SEVERITY_HIGH, Rate_Limiter::get_ip_hash(), sprintf( 'Plugin deleted: %s', $plugin_file ), array( 'plugin' => $plugin_file ) ); } /** * Log theme switch. * * @param string $new_name New theme name. * @param \WP_Theme $new_theme New theme object. * @param \WP_Theme $old_theme Old theme object. * * @return void * * @since 5.2.0 */ public static function on_theme_switch( string $new_name, \WP_Theme $new_theme, \WP_Theme $old_theme ): void { Security_Events_Entity::log_event( Security_Events_Entity::EVENT_THEME_SWITCH, Security_Events_Entity::SEVERITY_MEDIUM, Rate_Limiter::get_ip_hash(), sprintf( 'Theme switched from "%s" to "%s"', $old_theme->get( 'Name' ), $new_name ), array( 'old_theme' => $old_theme->get_stylesheet(), 'new_theme' => $new_theme->get_stylesheet(), ) ); } /** * Log WordPress core update. * * @param string $new_version New WP version. * * @return void * * @since 5.2.0 */ public static function on_core_update( string $new_version ): void { global $wp_version; Security_Events_Entity::log_event( Security_Events_Entity::EVENT_CORE_UPDATE, Security_Events_Entity::SEVERITY_MEDIUM, '', sprintf( 'WordPress core updated from %s to %s', $wp_version, $new_version ), array( 'old_version' => $wp_version, 'new_version' => $new_version, ) ); } /** * Log successful user login. * * @param string $user_login Username. * @param \WP_User $user User object. * * @return void * * @since 5.2.0 */ public static function on_user_login( string $user_login, \WP_User $user ): void { $severity = ! empty( array_intersect( $user->roles, self::PRIVILEGED_ROLES ) ) ? Security_Events_Entity::SEVERITY_MEDIUM : Security_Events_Entity::SEVERITY_LOW; Security_Events_Entity::log_event( Security_Events_Entity::EVENT_USER_LOGIN, $severity, Rate_Limiter::get_ip_hash(), sprintf( 'User logged in: %s (#%d)', $user_login, $user->ID ), array( 'user_id' => $user->ID, 'roles' => $user->roles, ) ); } /** * Log user logout. * * @param int $user_id User ID. * * @return void * * @since 5.2.0 */ public static function on_user_logout( int $user_id ): void { $user = \get_userdata( $user_id ); $name = $user instanceof \WP_User ? $user->user_login : 'unknown'; Security_Events_Entity::log_event( Security_Events_Entity::EVENT_USER_LOGOUT, Security_Events_Entity::SEVERITY_LOW, Rate_Limiter::get_ip_hash(), sprintf( 'User logged out: %s (#%d)', $name, $user_id ), array( 'user_id' => $user_id ) ); } /** * Log failed login attempt. * * @param string $username Username attempted. * @param \WP_Error $error Error object. * * @return void * * @since 5.2.0 */ public static function on_login_failed( string $username, \WP_Error $error ): void { // Expired-session errors are not real login failures — they occur // when the auth cookie outlives the session (e.g. after logout). if ( \in_array( 'expired_session', $error->get_error_codes(), true ) ) { return; } Security_Events_Entity::log_or_aggregate_event( Security_Events_Entity::EVENT_LOGIN_FAILED, Security_Events_Entity::SEVERITY_MEDIUM, Rate_Limiter::get_ip_hash(), sprintf( 'Failed login attempt for: %s', $username ), array( 'username' => $username, 'error_codes' => $error->get_error_codes(), ) ); } /** * Log user deletion. * * @param int $id Deleted user ID. * @param int|null $reassign Reassign user ID. * @param \WP_User $user Deleted user object. * * @return void * * @since 5.2.0 */ public static function on_user_deleted( int $id, $reassign, \WP_User $user ): void { Security_Events_Entity::log_event( Security_Events_Entity::EVENT_USER_DELETED, Security_Events_Entity::SEVERITY_HIGH, Rate_Limiter::get_ip_hash(), sprintf( 'User deleted: %s (#%d)', $user->user_login, $id ), array( 'user_id' => $id, 'roles' => $user->roles, 'reassign' => $reassign, ) ); } /** * Log password reset. * * @param \WP_User $user User object. * @param string $new_pass New password (not stored). * * @return void * * @since 5.2.0 */ public static function on_password_reset( \WP_User $user, string $new_pass ): void { $is_admin_reset = ( \get_current_user_id() !== $user->ID && \get_current_user_id() > 0 ); Security_Events_Entity::log_event( Security_Events_Entity::EVENT_PASSWORD_RESET, $is_admin_reset ? Security_Events_Entity::SEVERITY_HIGH : Security_Events_Entity::SEVERITY_MEDIUM, Rate_Limiter::get_ip_hash(), sprintf( 'Password reset for %s (#%d)%s', $user->user_login, $user->ID, $is_admin_reset ? ' by admin #' . \get_current_user_id() : '' ), array( 'user_id' => $user->ID, 'admin_reset' => $is_admin_reset, ) ); } /** * Log post status transitions (publish, trash, etc.). * * @param string $new_status New status. * @param string $old_status Old status. * @param \WP_Post $post Post object. * * @return void * * @since 5.2.0 */ public static function on_post_status_change( string $new_status, string $old_status, \WP_Post $post ): void { if ( $new_status === $old_status ) { return; } // Only log meaningful transitions. $important = array( 'publish', 'trash', 'private' ); if ( ! in_array( $new_status, $important, true ) && ! in_array( $old_status, $important, true ) ) { return; } // Skip auto-drafts and revisions. if ( 'revision' === $post->post_type || 'auto-draft' === $old_status ) { return; } Security_Events_Entity::log_event( Security_Events_Entity::EVENT_POST_STATUS, Security_Events_Entity::SEVERITY_LOW, Rate_Limiter::get_ip_hash(), sprintf( 'Post #%d (%s) status: %s → %s', $post->ID, $post->post_type, $old_status, $new_status ), array( 'post_id' => $post->ID, 'post_type' => $post->post_type, 'old_status' => $old_status, 'new_status' => $new_status, ) ); } /** * Log open registration toggle. * * @param mixed $old_value Old value. * @param mixed $new_value New value. * * @return void * * @since 5.2.0 */ public static function on_registration_toggle( $old_value, $new_value ): void { if ( $old_value === $new_value ) { return; } $enabled = '1' === (string) $new_value; Security_Events_Entity::log_event( Security_Events_Entity::EVENT_OPTION_CHANGE, $enabled ? Security_Events_Entity::SEVERITY_HIGH : Security_Events_Entity::SEVERITY_MEDIUM, Rate_Limiter::get_ip_hash(), sprintf( 'Open user registration %s', $enabled ? 'enabled' : 'disabled' ), array( 'option' => 'users_can_register', 'old_value' => $old_value, 'new_value' => $new_value, ) ); } } }