*/
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(
'',
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 ) ) ) );
}
}