expiry. */ const OPTION_LINKS = 'csdt_lockdown_links'; /** Last EVENTS_MAX engage, release and would-engage events, for the panel. */ const OPTION_EVENTS = 'csdt_lockdown_events'; const TRIGGER_OFF = 'off'; const TRIGGER_REPORT = 'report'; const TRIGGER_ENFORCE = 'enforce'; /** Report only until the owner opts the trigger into enforcing. */ const DEFAULTS = array( 'trigger' => self::TRIGGER_REPORT, 'link' => '1', 'signout' => '1', 'admin_only' => '0', ); /** Defined true in wp-config.php: every rule is suspended, the state is kept. */ const DISABLE_CONSTANT = 'CSDT_LOCKDOWN_DISABLE'; /** The query argument the engage link arrives on. */ const QUERY_ARG = 'csdt_lockdown'; /** How long an engage link lives. */ const LINK_TTL = 30 * MINUTE_IN_SECONDS; /** Most outstanding links kept; the oldest go first. */ const LINK_MAX = 20; /** Events kept for the panel. */ const EVENTS_MAX = 20; /** Failed logins from one address, while engaged, before it is refused. */ const STRICT_FAILS = 3; /** How long that refusal, and the count behind it, lasts. */ const STRICT_WINDOW = 30 * MINUTE_IN_SECONDS; /** One Activity Log row per (refusal, address) in this window. */ const LOG_WINDOW = 10 * MINUTE_IN_SECONDS; /** One report-only alert per signal in this window. */ const SIGNAL_WINDOW = HOUR_IN_SECONDS; /** The heartbeat's own mu-plugin loader, installed by an admin button: not a signal. */ const OWN_MU_LOADER = 'mu-plugins/cloudscale-heartbeat-loader.php'; /** * Capabilities refused to everyone while engaged, through map_meta_cap so that * do_not_allow also stops a multisite super admin. manage_options is never here. */ const FROZEN_CAPS = array( 'install_plugins', 'upload_plugins', 'update_plugins', 'edit_plugins', 'activate_plugin', 'install_themes', 'upload_themes', 'update_themes', 'edit_themes', 'switch_themes', 'edit_files', 'create_users', 'add_users', 'promote_users', 'promote_user', ); /** A grant of any of these to a user who did not have it is refused while engaged. */ const PRIVILEGED_CAPS = array( 'manage_options', 'manage_network', 'promote_users', 'create_users', 'edit_users', 'delete_users', 'install_plugins', 'activate_plugins', 'edit_plugins', 'install_themes', 'edit_themes', 'switch_themes', 'unfiltered_html', 'unfiltered_upload', 'update_core', ); /** Admin screens refused, by their load- hook. */ const FROZEN_SCREENS = array( 'load-plugin-install.php' => 'plugin install screen', 'load-theme-install.php' => 'theme install screen', 'load-plugin-editor.php' => 'plugin file editor', 'load-theme-editor.php' => 'theme file editor', 'load-user-new.php' => 'add new user screen', 'load-update.php' => 'plugin or theme install, upload or update', ); /** Core admin-ajax actions refused. */ const FROZEN_AJAX = array( 'install-plugin', 'update-plugin', 'install-theme', 'update-theme', 'edit-theme-plugin-file', ); /** * Test-only clock. Null in production. * * @var int|null */ public static $now_override = null; /** * Test-only answer to "is this WP-CLI?". Null in production. * * @var bool|null */ public static $cli_override = null; /** * The state read once per request; false = not read yet. * * @var array|null|false */ private static $state_cache = false; /** * Refusals already logged in this request. * * @var array */ private static $logged = array(); /** Current time. */ private static function now(): int { return null !== self::$now_override ? self::$now_override : time(); } /** Forgets the per-request caches. Engage and release call it; so do the tests. */ public static function reset_cache(): void { self::$state_cache = false; self::$logged = array(); } /** * Logs a caught throwable and carries on: the fail-open half of every callback. * * @param \Throwable $e What was caught. * @param string $where Where. * @return void */ private static function swallow( \Throwable $e, string $where ): void { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, $where ); } } // ── State ────────────────────────────────────────────────────────── /** The settings, each value validated. */ public static function settings(): array { $s = get_option( self::OPTION_SETTINGS, array() ); $s = is_array( $s ) ? $s : array(); $out = self::DEFAULTS; if ( in_array( $s['trigger'] ?? '', array( self::TRIGGER_OFF, self::TRIGGER_REPORT, self::TRIGGER_ENFORCE ), true ) ) { $out['trigger'] = (string) $s['trigger']; } foreach ( array( 'link', 'signout', 'admin_only' ) as $k ) { if ( isset( $s[ $k ] ) ) { $out[ $k ] = '1' === (string) $s[ $k ] ? '1' : '0'; } } return $out; } /** The engaged state, or null while off. */ public static function state(): ?array { if ( false === self::$state_cache ) { $s = get_option( self::OPTION_STATE, null ); self::$state_cache = is_array( $s ) && ! empty( $s['engaged'] ) ? $s : null; } return self::$state_cache; } /** Whether lockdown is switched on (whether or not the rules are suspended). */ public static function is_engaged(): bool { return null !== self::state(); } /** Whether wp-config.php suspends every rule. */ public static function suspended(): bool { return defined( self::DISABLE_CONSTANT ) && (bool) constant( self::DISABLE_CONSTANT ); } /** Whether this is WP-CLI, which is never restricted. */ private static function is_cli(): bool { if ( null !== self::$cli_override ) { return self::$cli_override; } return defined( 'WP_CLI' ) && WP_CLI; } /** Whether this copy is a standby. */ private static function is_standby(): bool { return class_exists( 'CSDT_Site_Role' ) && CSDT_Site_Role::is_standby(); } /** * Whether the rules apply to this request. False on any error: fails open. * * @return bool */ public static function active(): bool { try { return ! self::suspended() && ! self::is_cli() && self::is_engaged(); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return false; } } /** The hash an address is kept under in the state. */ private static function ip_hash( string $ip ): string { return (string) wp_hash( 'csdt-lockdown|' . $ip ); } /** * Whether an address is never refused: the engaging address, an administrator login address * of the last 30 days, or any Auto-Block exemption (never-block list, live administrator * session, own server, trusted proxy, private range). * * @param string $ip Client address. * @return bool */ public static function ip_allowed( string $ip ): bool { if ( '' === $ip ) { return true; } $state = self::state(); if ( null !== $state && in_array( self::ip_hash( $ip ), (array) ( $state['allow'] ?? array() ), true ) ) { return true; } if ( class_exists( 'CSDT_Edge_Waf' ) && CSDT_Edge_Waf::is_admin_ip( $ip ) ) { return true; } return class_exists( 'CSDT_Auto_Block' ) && CSDT_Auto_Block::is_exempt( $ip ); } /** * Adds one event for the panel, newest first. * * @param string $type engaged | released | would_engage. * @param string $source Where it came from. * @param string $detail Detail. * @return void */ private static function add_event( string $type, string $source, string $detail ): void { $events = get_option( self::OPTION_EVENTS, array() ); $events = is_array( $events ) ? $events : array(); array_unshift( $events, array( 'at' => self::now(), 'type' => $type, 'source' => $source, 'detail' => substr( $detail, 0, 300 ), ) ); update_option( self::OPTION_EVENTS, array_slice( $events, 0, self::EVENTS_MAX ), false ); } /** The stored events, newest first. */ public static function events(): array { $e = get_option( self::OPTION_EVENTS, array() ); return is_array( $e ) ? $e : array(); } /** * One Activity Log row. * * @param string $event Event. * @param string $object Object. * @param string $detail Detail. * @return void */ private static function record( string $event, string $object, string $detail ): void { if ( class_exists( 'CSDT_Activity_Log' ) ) { CSDT_Activity_Log::record( $event, $object, $detail ); } } // ── Engage and release ───────────────────────────────────────────── /** * Switches lockdown on. The state is written last, so a failure part-way engages nothing. * * @param string $source manual | telegram | auto | cli. * @param int $user_id Engaging user, 0 for none. * @param string $ip Engaging address, never refused while engaged; '' for none. * @param string $reason Why. * @return array{ok:bool,msg:string,signed_out:int} */ public static function engage( string $source, int $user_id = 0, string $ip = '', string $reason = '' ): array { if ( self::is_engaged() ) { return array( 'ok' => false, 'msg' => __( 'Lockdown is already engaged.', 'cloudscale-cyber-devtools' ), 'signed_out' => 0 ); } $signed_out = 0; if ( '1' === self::settings()['signout'] ) { try { $signed_out = self::sign_out_admins( $user_id ); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); } } update_option( self::OPTION_STATE, array( 'engaged' => 1, 'at' => self::now(), 'source' => $source, 'by' => $user_id, 'reason' => substr( $reason, 0, 300 ), 'allow' => '' !== $ip ? array( self::ip_hash( $ip ) ) : array(), ), true ); self::reset_cache(); $who = $user_id > 0 && class_exists( 'CSDT_Activity_Log' ) ? CSDT_Activity_Log::login_for( $user_id ) : ''; $detail = sprintf( 'source %s%s%s; %d other administrator session(s) signed out', $source, '' !== $who ? ', by ' . $who : '', '' !== $reason ? ', ' . $reason : '', $signed_out ); self::add_event( 'engaged', $source, $detail ); self::record( 'lockdown_engaged', $source, $detail ); self::alert( sprintf( 'CSDT: 🔒 Emergency lockdown ENGAGED (%s)', self::site_name() ), "Emergency lockdown is on.\n\n" . $detail . "\n\nWhile it is on: no new users or role grants, no plugin or theme install, update, edit or activation, no theme switch, no application password or XML-RPC logins, and 3 failed logins refuse an address for 30 minutes. Two-factor and the hidden login page are unchanged.\n\nRelease it in wp-admin only (red banner, or Login Security tab, Emergency Lockdown): " . self::panel_url(), 'urgent' ); return array( 'ok' => true, 'msg' => __( 'Lockdown engaged.', 'cloudscale-cyber-devtools' ), 'signed_out' => $signed_out ); } /** * Switches lockdown off. The state goes first, so every rule lifts even if the rest fails. * * @param int $user_id Releasing user, 0 for WP-CLI. * @param string $via panel | banner | cli. * @return array{ok:bool,msg:string} */ public static function release( int $user_id, string $via ): array { if ( ! self::is_engaged() ) { return array( 'ok' => false, 'msg' => __( 'Lockdown is not engaged.', 'cloudscale-cyber-devtools' ) ); } $was = self::state(); delete_option( self::OPTION_STATE ); self::reset_cache(); try { $who = $user_id > 0 && class_exists( 'CSDT_Activity_Log' ) ? CSDT_Activity_Log::login_for( $user_id ) : 'WP-CLI'; $detail = sprintf( 'released from the %s by %s, %d minute(s) after it was engaged', $via, $who, (int) max( 0, ( self::now() - (int) ( $was['at'] ?? self::now() ) ) / MINUTE_IN_SECONDS ) ); self::add_event( 'released', $via, $detail ); self::record( 'lockdown_released', $via, $detail ); self::alert( sprintf( 'CSDT: Emergency lockdown released (%s)', self::site_name() ), "Emergency lockdown is off.\n\n" . $detail . "\n\nEvery rule it applied is lifted. Review the Activity Log for what it refused: " . self::panel_url(), 'high' ); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); } return array( 'ok' => true, 'msg' => __( 'Lockdown released. Every rule it applied is lifted.', 'cloudscale-cyber-devtools' ) ); } /** * Destroys every session of every administrator except the engaging user's current one. * * @param int $keep_user Engaging user, 0 for none (then every administrator is signed out). * @return int Users signed out. */ public static function sign_out_admins( int $keep_user ): int { if ( ! class_exists( 'WP_Session_Tokens' ) ) { return 0; } $ids = array_map( 'intval', (array) get_users( array( 'capability' => 'manage_options', 'fields' => 'ID' ) ) ); if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_super_admins' ) ) { foreach ( (array) get_super_admins() as $login ) { $u = get_user_by( 'login', (string) $login ); if ( $u ) { $ids[] = (int) $u->ID; } } } $n = 0; foreach ( array_unique( $ids ) as $id ) { $tokens = WP_Session_Tokens::get_instance( $id ); if ( $id === $keep_user && $keep_user > 0 ) { $current = function_exists( 'wp_get_session_token' ) ? (string) wp_get_session_token() : ''; if ( '' !== $current ) { $tokens->destroy_others( $current ); continue; } } $tokens->destroy_all(); ++$n; } return $n; } // ── Enforcement ──────────────────────────────────────────────────── /** Registers every hook. Each callback checks active() first and fails open. */ public static function register(): void { add_filter( 'pre_option_users_can_register', array( __CLASS__, 'filter_registration_read' ), 10, 1 ); add_filter( 'map_meta_cap', array( __CLASS__, 'filter_map_meta_cap' ), 99, 2 ); add_filter( 'update_user_metadata', array( __CLASS__, 'filter_user_meta' ), 1, 4 ); add_filter( 'add_user_metadata', array( __CLASS__, 'filter_user_meta' ), 1, 4 ); add_filter( 'pre_update_option_active_plugins', array( __CLASS__, 'filter_active_plugins' ), 1, 2 ); add_filter( 'pre_update_site_option_active_sitewide_plugins', array( __CLASS__, 'filter_sitewide_plugins' ), 1, 2 ); add_filter( 'pre_update_option_stylesheet', array( __CLASS__, 'filter_keep_option' ), 1, 3 ); add_filter( 'pre_update_option_template', array( __CLASS__, 'filter_keep_option' ), 1, 3 ); add_filter( 'pre_update_option_default_role', array( __CLASS__, 'filter_keep_option' ), 1, 3 ); add_filter( 'pre_update_option_users_can_register', array( __CLASS__, 'filter_registration_write' ), 1, 2 ); add_filter( 'pre_update_site_option_site_admins', array( __CLASS__, 'filter_site_admins' ), 1, 2 ); global $wpdb; if ( isset( $wpdb ) && is_object( $wpdb ) && method_exists( $wpdb, 'get_blog_prefix' ) ) { add_filter( 'pre_update_option_' . $wpdb->get_blog_prefix() . 'user_roles', array( __CLASS__, 'filter_keep_option' ), 1, 3 ); } add_filter( 'wp_is_application_passwords_available', array( __CLASS__, 'filter_off_while_engaged' ), 99, 1 ); add_filter( 'xmlrpc_enabled', array( __CLASS__, 'filter_off_while_engaged' ), 99, 1 ); add_filter( 'authenticate', array( __CLASS__, 'filter_authenticate' ), 2, 2 ); add_action( 'wp_login_failed', array( __CLASS__, 'on_login_failed' ), 10, 1 ); add_filter( 'rest_pre_dispatch', array( __CLASS__, 'filter_rest' ), 1, 3 ); foreach ( array_keys( self::FROZEN_SCREENS ) as $hook ) { add_action( $hook, array( __CLASS__, 'on_frozen_screen' ), 0, 0 ); } add_action( 'admin_init', array( __CLASS__, 'on_admin_ajax' ), 0, 0 ); add_action( 'admin_notices', array( __CLASS__, 'render_banner' ), 1, 0 ); add_action( 'network_admin_notices', array( __CLASS__, 'render_banner' ), 1, 0 ); add_action( 'admin_post_csdt_lockdown_release', array( __CLASS__, 'handle_banner_release' ), 10, 0 ); add_action( 'init', array( __CLASS__, 'handle_link_request' ), 2, 0 ); // Admin login addresses come from CSDT_Edge_Waf's hashed store (wp_login), so none here. CSDT_Ajax::register( 'wp_ajax_csdt_lockdown_status', array( __CLASS__, 'ajax_status' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_lockdown_save', array( __CLASS__, 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_lockdown_engage', array( __CLASS__, 'ajax_engage' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_lockdown_release', array( __CLASS__, 'ajax_release' ), array( 'refuse_test_session' => true ) ); if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( 'WP_CLI' ) ) { WP_CLI::add_command( 'csdt lockdown', array( __CLASS__, 'cli' ) ); } } /** * Logs one refusal to the Activity Log, at most once per (what, address) per LOG_WINDOW. * The throttle is a transient, the brute-force code's own pattern, so an attack cannot * flood the capped log and evict everything else. * * @param string $what What was refused. * @param string $detail Detail. * @return void */ public static function refuse( string $what, string $detail = '' ): void { try { $ip = class_exists( 'CSDT_DevTools' ) ? (string) CSDT_DevTools::get_client_ip() : ''; $key = 'csdt_lockdown_log_' . md5( $what . '|' . $ip ); if ( isset( self::$logged[ $key ] ) || false !== get_transient( $key ) ) { return; } self::$logged[ $key ] = true; set_transient( $key, 1, self::LOG_WINDOW ); self::record( 'lockdown_refused', $what, $detail ); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); } } /** * pre_option_users_can_register: registration reads as off while engaged. * * @param mixed $pre Short-circuit value. * @return mixed */ public static function filter_registration_read( $pre ) { return self::active() ? '0' : $pre; } /** * map_meta_cap: do_not_allow on every frozen capability while engaged. * * @param mixed $caps Primitive capabilities required. * @param mixed $cap Capability asked for. * @return mixed */ public static function filter_map_meta_cap( $caps, $cap = '' ) { try { if ( ! is_array( $caps ) || ! self::active() ) { return $caps; } if ( in_array( (string) $cap, self::FROZEN_CAPS, true ) || array_intersect( $caps, self::FROZEN_CAPS ) ) { $caps[] = 'do_not_allow'; } return $caps; } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return $caps; } } /** * Privileged capabilities a capabilities-meta value grants. * * @param mixed $value Roles and capabilities, role => true. * @return array */ public static function privileged_in( $value ): array { if ( is_string( $value ) && function_exists( 'maybe_unserialize' ) ) { $value = maybe_unserialize( $value ); } if ( ! is_array( $value ) ) { return array(); } $caps = array(); foreach ( $value as $name => $granted ) { if ( empty( $granted ) ) { continue; } $caps[ (string) $name ] = true; $role = function_exists( 'get_role' ) ? get_role( (string) $name ) : null; if ( is_object( $role ) && is_array( $role->capabilities ?? null ) ) { foreach ( $role->capabilities as $c => $on ) { if ( $on ) { $caps[ (string) $c ] = true; } } } } return array_values( array_intersect( self::PRIVILEGED_CAPS, array_keys( $caps ) ) ); } /** * update_user_metadata / add_user_metadata: a write to the capabilities meta that grants a * privileged capability the user did not hold is refused while engaged, and is a signal * while not. Demotions pass. * * @param mixed $check Short-circuit value (null = carry on). * @param mixed $object_id User id. * @param mixed $meta_key Meta key. * @param mixed $value New value. * @return mixed */ public static function filter_user_meta( $check, $object_id = 0, $meta_key = '', $value = null ) { try { global $wpdb; if ( null !== $check || ! isset( $wpdb ) || ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_blog_prefix' ) ) { return $check; } if ( (string) $meta_key !== $wpdb->get_blog_prefix() . 'capabilities' ) { return $check; } $uid = (int) $object_id; $new = self::privileged_in( $value ); $old = self::privileged_in( get_user_meta( $uid, (string) $meta_key, true ) ); $added = array_values( array_diff( $new, $old ) ); if ( array() === $added ) { return $check; } if ( self::active() ) { self::refuse( 'role_grant', sprintf( 'user #%d would have gained %s', $uid, implode( ', ', $added ) ) ); return false; } self::maybe_signal_admin( $uid, $added ); return $check; } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return $check; } } /** * pre_update_option_active_plugins: additions refused while engaged, removals kept. * * @param mixed $value New list. * @param mixed $old Old list. * @return mixed */ public static function filter_active_plugins( $value, $old = array() ) { try { if ( ! self::active() || ! is_array( $value ) ) { return $value; } $old = is_array( $old ) ? $old : array(); $added = array_values( array_diff( $value, $old ) ); if ( array() === $added ) { return $value; } self::refuse( 'plugin_activation', implode( ', ', array_map( 'strval', $added ) ) ); return array_values( array_intersect( $value, $old ) ); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return $value; } } /** * pre_update_site_option_active_sitewide_plugins: network activations refused while engaged. * * @param mixed $value New map, plugin => time. * @param mixed $old Old map. * @return mixed */ public static function filter_sitewide_plugins( $value, $old = array() ) { try { if ( ! self::active() || ! is_array( $value ) ) { return $value; } $old = is_array( $old ) ? $old : array(); $added = array_diff_key( $value, $old ); if ( array() === $added ) { return $value; } self::refuse( 'plugin_activation', implode( ', ', array_map( 'strval', array_keys( $added ) ) ) ); return array_intersect_key( $value, $old ); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return $value; } } /** * pre_update_option_{stylesheet,template,default_role,user_roles}: any change is * refused while engaged (the old value is kept, so update_option() writes nothing). * * @param mixed $value New value. * @param mixed $old Old value. * @param mixed $option Option name. * @return mixed */ public static function filter_keep_option( $value, $old = null, $option = '' ) { try { if ( ! self::active() || $value === $old ) { return $value; } $what = in_array( (string) $option, array( 'stylesheet', 'template' ), true ) ? 'theme_switch' : ( 'default_role' === $option ? 'default_role' : 'role_definitions' ); self::refuse( $what, (string) $option ); return $old; } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return $value; } } /** * pre_update_option_users_can_register: switching registration on is refused while engaged. * * @param mixed $value New value. * @param mixed $old Old value. * @return mixed */ public static function filter_registration_write( $value, $old = '0' ) { try { if ( ! self::active() || ! (int) $value || (int) $old ) { return $value; } self::refuse( 'registration', 'users_can_register' ); return $old; } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return $value; } } /** * pre_update_site_option_site_admins: a new super admin is refused while engaged. * * @param mixed $value New list of logins. * @param mixed $old Old list. * @return mixed */ public static function filter_site_admins( $value, $old = array() ) { try { if ( ! self::active() || ! is_array( $value ) ) { return $value; } $old = is_array( $old ) ? $old : array(); if ( array() === array_diff( $value, $old ) ) { return $value; } self::refuse( 'role_grant', 'super admin' ); return $old; } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return $value; } } /** * wp_is_application_passwords_available and xmlrpc_enabled: off while engaged. Both sign in * without the login form, the hidden login page or the second factor. * * @param mixed $on Current answer. * @return mixed */ public static function filter_off_while_engaged( $on ) { return self::active() ? false : $on; } /** The failed-login counter key for an address. */ private static function fail_key( string $ip ): string { return 'csdt_lockdown_fail_' . md5( $ip ); } /** * authenticate, priority 2 (before the password check): while engaged, an address that is * not allowed is refused after STRICT_FAILS failures, and always when the owner chose * administrator addresses only. Allowed addresses are never refused. * * @param mixed $user User, WP_Error or null so far. * @param mixed $username Username. * @return mixed */ public static function filter_authenticate( $user, $username = '' ) { try { if ( '' === (string) $username || ! self::active() ) { return $user; } $ip = (string) CSDT_DevTools::get_client_ip(); if ( self::ip_allowed( $ip ) ) { return $user; } if ( '1' === self::settings()['admin_only'] ) { self::refuse( 'login', 'not an administrator address' ); return new \WP_Error( 'csdt_lockdown_login', __( 'Logins are paused on this site. Try again later.', 'cloudscale-cyber-devtools' ) ); } if ( (int) get_transient( self::fail_key( $ip ) ) >= self::STRICT_FAILS ) { self::refuse( 'login_limit', sprintf( '%d failed logins inside %d minutes', self::STRICT_FAILS, (int) ( self::STRICT_WINDOW / MINUTE_IN_SECONDS ) ) ); return new \WP_Error( 'csdt_lockdown_login', __( 'Logins are paused on this site. Try again later.', 'cloudscale-cyber-devtools' ) ); } return $user; } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return $user; } } /** * wp_login_failed: counts failures from an address that is not allowed, while engaged. * * @param mixed $username Username, unused. * @return void */ public static function on_login_failed( $username = '' ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- wp_login_failed passes the username try { if ( ! self::active() ) { return; } $ip = (string) CSDT_DevTools::get_client_ip(); if ( self::ip_allowed( $ip ) ) { return; } $key = self::fail_key( $ip ); set_transient( $key, (int) get_transient( $key ) + 1, self::STRICT_WINDOW ); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); } } /** * rest_pre_dispatch: plugin and theme writes, user creation and role changes over REST are * refused while engaged (capabilities refuse them too; this logs and answers plainly). * * @param mixed $result Short-circuit value. * @param mixed $server REST server. * @param mixed $request Request. * @return mixed */ public static function filter_rest( $result, $server = null, $request = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- rest_pre_dispatch passes the server second try { if ( null !== $result || ! is_object( $request ) || ! method_exists( $request, 'get_route' ) || ! self::active() ) { return $result; } $method = strtoupper( (string) $request->get_method() ); if ( in_array( $method, array( 'GET', 'HEAD', 'OPTIONS' ), true ) ) { return $result; } $route = (string) $request->get_route(); $what = ''; if ( 1 === preg_match( '#^/wp/v2/(plugins|themes)(/|$)#', $route ) ) { $what = 'rest_plugins_themes'; } elseif ( 1 === preg_match( '#^/wp/v2/users/?$#', $route ) ) { $what = 'rest_user_create'; } elseif ( 1 === preg_match( '#^/wp/v2/users/#', $route ) && null !== $request->get_param( 'roles' ) ) { $what = 'rest_role_change'; } if ( '' === $what ) { return $result; } self::refuse( $what, $method . ' ' . $route ); return new \WP_Error( 'csdt_lockdown', __( 'This site is in emergency lockdown. An administrator can release it in wp-admin.', 'cloudscale-cyber-devtools' ), array( 'status' => 403 ) ); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return $result; } } /** load-: the install, update, editor and add-user screens refuse while engaged. */ public static function on_frozen_screen(): void { try { if ( ! self::active() ) { return; } $hook = function_exists( 'current_filter' ) ? (string) current_filter() : ''; self::refuse( 'screen', self::FROZEN_SCREENS[ $hook ] ?? $hook ); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return; } wp_die( esc_html__( 'Emergency lockdown is on: plugin and theme changes and new users are paused. Release it from the red banner.', 'cloudscale-cyber-devtools' ), esc_html__( 'Emergency lockdown', 'cloudscale-cyber-devtools' ), array( 'response' => 403, 'back_link' => true, ) ); } /** admin_init, priority 0: core's install, update and file-edit AJAX actions refuse while engaged. */ public static function on_admin_ajax(): void { try { if ( ! function_exists( 'wp_doing_ajax' ) || ! wp_doing_ajax() || ! self::active() ) { return; } $action = (string) CSDT_Request::routing_text( 'action', 'request' ); if ( ! in_array( $action, self::FROZEN_AJAX, true ) ) { return; } self::refuse( 'ajax', $action ); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return; } wp_send_json_error( array( 'errorMessage' => __( 'This site is in emergency lockdown. An administrator can release it in wp-admin.', 'cloudscale-cyber-devtools' ), ), 403 ); } // ── The engage link ──────────────────────────────────────────────── /** * The 32-byte link key, '' when there is none (or it cannot be read on this copy). * * @param bool $create Make one when there is none. * @return string */ private static function link_key( bool $create ): string { $hex = class_exists( 'CSDT_Secrets' ) ? CSDT_Secrets::get( self::OPTION_LINK_KEY ) : ''; if ( 1 === preg_match( '/^[a-f0-9]{64}$/', $hex ) ) { return (string) hex2bin( $hex ); } if ( ! $create || ! class_exists( 'CSDT_Secrets' ) ) { return ''; } $raw = random_bytes( 32 ); return CSDT_Secrets::set( self::OPTION_LINK_KEY, bin2hex( $raw ) ) ? $raw : ''; } /** * The signature over one link. * * @param string $id Link id. * @param int $exp Expiry. * @param string $key Key. * @return string */ private static function sign( string $id, int $exp, string $key ): string { return hash_hmac( 'sha256', 'engage|' . $id . '|' . $exp . '|' . rtrim( (string) home_url(), '/' ), $key ); } /** * Mints one engage link, or null (links switched off, a standby, no key). * * @return string|null */ public static function mint_link(): ?string { try { if ( '1' !== self::settings()['link'] || self::is_standby() ) { return null; } $key = self::link_key( true ); if ( '' === $key ) { return null; } $now = self::now(); $id = bin2hex( random_bytes( 16 ) ); $exp = $now + self::LINK_TTL; $links = get_option( self::OPTION_LINKS, array() ); $links = is_array( $links ) ? array_filter( $links, static fn( $e ) => (int) $e > $now ) : array(); $links[ hash( 'sha256', $id ) ] = $exp; arsort( $links ); update_option( self::OPTION_LINKS, array_slice( $links, 0, self::LINK_MAX, true ), false ); return rtrim( (string) home_url( '/' ), '/' ) . '/?' . self::QUERY_ARG . '=' . $id . '.' . $exp . '.' . self::sign( $id, $exp, $key ); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return null; } } /** * Checks one link token. Never writes. * * @param string $token id.exp.sig. * @return array{ok:bool,why:string,id:string} */ public static function verify_link( string $token ): array { $bad = array( 'ok' => false, 'why' => 'invalid', 'id' => '' ); if ( 1 !== preg_match( '/^([a-f0-9]{32})\.(\d{10})\.([a-f0-9]{64})$/', $token, $m ) ) { return $bad; } if ( self::is_standby() ) { return array( 'ok' => false, 'why' => 'standby', 'id' => '' ); } $exp = (int) $m[2]; $now = self::now(); if ( $exp > $now + self::LINK_TTL + MINUTE_IN_SECONDS ) { return $bad; } $key = self::link_key( false ); if ( '' === $key || ! hash_equals( self::sign( $m[1], $exp, $key ), $m[3] ) ) { return $bad; } if ( $exp <= $now ) { return array( 'ok' => false, 'why' => 'expired', 'id' => '' ); } $links = get_option( self::OPTION_LINKS, array() ); if ( ! is_array( $links ) || (int) ( $links[ hash( 'sha256', $m[1] ) ] ?? 0 ) !== $exp ) { return array( 'ok' => false, 'why' => 'used', 'id' => '' ); } return array( 'ok' => true, 'why' => '', 'id' => $m[1] ); } /** * What the link endpoint answers. GET (previews, scanners, prefetchers) only ever shows the * confirm page. Only a confirmed POST with a valid nonce consumes the link and engages. * * @param string $method Request method. * @param string $token The link token. * @param bool $confirm Whether the confirm field was posted. * @param bool $nonce_ok Whether its nonce verified. * @param string $ip The confirming address. * @return array{code:int,view:string} */ public static function link_response( string $method, string $token, bool $confirm, bool $nonce_ok, string $ip ): array { $v = self::verify_link( $token ); if ( ! $v['ok'] ) { return array( 'code' => 403, 'view' => 'expired' === $v['why'] || 'used' === $v['why'] ? $v['why'] : 'invalid' ); } if ( self::is_engaged() ) { return array( 'code' => 200, 'view' => 'already' ); } if ( 'POST' !== strtoupper( $method ) || ! $confirm ) { return array( 'code' => 200, 'view' => 'confirm' ); } if ( ! $nonce_ok ) { return array( 'code' => 403, 'view' => 'confirm' ); } // Consume before engaging: a second POST in flight finds the link used. $links = get_option( self::OPTION_LINKS, array() ); $links = is_array( $links ) ? $links : array(); unset( $links[ hash( 'sha256', $v['id'] ) ] ); update_option( self::OPTION_LINKS, $links, false ); $r = self::engage( 'telegram', 0, $ip, 'confirmed from an alert link' ); return array( 'code' => 200, 'view' => $r['ok'] ? 'engaged' : 'already' ); } /** init, priority 2: the engage link. Nothing is read unless the query argument is there. */ public static function handle_link_request(): void { $token = class_exists( 'CSDT_Request' ) ? CSDT_Request::routing_text( self::QUERY_ARG ) : null; if ( null === $token ) { return; } try { $method = CSDT_Request::server( 'REQUEST_METHOD', 16 ); $confirm = isset( $_POST['csdt_lockdown_confirm'] ) && '1' === sanitize_key( wp_unslash( $_POST['csdt_lockdown_confirm'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the nonce is verified on the next line, before anything acts on this $nonce_ok = false; if ( isset( $_POST['_csdt_lockdown_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_csdt_lockdown_nonce'] ) ), 'csdt_lockdown_link' ) ) { $nonce_ok = true; } $r = self::link_response( $method, (string) $token, $confirm, $nonce_ok, (string) CSDT_DevTools::get_client_ip() ); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); $r = array( 'code' => 403, 'view' => 'invalid' ); } status_header( $r['code'] ); nocache_headers(); header( 'Cache-Control: no-store, private, max-age=0' ); header( 'X-Robots-Tag: noindex, nofollow' ); header( 'Referrer-Policy: no-referrer' ); header( 'Content-Type: text/html; charset=utf-8' ); self::render_link_page( $r['view'], (string) $token ); exit; } /** * The link pages, escaped. * * @param string $view confirm | engaged | already | expired | used | invalid. * @param string $token The token, posted back on confirm. * @return void */ public static function render_link_page( string $view, string $token ): void { $copy = array( 'confirm' => array( __( 'Engage emergency lockdown?', 'cloudscale-cyber-devtools' ), __( 'This freezes new users and role grants, plugin and theme changes and file editing, and signs out every administrator. Two-factor and the hidden login page stay on. Only an administrator in wp-admin can release it.', 'cloudscale-cyber-devtools' ) ), 'engaged' => array( __( 'Lockdown engaged', 'cloudscale-cyber-devtools' ), __( 'The site is locked down. Log in to wp-admin to review the Activity Log and release it when the site is clean.', 'cloudscale-cyber-devtools' ) ), 'already' => array( __( 'Lockdown is already on', 'cloudscale-cyber-devtools' ), __( 'Nothing was changed. Release it in wp-admin when the site is clean.', 'cloudscale-cyber-devtools' ) ), 'expired' => array( __( 'This link has expired', 'cloudscale-cyber-devtools' ), __( 'Engage links last 30 minutes. Engage lockdown from wp-admin instead.', 'cloudscale-cyber-devtools' ) ), 'used' => array( __( 'This link was already used', 'cloudscale-cyber-devtools' ), __( 'Each engage link works once. Engage lockdown from wp-admin instead.', 'cloudscale-cyber-devtools' ) ), 'invalid' => array( __( 'This link is not valid', 'cloudscale-cyber-devtools' ), __( 'Nothing was changed.', 'cloudscale-cyber-devtools' ) ), ); $c = $copy[ $view ] ?? $copy['invalid']; if ( function_exists( 'wp_register_style' ) && class_exists( 'CSDT_DevTools' ) && defined( 'CSDT_PLUGIN_FILE' ) ) { wp_register_style( 'csdt-403', plugins_url( 'assets/cs-403.css', CSDT_PLUGIN_FILE ), array(), CSDT_DevTools::VERSION ); } ?> <?php echo esc_html( $c[0] ); ?> 0 && $actor !== $uid && user_can( $actor, 'manage_options' ) ) { return; } $by = $actor > 0 ? ( $actor === $uid ? 'the same user (self-promotion)' : 'user #' . $actor . ', not an administrator' ) : 'nobody signed in'; self::signal( 'admin_granted', sprintf( 'user #%d gained administrator rights, by %s', $uid, $by ) ); } /** * Called by CSDT_Persistence_Watch with each diff it reports. A new or changed must-use * plugin (other than the heartbeat's own loader) is a signal. * * @param array $d {added, changed, removed} labels. * @return void */ public static function on_persistence_changed( array $d ): void { try { $hits = array(); foreach ( array_merge( (array) ( $d['added'] ?? array() ), (array) ( $d['changed'] ?? array() ) ) as $label ) { $label = (string) $label; if ( 0 === strpos( $label, 'mu-plugins/' ) && self::OWN_MU_LOADER !== $label ) { $hits[] = $label; } } if ( array() !== $hits ) { self::signal( 'mu_plugin', 'must-use plugin added or changed: ' . implode( ', ', $hits ) ); } } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); } } /** * A high-confidence breach signal. Report (default): record and alert "would have locked * down", once per signal per hour. Enforce: engage. Off, a standby, or already engaged: * nothing. * * @param string $kind Signal name. * @param string $detail What was seen. * @return string none | reported | engaged. */ public static function signal( string $kind, string $detail ): string { try { if ( self::is_engaged() || self::is_standby() ) { return 'none'; } $trigger = self::settings()['trigger']; if ( self::TRIGGER_OFF === $trigger ) { return 'none'; } if ( self::TRIGGER_ENFORCE === $trigger ) { self::engage( 'auto', 0, '', $kind . ': ' . $detail ); return 'engaged'; } if ( class_exists( 'CSDT_Alert_Window' ) && ! CSDT_Alert_Window::claim( 'lockdown_signal_' . md5( $kind . '|' . $detail ), self::SIGNAL_WINDOW, self::now() ) ) { return 'none'; } self::add_event( 'would_engage', $kind, $detail ); self::record( 'lockdown_would_engage', $kind, $detail ); $link = self::mint_link(); self::alert( sprintf( 'CSDT: 🚨 Lockdown WOULD have engaged (%s)', self::site_name() ), "A high-confidence breach signal fired:\n\n" . $detail . "\n\nThe automatic trigger is in report-only mode, so nothing was locked. " . ( null !== $link ? "To lock the site down now, open this link and confirm (single use, expires in 30 minutes):\n" . $link . "\n\n" : '' ) . 'Release is in wp-admin only. Login Security tab, Emergency Lockdown: ' . self::panel_url(), 'urgent' ); return 'reported'; } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return 'none'; } } // ── Alerts ───────────────────────────────────────────────────────── /** The site name for alert subjects. */ private static function site_name(): string { $name = function_exists( 'get_bloginfo' ) ? (string) get_bloginfo( 'name' ) : ''; $name = '' !== $name ? $name : (string) home_url(); return function_exists( 'wp_specialchars_decode' ) ? wp_specialchars_decode( $name, ENT_QUOTES ) : $name; } /** The panel. */ public static function panel_url(): string { return admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=login#cs-panel-lockdown' ); } /** * One SECURITY alert through the threat-alert path (email when on, Telegram when set up). * * @param string $subject Subject. * @param string $body Body. * @param string $priority urgent | high. * @return void */ private static function alert( string $subject, string $body, string $priority ): void { try { if ( is_callable( array( 'CSDT_Threat_Monitor', 'send_threat_alert' ) ) ) { CSDT_Threat_Monitor::send_threat_alert( $subject, $body, $priority, 'lock,rotating_light', self::panel_url(), CSDT_Alert_Events::SECURITY ); } elseif ( class_exists( 'CSDT_Alert_Events' ) ) { CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, $subject . "\n\n" . $body, 'Emergency Lockdown', 'urgent' === $priority ? 'critical' : 'warning' ); } } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); } } // ── Admin: banner, AJAX, WP-CLI ──────────────────────────────────── /** admin_notices: the red banner, with Release, for administrators while engaged. */ public static function render_banner(): void { try { $state = self::state(); if ( null === $state || ! current_user_can( 'manage_options' ) ) { return; } /* translators: 1: when, 2: source */ $since = sprintf( __( 'Since %1$s (%2$s). New users, role grants, plugin and theme changes and file editing are paused.', 'cloudscale-cyber-devtools' ), self::when( (int) ( $state['at'] ?? 0 ) ), (string) ( $state['source'] ?? '' ) ); } catch ( \Throwable $e ) { self::swallow( $e, __METHOD__ ); return; } ?>
🔒
false, 'msg' => 'Unauthorized' ); } if ( ! current_user_can( 'manage_options' ) ) { return array( 'ok' => false, 'msg' => 'Unauthorized' ); } if ( is_callable( array( 'CSDT_DevTools', 'viewer_is_test_session' ) ) && CSDT_DevTools::viewer_is_test_session() ) { return array( 'ok' => false, 'msg' => CSDT_Ajax::TEST_SESSION_MESSAGE ); } return self::release( (int) get_current_user_id(), 'banner' ); } /** admin_post_csdt_lockdown_release. */ public static function handle_banner_release(): void { $r = self::banner_release(); if ( ! $r['ok'] && in_array( $r['msg'], array( 'Unauthorized', CSDT_Ajax::TEST_SESSION_MESSAGE ), true ) ) { wp_die( esc_html( $r['msg'] ), esc_html__( 'Emergency lockdown', 'cloudscale-cyber-devtools' ), array( 'response' => 403 ) ); } wp_safe_redirect( self::panel_url() ); exit; } /** Status: read only, so a test session may ask. */ public static function ajax_status(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } wp_send_json_success( array( 'ok' => true, 'engaged' => self::is_engaged(), 'html' => self::status_html() ) ); } /** Saves the settings. */ public static function ajax_save(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $trigger = sanitize_key( wp_unslash( $_POST['trigger'] ?? '' ) ); $link = sanitize_key( wp_unslash( $_POST['link'] ?? '' ) ); $signout = sanitize_key( wp_unslash( $_POST['signout'] ?? '' ) ); $admin = sanitize_key( wp_unslash( $_POST['admin_only'] ?? '' ) ); $cur = self::settings(); $new = array( 'trigger' => in_array( $trigger, array( self::TRIGGER_OFF, self::TRIGGER_REPORT, self::TRIGGER_ENFORCE ), true ) ? $trigger : $cur['trigger'], 'link' => '1' === $link ? '1' : '0', 'signout' => '1' === $signout ? '1' : '0', 'admin_only' => '1' === $admin ? '1' : '0', ); update_option( self::OPTION_SETTINGS, $new, false ); $msg = self::TRIGGER_ENFORCE === $new['trigger'] ? __( 'Saved. The automatic trigger now ENGAGES lockdown on a high-confidence signal.', 'cloudscale-cyber-devtools' ) : __( 'Saved.', 'cloudscale-cyber-devtools' ); wp_send_json_success( array( 'ok' => true, 'msg' => $msg, 'html' => self::status_html() ) ); } /** Engage from the panel. The engaging admin keeps this session and this address. */ public static function ajax_engage(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $r = self::engage( 'manual', (int) get_current_user_id(), (string) CSDT_DevTools::get_client_ip(), 'engaged from the panel' ); /* translators: %d: number of administrators signed out */ $msg = $r['ok'] ? sprintf( __( 'Lockdown engaged. %d other administrator(s) signed out; you stay signed in.', 'cloudscale-cyber-devtools' ), $r['signed_out'] ) : $r['msg']; wp_send_json_success( array( 'ok' => $r['ok'], 'msg' => $msg, 'engaged' => self::is_engaged(), 'html' => self::status_html() ) ); } /** Release from the panel. */ public static function ajax_release(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $r = self::release( (int) get_current_user_id(), 'panel' ); wp_send_json_success( array( 'ok' => $r['ok'], 'msg' => $r['msg'], 'engaged' => self::is_engaged(), 'html' => self::status_html() ) ); } /** * Wp csdt lockdown status|engage|release. The escape hatch that needs no login. * * @param array $args Positional arguments. * @return void */ public static function cli( $args ): void { $verb = (string) ( $args[0] ?? 'status' ); if ( 'release' === $verb ) { $r = self::release( 0, 'cli' ); } elseif ( 'engage' === $verb ) { $r = self::engage( 'cli', 0, '', 'engaged from WP-CLI' ); } else { $s = self::state(); $r = array( 'ok' => true, 'msg' => null === $s ? 'Lockdown is off.' : 'Lockdown is ENGAGED since ' . self::when( (int) $s['at'] ) . ' (' . (string) $s['source'] . ').' ); } if ( $r['ok'] ) { WP_CLI::success( $r['msg'] ); return; } WP_CLI::warning( $r['msg'] ); } // ── Panel ────────────────────────────────────────────────────────── /** When, in words. */ private static function when( int $ts ): string { if ( $ts <= 0 ) { return __( 'never', 'cloudscale-cyber-devtools' ); } return function_exists( 'wp_date' ) ? (string) wp_date( 'Y-m-d H:i', $ts ) : gmdate( 'Y-m-d H:i', $ts ) . ' UTC'; } /** Trigger labels, in the order offered. */ public static function trigger_labels(): array { return array( self::TRIGGER_OFF => __( 'Off (no automatic signal)', 'cloudscale-cyber-devtools' ), self::TRIGGER_REPORT => __( 'Report only: alert "would have locked down" (default)', 'cloudscale-cyber-devtools' ), self::TRIGGER_ENFORCE => __( 'Enforce: engage lockdown automatically', 'cloudscale-cyber-devtools' ), ); } /** * The status block, escaped, from stored data only. * * @return string */ public static function status_html(): string { $state = self::state(); $set = self::settings(); if ( null !== $state && self::suspended() ) { $pill = array( '#fef3c7', '#92400e', __( 'Engaged, suspended by CSDT_LOCKDOWN_DISABLE', 'cloudscale-cyber-devtools' ) ); } elseif ( null !== $state ) { $pill = array( '#b91c1c', '#ffffff', __( 'ENGAGED', 'cloudscale-cyber-devtools' ) ); } elseif ( self::is_standby() ) { $pill = array( '#e0e7ff', '#3730a3', __( 'Off (standby copy: never engages by itself)', 'cloudscale-cyber-devtools' ) ); } else { $pill = array( '#f1f5f9', '#334155', __( 'Off', 'cloudscale-cyber-devtools' ) ); } $rows = array( array( __( 'State', 'cloudscale-cyber-devtools' ), null === $state ? __( 'not engaged', 'cloudscale-cyber-devtools' ) : sprintf( 'engaged %s, source %s', self::when( (int) $state['at'] ), (string) $state['source'] ) ), array( __( 'Automatic trigger', 'cloudscale-cyber-devtools' ), self::trigger_labels()[ $set['trigger'] ] ), array( __( 'Engage link in alerts', 'cloudscale-cyber-devtools' ), '1' === $set['link'] ? __( 'on (single use, 30 minutes, confirm page)', 'cloudscale-cyber-devtools' ) : __( 'off', 'cloudscale-cyber-devtools' ) ), array( __( 'On engage', 'cloudscale-cyber-devtools' ), '1' === $set['signout'] ? __( 'sign out every other administrator', 'cloudscale-cyber-devtools' ) : __( 'leave other sessions signed in', 'cloudscale-cyber-devtools' ) ), array( __( 'Logins while engaged', 'cloudscale-cyber-devtools' ), '1' === $set['admin_only'] ? __( 'administrator addresses only', 'cloudscale-cyber-devtools' ) : __( 'anyone, 3 failures refuse an address for 30 minutes', 'cloudscale-cyber-devtools' ) ), ); $h = '' . esc_html( $pill[2] ) . ''; $h .= ''; foreach ( $rows as $r ) { $h .= ''; } $h .= '
' . esc_html( $r[0] ) . '' . esc_html( $r[1] ) . '
'; $events = array_slice( self::events(), 0, 10 ); $h .= '

' . esc_html__( 'Recent events', 'cloudscale-cyber-devtools' ) . '

'; if ( array() === $events ) { return $h . '

' . esc_html__( 'None yet.', 'cloudscale-cyber-devtools' ) . '

'; } $labels = array( 'engaged' => __( 'Engaged', 'cloudscale-cyber-devtools' ), 'released' => __( 'Released', 'cloudscale-cyber-devtools' ), 'would_engage' => __( 'Would have engaged', 'cloudscale-cyber-devtools' ), ); $h .= ''; foreach ( $events as $e ) { $h .= ''; } return $h . '
' . esc_html( self::when( (int) ( $e['at'] ?? 0 ) ) ) . '' . esc_html( (string) ( $labels[ $e['type'] ?? '' ] ?? ( $e['type'] ?? '' ) ) ) . '' . esc_html( (string) ( $e['detail'] ?? '' ) ) . '
'; } /** The panel, in its own EMERGENCY group on the Login Security tab. */ public static function render_panel(): void { $set = self::settings(); $engaged = self::is_engaged(); ?>
🔒 'When to use it', 'rec' => 'Info', 'html' => '

When you think someone has broken in: an administrator you did not create, a plugin you did not install, a file that changed by itself. Lockdown stops the changes that make a break-in permanent while you investigate, and the site stays online for visitors.

', ), array( 'name' => 'What it does', 'rec' => 'Info', 'html' => '

While engaged: registration is frozen; nobody can be given administrator or other high rights (even by code that writes the role directly); plugins and themes cannot be installed, uploaded, updated, edited, activated or switched; the file editors are closed; application passwords and XML-RPC logins are off; 3 failed logins refuse an address for 30 minutes; every other administrator is signed out on engage. Every refusal is written to the Activity Log. Demoting or deleting a user, deactivating or deleting a plugin still work, because those are how you clean up. Background updates from WordPress.org still run.

', ), array( 'name' => 'You are never locked out', 'rec' => 'Info', 'html' => '

Administrator rights to settings are never removed, so the red banner and this panel can always release it. You stay signed in when you engage it, and your address, every address an administrator logged in from in the last 30 days, and the Auto-Block never-block list are never refused. Two-factor and the hidden login page are not touched. From the server, wp csdt lockdown release releases it, and defining CSDT_LOCKDOWN_DISABLE as true in wp-config.php suspends every rule.

', ), array( 'name' => 'Automatic trigger', 'rec' => 'Info', 'html' => '

Two high-confidence signals: administrator rights granted outside the admin screens (by nobody signed in, by a lower role, or a user promoting themselves), and a new or changed must-use plugin. Report only (default) records it and alerts that lockdown would have engaged. Enforce engages it. A standby copy never engages by itself.

', ), array( 'name' => 'Engage link in alerts', 'rec' => 'Info', 'html' => '

A report-only alert carries a link that engages lockdown from your phone. It is signed, works once, expires after 30 minutes, and opens a page with a confirm button: Telegram link previews and mail scanners only open pages, so they cannot engage it. Release is never possible from the link or Telegram, only here or from the banner.

', ), array( 'name' => 'Admin addresses only', 'rec' => 'Info', 'html' => '

Optional and off by default: while engaged, only the addresses above may log in at all. Leave it off if you travel or your address changes often, or keep the server command above to hand.

', ), ) ); ?>