$result, 'admins' => AdminAudit::admins( $result['hidden_ids'] ), ]; } /** * Drop an account from the stored verdict after it has been removed, so the * UI and dashboard stop reporting a user that no longer exists. * * @param int $user_id */ public static function forget( $user_id ) { $user_id = (int) $user_id; $r = self::get_last_result(); if ( ! is_array( $r ) ) { return; } $drop = static function ( $a ) use ( $user_id ) { return (int) $a['id'] !== $user_id; }; $r['hidden_ids'] = array_values( array_diff( (array) $r['hidden_ids'], [ $user_id ] ) ); $r['hidden'] = array_values( array_filter( (array) $r['hidden'], $drop ) ); $r['suspicious_ids'] = array_values( array_diff( (array) $r['suspicious_ids'], [ $user_id ] ) ); $r['suspicious'] = array_values( array_filter( (array) $r['suspicious'], $drop ) ); update_option( self::RESULT_OPTION, $r, false ); update_option( self::STATE_OPTION, implode( ',', $r['hidden_ids'] ), false ); } /** * @return array|null Last stored reconciliation result. */ public static function get_last_result() { $r = get_option( self::RESULT_OPTION, null ); return is_array( $r ) ? $r : null; } /* --------------------------------------------------------------------- */ /* Internals */ /* --------------------------------------------------------------------- */ /** * Compute the reconciled verdict for the current context. * * @param bool $authoritative * @return array */ private static function reconcile( $authoritative ) { $audit = AdminAudit::audit(); $raw = AdminAudit::raw_admin_ids(); // Hidden accounts seen live in this context (bounded to real admins). $live_hidden = array_values( array_intersect( $audit['hidden_ids'], $raw ) ); if ( $authoritative ) { $hidden_ids = $live_hidden; } else { // Never downgrade a standing detection from a non-admin context, but // drop ids that are no longer administrators (deleted / demoted). $prev = self::get_last_result(); $prev_hidden = is_array( $prev ) && ! empty( $prev['hidden_ids'] ) ? array_intersect( (array) $prev['hidden_ids'], $raw ) : []; $hidden_ids = array_values( array_unique( array_merge( $live_hidden, $prev_hidden ) ) ); } sort( $hidden_ids ); $all = AdminAudit::admins( $hidden_ids ); $hidden = array_values( array_filter( $all, static function ( $a ) { return $a['hidden']; } ) ); $suspicious = array_values( array_filter( $all, static function ( $a ) { return $a['suspicious']; } ) ); $visible_count = count( $all ) - count( $hidden ); return [ 'raw_count' => count( $all ), 'visible_count' => $visible_count, 'counted' => $audit['counted'], 'hidden' => $hidden, 'hidden_ids' => array_map( static function ( $a ) { return $a['id']; }, $hidden ), 'suspicious' => $suspicious, 'suspicious_ids' => array_map( static function ( $a ) { return $a['id']; }, $suspicious ), 'mismatch' => ! empty( $hidden ) || $audit['counted'] > $visible_count, 'checked_at' => current_time( 'mysql' ), ]; } /** * Log a critical event and (optionally) email the site owner about newly * detected hidden administrators. * * @param array $result */ private static function alert( $result ) { $count = count( $result['hidden'] ); $names = implode( ', ', array_map( static function ( $a ) { return sprintf( '%s (%s)', $a['login'], $a['email'] ); }, $result['hidden'] ) ); Logger::log( 'hidden_admin_detected', sprintf( /* translators: 1: count, 2: comma-separated account list. */ _n( '%1$d hidden administrator account detected: %2$s. It exists in the database but is concealed from the Users screen — a strong indicator the site is compromised.', '%1$d hidden administrator accounts detected: %2$s. They exist in the database but are concealed from the Users screen — a strong indicator the site is compromised.', $count, 'gemsecurity' ), $count, $names ), Logger::SEVERITY_CRITICAL, [ 'context' => [ 'hidden_ids' => $result['hidden_ids'], 'counts' => [ 'database' => $result['raw_count'], 'visible' => $result['visible_count'], 'counted' => $result['counted'], ], ], ] ); if ( Helper::get_setting( 'notify_on_hidden_admin', true ) ) { self::notify( $result, $names, $count ); } } /** * @param array $result * @param string $names * @param int $count */ private static function notify( $result, $names, $count ) { Mailer::send( \GemSecurity\Mailer::recipient(), '🕵️', __( 'Hidden administrator account detected', 'gemsecurity' ), [ [ 'type' => 'notice', 'tone' => 'critical', 'text' => sprintf( /* translators: %d: number of hidden accounts. */ _n( '%d administrator account is hidden from the WordPress Users screen. A hidden administrator almost always means the site has been compromised.', '%d administrator accounts are hidden from the WordPress Users screen. A hidden administrator almost always means the site has been compromised.', $count, 'gemsecurity' ), $count ), ], [ 'type' => 'table', 'heading' => __( 'What was found', 'gemsecurity' ), 'rows' => [ [ __( 'Hidden account(s)', 'gemsecurity' ), esc_html( $names ) ], [ __( 'Admins in the database', 'gemsecurity' ), (int) $result['raw_count'] ], [ __( 'Admins visible in wp-admin', 'gemsecurity' ), (int) $result['visible_count'] ], ], ], [ 'type' => 'paragraph', 'text' => __( 'Review and remove the account, then run a full malware scan and rotate every administrator password.', 'gemsecurity' ), ], [ 'type' => 'button', 'url' => admin_url( 'admin.php?page=' . GEMSECURITY_PLUGIN_SLUG . '-admin-guard' ), 'label' => __( 'Open Admin Guard', 'gemsecurity' ), ], ] ); } }