*/ private static $creating = []; /** Recurring sweep for unapproved administrators already on the site. */ const CRON_HOOK = 'gemsecurity_admin_guard_sweep'; public static function init() { $self = new self(); add_filter( 'insert_user_meta', [ $self, 'mark_creating' ], 10, 3 ); // New accounts are caught at registration; role changes on existing // accounts are caught at set_user_role. In modern WordPress set_role() // internally fires add_user_role/remove_user_role *before* set_user_role, // so hooking those would double-process and corrupt the role — we don't. add_action( 'user_register', [ $self, 'on_user_register' ], 99, 1 ); add_action( 'set_user_role', [ $self, 'on_set_user_role' ], 99, 3 ); // add_role() does not fire set_user_role, so a promotion made that way // would slip past the hooks above. add_action( 'add_user_role', [ $self, 'on_add_user_role' ], 99, 2 ); // Enforcement for accounts the hooks never saw. Priority 99 puts this // after WordPress has authenticated the credentials, so an unapproved // account is only revealed to someone who already knows its password. add_filter( 'authenticate', [ $self, 'block_login' ], 99, 1 ); // Magic links and social login skip `authenticate`. add_filter( 'gemsecurity/login_gate', [ $self, 'block_login' ] ); // And end a session that predates the whitelist. add_action( 'init', [ $self, 'guard_session' ], 1 ); // Dormant accounts never reach either check, so sweep for them: hourly, // and immediately whenever the whitelist itself changes. add_action( self::CRON_HOOK, [ __CLASS__, 'run_scheduled_sweep' ] ); add_action( 'init', [ __CLASS__, 'schedule_sweep' ] ); add_action( 'update_option_' . GEMSECURITY_SETTINGS_NAME, [ __CLASS__, 'on_settings_saved' ], 10, 2 ); // "Authorize this administrator" on the Add User / Edit User screens, so an // approved administrator can create another one without it being removed. // Processed at priority 5: before the removal hooks above (99). add_action( 'user_new_form', [ $self, 'render_new_user_field' ] ); add_action( 'edit_user_profile', [ $self, 'render_edit_user_field' ] ); add_action( 'user_register', [ $self, 'authorize_from_request' ], 5, 1 ); add_action( 'set_user_role', [ $self, 'authorize_from_request' ], 5, 1 ); add_action( 'add_user_role', [ $self, 'authorize_from_request' ], 5, 1 ); // Say what happened on the next admin screen. add_action( 'admin_init', [ $self, 'load_flash' ] ); // Not `admin_notices`: other plugins clear that hook of everyone else's // callbacks at `in_admin_header` (priority 1000, e.g. a plugin that keeps // its own screens tidy), which would silently swallow this message. It is // printed after them instead; core's admin JS moves any `.notice` printed // here up under the page title, exactly like a normal admin notice. add_action( 'in_admin_header', [ $self, 'show_flash' ], 1100 ); } /* --------------------------------------------------------------------- */ /* Authorize-on-create / on-promote */ /* --------------------------------------------------------------------- */ const APPROVE_FIELD = 'gemsec_approve_admin'; const APPROVE_NONCE = 'gemsec_approve_admin_nonce'; /** Add User screen. @param string $type 'add-new-user' (or 'add-existing-user' on multisite). */ public function render_new_user_field( $type ) { if ( 'add-new-user' !== $type || ! $this->guard_enabled() || ! current_user_can( 'promote_users' ) ) { return; } $this->render_field( null ); } /** Edit User screen (another user's profile). @param \WP_User $user */ public function render_edit_user_field( $user ) { if ( ! $user instanceof \WP_User || ! $this->guard_enabled() || ! current_user_can( 'promote_users' ) ) { return; } $this->render_field( $user ); } /** * The checkbox row. Shown only while the Role field says Administrator. * * @param \WP_User|null $user The account being edited; null on Add User. */ private function render_field( $user ) { $approved = $user && $this->is_whitelisted( $user ); $can = $this->can_approve( wp_get_current_user() ); echo ''; wp_print_inline_script_tag( '(function(){var r=document.getElementById("role"),b=document.getElementById("gemsec-approve-admin");if(!r||!b){return;}function s(){b.style.display=r.value==="administrator"?"":"none";}r.addEventListener("change",s);s();}());' ); } /** * Read the checkbox from the Add User / Edit User request and approve the * account before the removal hooks run. * * Only an administrator who is already approved (whitelisted or protected) may * approve another one — otherwise a rogue administrator could simply authorize * their own accounts. * * @param int $user_id */ public function authorize_from_request( $user_id ) { if ( ! $this->guard_enabled() ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified just below. if ( empty( $_POST[ self::APPROVE_FIELD ] ) ) { return; } $nonce = isset( $_POST[ self::APPROVE_NONCE ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::APPROVE_NONCE ] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'gemsec_approve_admin' ) ) { return; } // The Edit User form names the account it is about; never approve a // different one that happens to be touched in the same request. if ( isset( $_POST['user_id'] ) && (int) $_POST['user_id'] !== (int) $user_id ) { return; } // phpcs:enable $user = new \WP_User( (int) $user_id ); if ( ! $user->exists() || ! in_array( 'administrator', (array) $user->roles, true ) || $this->is_whitelisted( $user ) ) { return; } $actor = wp_get_current_user(); if ( ! $actor->exists() || ! current_user_can( 'promote_users' ) || ! $this->can_approve( $actor ) ) { self::flash( 'error', __( 'You cannot authorize administrators: only an already-approved administrator can.', 'gemsecurity' ) ); return; } $list = $this->whitelist(); $seeded = empty( $list ); if ( $seeded ) { // An empty whitelist means the guard is not enforcing yet. Adding one // address would switch enforcement on and remove every administrator // not on it, so approve the current ones in the same step. $list = self::current_admin_emails(); } $email = strtolower( trim( $user->user_email ) ); if ( ! in_array( $email, $list, true ) ) { $list[] = $email; } $settings = get_option( GEMSECURITY_SETTINGS_NAME, [] ); $settings = is_array( $settings ) ? $settings : []; $settings['admin_whitelist'] = array_values( array_unique( $list ) ); update_option( GEMSECURITY_SETTINGS_NAME, $settings ); Logger::log( 'admin_authorized', sprintf( 'Administrator "%s" (%s) was authorized by %s.', $user->user_login, $user->user_email, $actor->user_login ), Logger::SEVERITY_WARNING, [ 'context' => [ 'target_user_id' => (int) $user->ID, 'target_email' => $user->user_email, 'authorized_by' => (int) $actor->ID ] ] ); self::flash( 'success', sprintf( /* translators: %s: username. */ __( '%s was authorized as an administrator and added to the Admin Guard whitelist.', 'gemsecurity' ), $user->user_login ) . ( $seeded ? ' ' . __( 'The whitelist was empty, so the current administrators were approved too.', 'gemsecurity' ) : '' ) ); } /** * Whether this administrator may approve others: whitelisted, or one of the * protected accounts (user ID 1, the site-owner email, super admins). * * @param \WP_User $user * @return bool */ private function can_approve( $user ) { if ( ! $user instanceof \WP_User || ! $user->exists() ) { return false; } return $this->is_whitelisted( $user ) || AdminAudit::is_protected( $user, false ); } /* --------------------------------------------------------------------- */ /* Notices */ /* --------------------------------------------------------------------- */ /** * Queue a notice for the current user's next admin screen. The Add User and * Edit User saves redirect, so the message has to survive the redirect. * * @param string $type success | warning | error * @param string $message Plain text. */ private static function flash( $type, $message ) { if ( ! get_current_user_id() || headers_sent() ) { return; } // A signed cookie, not an option or transient: the next page may be served // by a different PHP worker whose object cache has not seen this write, and // a notice that silently never appears is worse than none. self::$queue[] = [ 'type' => $type, 'message' => $message ]; setcookie( self::FLASH_COOKIE, Helper::sign_payload( [ 'items' => self::$queue ] ), [ 'expires' => time() + 5 * MINUTE_IN_SECONDS, 'path' => '/', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ] ); } /** Notices written this request, kept so a second one adds to the first. */ private static $queue = []; /** Notices read from the previous request, waiting to be printed. */ private static $pending = []; const FLASH_COOKIE = 'gemsec_admin_flash'; /** * Pick up the notices left by the previous request and clear the cookie. Runs * on admin_init because the cookie can only be cleared before output starts. */ public function load_flash() { if ( empty( $_COOKIE[ self::FLASH_COOKIE ] ) ) { return; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- signature-checked below. $data = Helper::verify_signed_payload( wp_unslash( $_COOKIE[ self::FLASH_COOKIE ] ), 5 * MINUTE_IN_SECONDS ); if ( ! headers_sent() ) { setcookie( self::FLASH_COOKIE, '', [ 'expires' => time() - 3600, 'path' => '/' ] ); } unset( $_COOKIE[ self::FLASH_COOKIE ] ); if ( is_array( $data ) && ! empty( $data['items'] ) && is_array( $data['items'] ) ) { self::$pending = $data['items']; } } /** Print the notices picked up by load_flash(). */ public function show_flash() { $items = self::$pending; self::$pending = []; foreach ( $items as $item ) { if ( ! is_array( $item ) || ! isset( $item['type'], $item['message'] ) ) { continue; } $type = in_array( $item['type'], [ 'success', 'warning', 'error' ], true ) ? $item['type'] : 'info'; printf( '

%2$s %3$s

', esc_attr( $type ), esc_html__( 'Admin Guard:', 'gemsecurity' ), esc_html( $item['message'] ) ); } } /** * Tell the person who just created or promoted an administrator that Admin * Guard undid it, and what to do instead. Only when a signed-in person did it * from the admin — a cron sweep or a login has nobody to show it to. * * @param string $login * @param string $action 'delete' | 'demote' * @param string $origin * @param string $fallback Role the account was set to when demoted. */ private static function flash_removal( $login, $action, $origin, $fallback ) { if ( ! is_admin() || ! get_current_user_id() || ! in_array( $origin, [ 'registration', 'promotion' ], true ) ) { return; } $tip = __( 'To keep an administrator, tick “Authorize this administrator” on the user form, or add their email to the Admin Guard whitelist first.', 'gemsecurity' ); if ( 'delete' === $action ) { self::flash( 'warning', sprintf( /* translators: %s: username. */ __( 'The administrator account “%s” was removed because its email is not on the whitelist.', 'gemsecurity' ), $login ) . ' ' . $tip ); return; } self::flash( 'warning', sprintf( /* translators: 1: username, 2: role. */ __( '“%1$s” was not made an administrator — its email is not on the whitelist — and was set to %2$s.', 'gemsecurity' ), $login, $fallback ) . ' ' . $tip ); } /** Keep the recurring sweep scheduled while the guard is enabled. */ public static function schedule_sweep() { $self = new self(); $scheduled = wp_next_scheduled( self::CRON_HOOK ); if ( $self->enforcement_active() ) { if ( ! $scheduled ) { wp_schedule_event( time() + MINUTE_IN_SECONDS, 'hourly', self::CRON_HOOK ); } } elseif ( $scheduled ) { wp_unschedule_event( $scheduled, self::CRON_HOOK ); } } /** Cron callback. */ public static function run_scheduled_sweep() { self::sweep( 'cron' ); } /** * Re-sweep when the whitelist or the guard's own switches change, so * approving or un-approving somebody takes effect at once instead of at * the next scheduled run. * * @param mixed $old * @param mixed $new */ public static function on_settings_saved( $old, $new ) { $keys = [ 'admin_whitelist_enabled', 'admin_whitelist', 'rogue_admin_action' ]; foreach ( $keys as $key ) { $before = is_array( $old ) && isset( $old[ $key ] ) ? $old[ $key ] : null; $after = is_array( $new ) && isset( $new[ $key ] ) ? $new[ $key ] : null; if ( $before !== $after ) { // `init` has already run by the time settings are saved, so // start (or stop) the recurring sweep here rather than leaving // it until the next page load. self::schedule_sweep(); self::sweep( 'settings' ); return; } } } /** * Flag brand-new users so the role hooks skip them during creation. * * @param array $meta * @param \WP_User $user * @param bool $update * @return array */ public function mark_creating( $meta, $user, $update ) { if ( ! $update && isset( $user->ID ) ) { self::$creating[ (int) $user->ID ] = true; } return $meta; } /* --------------------------------------------------------------------- */ /* Hook handlers */ /* --------------------------------------------------------------------- */ /** * Brand-new account created with administrator privileges → delete. * * @param int $user_id */ public function on_user_register( $user_id ) { $user_id = (int) $user_id; // Creation is complete — clear the in-progress flag for this user. unset( self::$creating[ $user_id ] ); if ( ! $this->guard_enabled() ) { return; } $user = get_user_by( 'id', $user_id ); if ( ! $this->is_unapproved_admin( $user ) ) { return; } $this->remove_rogue_admin( $user, 'delete', 'registration' ); } /** * Role replaced via set_role(). If the user becomes an unapproved admin, * revert (or delete) per the configured action. * * @param int $user_id * @param string $role * @param string[] $old_roles */ public function on_set_user_role( $user_id, $role, $old_roles ) { if ( ! $this->guard_enabled() || 'administrator' !== $role ) { return; } // Skip while the account is being created — on_user_register handles it. if ( isset( self::$creating[ (int) $user_id ] ) ) { return; } $user = get_user_by( 'id', $user_id ); if ( ! $this->is_unapproved_admin( $user ) ) { return; } $action = Helper::get_setting( 'rogue_admin_action', 'demote' ); $fallback = $this->pick_fallback_role( $old_roles ); $this->remove_rogue_admin( $user, $action, 'promotion', $fallback ); } /** * Role added via add_role() rather than set_role(). * * @param int $user_id * @param string $role */ public function on_add_user_role( $user_id, $role ) { if ( ! $this->guard_enabled() || 'administrator' !== $role ) { return; } // set_role() internally calls remove_user_role/add_user_role, so skip // when set_user_role is going to handle it, and during creation. if ( isset( self::$creating[ (int) $user_id ] ) || doing_action( 'set_user_role' ) ) { return; } $user = get_user_by( 'id', $user_id ); if ( ! $this->is_unapproved_admin( $user ) ) { return; } $action = Helper::get_setting( 'rogue_admin_action', 'demote' ); $this->remove_rogue_admin( $user, $action, 'promotion', $this->pick_fallback_role( $user->roles ) ); } /* --------------------------------------------------------------------- */ /* Login & session enforcement */ /* --------------------------------------------------------------------- */ /** * Refuse a login from an administrator who is not on the whitelist. * * Runs after the password has been verified, so this never tells an * anonymous prober whether an account exists. The configured remediation * runs too: an account that reaches the login form unapproved is one the * creation hooks missed, so leaving it an administrator would just defer * the problem to the next request. * * @param \WP_User|\WP_Error|null $user * @return \WP_User|\WP_Error|null */ public function block_login( $user ) { if ( ! $user instanceof \WP_User || ! $this->enforcement_active() ) { return $user; } if ( ! $this->is_unapproved_admin( $user, false ) ) { return $user; } $login = $user->user_login; $email = $user->user_email; Logger::log( 'rogue_admin_login_blocked', sprintf( 'Blocked a sign-in by unapproved administrator "%s" (%s).', $login, $email ), Logger::SEVERITY_CRITICAL, [ 'context' => [ 'target_user_id' => $user->ID, 'target_email' => $email ] ] ); $this->remove_rogue_admin( $user, Helper::get_setting( 'rogue_admin_action', 'demote' ), 'login', $this->pick_fallback_role( $user->roles ) ); return new \WP_Error( 'gemsecurity_admin_not_approved', __( 'Sign-in blocked. This administrator account is not on the approved list.', 'gemsecurity' ) ); } /** * End a session belonging to an unapproved administrator. * * Covers the account that was already signed in when the whitelist was * turned on, or that was promoted out-of-band mid-session — blocking the * login form alone would leave those sessions untouched. */ public function guard_session() { if ( ! is_user_logged_in() || ! $this->enforcement_active() ) { return; } $user = wp_get_current_user(); // $allow_self = false: here the unapproved administrator *is* the // current user, so the usual self-protection would disable the check. if ( ! $this->is_unapproved_admin( $user, false ) ) { return; } Logger::log( 'rogue_admin_session_ended', sprintf( 'Ended the session of unapproved administrator "%s" (%s).', $user->user_login, $user->user_email ), Logger::SEVERITY_CRITICAL, [ 'context' => [ 'target_user_id' => $user->ID, 'target_email' => $user->user_email ] ] ); $this->remove_rogue_admin( $user, Helper::get_setting( 'rogue_admin_action', 'demote' ), 'session', $this->pick_fallback_role( $user->roles ) ); wp_logout(); // Only a page view can be redirected. Ending the session is the point; // bouncing a REST, AJAX, cron or CLI request to the login screen would // just corrupt its response. if ( wp_doing_ajax() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) { return; } wp_safe_redirect( wp_login_url() ); exit; } /* --------------------------------------------------------------------- */ /* Sweep of existing accounts */ /* --------------------------------------------------------------------- */ /** * Remove every administrator that is not on the whitelist. * * Blocking logins stops an unapproved administrator being *used*, but the * account still exists, still holds the role, and still comes back the * moment this module is switched off. The sweep is what actually removes * it — including accounts that never log in, which is the usual shape of a * back door left behind for later. * * Administrators are read from the filter-proof audit source, so an account * hidden from the users list by a `pre_user_query` filter is swept too. * * @param string $origin Why the sweep ran: cron | settings | manual. * @param bool $dry_run Report what would happen without touching anything. * @return array { checked, removed[], protected[], action, ran } */ public static function sweep( $origin = 'manual', $dry_run = false ) { $self = new self(); $action = Helper::get_setting( 'rogue_admin_action', 'demote' ); $out = [ 'checked' => 0, 'removed' => [], 'protected' => [], 'action' => $action, 'ran' => false, ]; // Same rule as login enforcement: an empty whitelist means "not // configured", and sweeping on it would remove every administrator. if ( ! $self->enforcement_active() ) { return $out; } $out['ran'] = true; foreach ( AdminAudit::raw_admin_ids() as $user_id ) { $user = get_userdata( (int) $user_id ); if ( ! $user instanceof \WP_User ) { continue; } $out['checked']++; if ( $self->is_whitelisted( $user ) ) { continue; } // Protected accounts are reported, never removed — the sweep must // not be able to orphan a site. `$allow_self` stays on so a manual // run from the admin cannot delete the operator running it. if ( $self->is_protected( $user ) ) { $out['protected'][] = [ 'id' => (int) $user->ID, 'login' => $user->user_login, 'email' => $user->user_email, ]; continue; } $out['removed'][] = [ 'id' => (int) $user->ID, 'login' => $user->user_login, 'email' => $user->user_email, ]; if ( ! $dry_run ) { $self->remove_rogue_admin( $user, $action, 'sweep:' . $origin, $self->pick_fallback_role( $user->roles ) ); } } return $out; } /** * Whether login/session enforcement should run. * * Deliberately stricter than the hook path: with an empty whitelist every * administrator is "unapproved", and enforcing that would lock the site's * owner out of their own site with no way back in. An empty list means the * feature is not configured yet, not that nobody is allowed. * * @return bool */ private function enforcement_active() { return $this->guard_enabled() && ! empty( $this->whitelist() ); } /* --------------------------------------------------------------------- */ /* Core logic */ /* --------------------------------------------------------------------- */ /** * @param \WP_User|false $user * @param bool $allow_self Treat the current user as protected. * True on the hook paths (an admin * editing users must not remove * themselves mid-request); false on the * login and session paths, where the * unapproved administrator is the * current user and self-protection would * disable the check entirely. * @return bool True when the user is an administrator that is not approved. */ private function is_unapproved_admin( $user, $allow_self = true ) { if ( ! $user instanceof \WP_User ) { return false; } if ( ! in_array( 'administrator', (array) $user->roles, true ) ) { return false; } if ( $this->is_protected( $user, $allow_self ) ) { return false; } return ! $this->is_whitelisted( $user ); } /** * Accounts that must never be auto-removed. * * Delegates to {@see AdminAudit::is_protected()} — the guard and the audit * used to carry byte-identical copies of these rules, so a rule added to * one silently did not apply to the other. * * @param \WP_User $user * @param bool $allow_self Whether the current user is protected. * @return bool */ private function is_protected( $user, $allow_self = true ) { return AdminAudit::is_protected( $user, $allow_self ); } /** * @param \WP_User $user * @return bool */ private function is_whitelisted( $user ) { $email = strtolower( trim( $user->user_email ) ); return in_array( $email, $this->whitelist(), true ); } /** * Normalised list of whitelisted admin emails. * * @return string[] */ private function whitelist() { $list = (array) Helper::get_setting( 'admin_whitelist', [] ); return array_values( array_filter( array_map( static function ( $e ) { return strtolower( trim( (string) $e ) ); }, $list ) ) ); } /** * @param string[] $old_roles * @return string */ private function pick_fallback_role( $old_roles ) { $old_roles = array_values( array_filter( (array) $old_roles, static function ( $r ) { return 'administrator' !== $r; } ) ); return ! empty( $old_roles ) ? $old_roles[0] : 'subscriber'; } /** * Remove an unapproved administrator. * * @param \WP_User $user * @param string $action 'delete' or 'demote'. * @param string $origin How the account became an admin. * @param string $fallback Role to assign when demoting. */ private function remove_rogue_admin( $user, $action, $origin, $fallback = 'subscriber' ) { if ( 'delete' === $action ) { $email = $user->user_email; $login = $user->user_login; $id = $user->ID; // Core's own "new user registration" admin email is still queued to // fire after this hook returns (on 'register_new_user' or // 'edit_user_created_user', depending on how the account was // created) and looks the user back up by ID. Since we are about to // delete that exact user, that lookup would fail and core would // dereference properties on `false` (a harmless-looking PHP // warning, not a fatal, but still worth not leaving behind) -- // and an admin email announcing an account GemSecurity already // removed as unauthorised is not useful anyway, given the // dedicated DANGER alert below covers it. add_filter( 'wp_send_new_user_notification_to_admin', '__return_false', 999 ); AdminAudit::delete_user( $id ); $this->log_removal( 'rogue_admin_deleted', $login, $email, $id, 'delete', $origin ); $this->maybe_notify( $login, $email, 'delete', $origin ); self::flash_removal( $login, 'delete', $origin, $fallback ); return; } // Demote. $user->set_role( $fallback ); $this->after_removal( $user, 'demote', $origin ); self::flash_removal( $user->user_login, 'demote', $origin, $fallback ); } /** * @param \WP_User $user * @param string $action * @param string $origin */ private function after_removal( $user, $action, $origin ) { $this->log_removal( 'rogue_admin_demoted', $user->user_login, $user->user_email, $user->ID, $action, $origin ); $this->maybe_notify( $user->user_login, $user->user_email, $action, $origin ); } private function log_removal( $event, $login, $email, $id, $action, $origin ) { Logger::log( $event, sprintf( 'Unapproved administrator "%s" (%s) was %s. Origin: %s.', $login, $email, 'delete' === $action ? 'deleted' : 'demoted', $origin ), Logger::SEVERITY_CRITICAL, [ 'context' => [ 'target_user_id' => $id, 'target_email' => $email, 'action' => $action, 'origin' => $origin ] ] ); } /** * Email the site owner when a rogue admin is neutralised. */ private function maybe_notify( $login, $email, $action, $origin ) { if ( ! Helper::get_setting( 'notify_on_rogue_admin', true ) ) { return; } $to = \GemSecurity\Mailer::recipient(); $verb = 'delete' === $action ? __( 'deleted', 'gemsecurity' ) : __( 'demoted', 'gemsecurity' ); Mailer::send( $to, '🚨', __( 'Unapproved administrator blocked', 'gemsecurity' ), [ [ 'type' => 'notice', 'tone' => 'critical', 'text' => sprintf( /* translators: 1: username, 2: deleted|demoted */ __( 'The administrator account %1$s was not on the approved list and has been %2$s.', 'gemsecurity' ), esc_html( $login ), esc_html( $verb ) ), ], [ 'type' => 'table', 'heading' => __( 'Account', 'gemsecurity' ), 'rows' => [ [ __( 'Username', 'gemsecurity' ), esc_html( $login ) ], [ __( 'Email', 'gemsecurity' ), esc_html( $email ) ], [ __( 'Trigger', 'gemsecurity' ), esc_html( $origin ) ], [ __( 'IP address', 'gemsecurity' ), esc_html( Helper::get_ip_address() ) ], ], ], [ 'type' => 'paragraph', 'text' => __( 'If this administrator was legitimate, add their email to the whitelist and recreate the account.', 'gemsecurity' ), ], [ 'type' => 'button', 'url' => admin_url( 'admin.php?page=' . GEMSECURITY_PLUGIN_SLUG . '-admin-guard' ), 'label' => __( 'Review Admin Guard', 'gemsecurity' ), ], ] ); } /* --------------------------------------------------------------------- */ /* Public helpers */ /* --------------------------------------------------------------------- */ private function guard_enabled() { // Never run during the initial WordPress install. if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) { return false; } return (bool) Helper::get_setting( 'admin_whitelist_enabled', false ); } /** * Current administrator emails — used to seed the whitelist on enable. * * @return string[] */ public static function current_admin_emails() { $admins = get_users( [ 'role' => 'administrator', 'fields' => [ 'user_email' ] ] ); return array_values( array_unique( array_filter( array_map( static function ( $u ) { return strtolower( trim( $u->user_email ) ); }, $admins ) ) ) ); } }