'anyone may register', 'default_role' => 'default role for new users', 'admin_email' => 'site admin email', 'siteurl' => 'site URL', 'home' => 'home URL', // Rewritten by activate_plugin(), which also fires activated_plugin, and by // anything that skips the API: WP-CLI, a rogue plugin, a SQL client. The // hook covers the first; watching the option covers the rest. 'active_plugins' => 'active plugin list', // Login security. 'csdt_devtools_2fa_method' => 'two-factor method', 'csdt_devtools_2fa_force_admins' => 'force 2FA for admins', 'csdt_devtools_login_hide_enabled' => 'hidden login page', 'csdt_devtools_login_slug' => 'hidden login slug', // Blocking and the firewall. 'csdt_autoblock_enabled' => 'auto-blocking', 'csdt_autoblock_threshold' => 'auto-block threshold', 'csdt_autoblock_allowlist' => 'never-block list', 'csdt_autoblock_alert_bulk' => 'alert bundling', 'csdt_ip_blocklist' => 'IP blocklist', 'csdt_firewall_mode' => 'firewall mode', 'csdt_firewall_disabled' => 'disabled firewall rules', 'csdt_geoblock_enabled' => 'country rules', 'csdt_geoblock_countries' => 'country list', // Alerting. 'csdt_alerts_muted' => 'alert muting', ); /** * Events that page the owner, not only sit in the log. * * The log recorded every one of these and alerted on none, so a plugin * activated at 3am, a theme switched, the file editor used, or the admin * email changed was a row nobody would read until the next visit to this * panel. Each is routed through the same threat-alert path as a modified * core file, once per (event, object) per hour. Automation writes are * already dropped before they reach record(). */ const ALERT_EVENTS = array( 'plugin_activated', 'theme_switched', 'file_edited', 'user_deleted', 'role_changed', 'plugin_deleted', 'theme_deleted', ); /** * Watched options whose change pages the owner. The plugin's own csdt_* * kill switches alert through CSDT_Login::on_option_updated() already; these * are WordPress's own, where a change is either a migration or a takeover. */ const ALERT_OPTIONS = array( 'users_can_register', 'default_role', 'admin_email', 'siteurl', 'home', 'active_plugins', ); /** Whether activity events page the owner. Default on. */ const OPTION_ALERTS = 'csdt_activity_alerts_enabled'; /** When each (event, object) last alerted, for the hourly throttle. An option, never a transient. */ const OPTION_ALERT_THROTTLE = 'csdt_activity_alert_sent'; /** * The current run of failed writes: count, first, last, error. Cleared by the next write * that succeeds, shown on the panel while it stands. * * @since 1.10.924 */ const OPTION_WRITE_FAILURE = 'csdt_activity_write_failure'; /** Whether this request has already logged a failed write. One line per request, not per event. */ private static $failure_logged = false; /** Events considered worth a red mark on the panel. */ const HIGH_SEVERITY = array( 'user_deleted', 'role_changed', 'plugin_deleted', 'theme_deleted', 'file_edited', 'option_changed', 'user_created', 'persistence_changed', ); /** Registers every hook. */ public static function init(): void { // Registered unconditionally and gated at write time, NOT gated here. Deciding at // registration means a setting change mid-request installs nothing, which is exactly // how the 2FA write-veto came to be absent on the one request that mattered. add_action( 'wp_login', array( __CLASS__, 'on_login' ), 10, 2 ); add_action( 'wp_login_failed', array( __CLASS__, 'on_login_failed' ), 10, 1 ); add_action( 'user_register', array( __CLASS__, 'on_user_created' ), 10, 1 ); add_action( 'delete_user', array( __CLASS__, 'on_user_deleted' ), 10, 1 ); add_action( 'set_user_role', array( __CLASS__, 'on_role_changed' ), 10, 3 ); add_action( 'after_password_reset', array( __CLASS__, 'on_password_reset' ), 10, 1 ); add_action( 'activated_plugin', array( __CLASS__, 'on_plugin_activated' ), 10, 1 ); add_action( 'deactivated_plugin', array( __CLASS__, 'on_plugin_deactivated' ), 10, 1 ); add_action( 'deleted_plugin', array( __CLASS__, 'on_plugin_deleted' ), 10, 2 ); add_action( 'switch_theme', array( __CLASS__, 'on_theme_switched' ), 10, 1 ); add_action( 'deleted_theme', array( __CLASS__, 'on_theme_deleted' ), 10, 2 ); add_action( 'upgrader_process_complete', array( __CLASS__, 'on_upgrade' ), 10, 2 ); // The file editors. Priority 1 so the attempt is recorded BEFORE core writes the // file, if the write then fails or the request dies, the attempt is still on record, // which is the direction an audit trail should fail in. add_action( 'wp_ajax_edit-theme-plugin-file', array( __CLASS__, 'on_file_edit' ), 1 ); // Security options. updated_option only fires when the value actually changed, so // this does not record a save that changed nothing. add_action( 'updated_option', array( __CLASS__, 'on_option_changed' ), 10, 3 ); add_action( 'added_option', array( __CLASS__, 'on_option_added' ), 10, 2 ); } /** Whether recording is switched on. */ public static function is_enabled(): bool { return '0' !== (string) get_option( self::OPTION_ENABLED, '1' ); } /** Retention in seconds, clamped to something sane. */ public static function retention(): int { $days = (int) get_option( self::OPTION_RETENTION_DAYS, self::DEFAULT_RETENTION_DAYS ); return max( 1, min( 365, $days ) ) * DAY_IN_SECONDS; } /** * Writes one entry. * * Never throws and never blocks the act being recorded. An audit trail that can break a * login is worse than no audit trail, so every failure here is silent to the caller, but * it is silent by DESIGN rather than by accident, and the panel reports whether the table * is available so "no entries" and "cannot write" are distinguishable. * * @since 1.10.497 * @param string $event Short machine event name. * @param string $object What it happened to. * @param string $detail Extra context. * @param int|null $user_id Actor; defaults to the current user. * @param int|null $now Unix time, for tests. * @return bool Whether a row was written. */ public static function record( string $event, string $object = '', string $detail = '', ?int $user_id = null, ?int $now = null ): bool { try { if ( '' === $event || ! self::is_enabled() ) { return false; } if ( ! class_exists( 'CSDT_Storage' ) || ! CSDT_Storage::is_available() ) { self::note_write_failure( $event, 'the activity table is not installed' ); return false; } if ( null === $user_id ) { $user_id = function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0; } $login = self::login_for( (int) $user_id ); $ip = class_exists( 'CSDT_DevTools' ) ? (string) CSDT_DevTools::get_client_ip() : ''; global $wpdb; $table = CSDT_Storage::activity_table(); $rows = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- audit write, must not be cached $wpdb->prepare( 'INSERT INTO %i (ts, user_id, user_login, ip, event, object, detail, severity) VALUES (%d, %d, %s, %s, %s, %s, %s, %s)', $table, null === $now ? time() : $now, (int) $user_id, substr( $login, 0, 60 ), substr( $ip, 0, 45 ), substr( $event, 0, 40 ), substr( self::clean( $object ), 0, 191 ), substr( self::clean( $detail ), 0, 255 ), in_array( $event, self::HIGH_SEVERITY, true ) ? 'high' : 'info' ) ); if ( false === $rows ) { self::note_write_failure( $event, '' !== (string) $wpdb->last_error ? (string) $wpdb->last_error : 'the database refused the insert' ); return false; } if ( false !== get_option( self::OPTION_WRITE_FAILURE, false ) ) { delete_option( self::OPTION_WRITE_FAILURE ); } self::maybe_alert( $event, $object, $detail, $login, $ip ); return true; } catch ( \Throwable $e ) { // Deliberately not re-raised and deliberately not alerted: an exception here // would otherwise be able to break a login, and alerting on it could recurse // through the alert path. Noted instead, which neither throws nor alerts. self::note_write_failure( $event, get_class( $e ) . ': ' . $e->getMessage() ); return false; } } /** * Remembers that an entry was lost, where the panel and the PHP log will show it. * * Until 1.10.924 a failed write returned false to callers that ignore the return value, so * an audit trail with a hole in it looked exactly like a quiet week. This makes the hole * visible without the things record() must never do: it does not throw, and it does not * alert, because an alert path that records activity could recurse back here. * * The option is written at most once a minute. When the database is what is failing, one * more write per lost event adds load to it and fails the same way; the count is therefore * a lower bound, and the panel says so. * * @since 1.10.924 * @param string $event Event that was not recorded. * @param string $why Why, for the panel and the log. * @return void */ private static function note_write_failure( string $event, string $why ): void { try { $now = time(); $prev = get_option( self::OPTION_WRITE_FAILURE, array() ); $prev = is_array( $prev ) ? $prev : array(); if ( ! $prev || $now - (int) ( $prev['last'] ?? 0 ) >= MINUTE_IN_SECONDS ) { update_option( self::OPTION_WRITE_FAILURE, array( 'count' => (int) ( $prev['count'] ?? 0 ) + 1, 'first' => (int) ( $prev['first'] ?? $now ), 'last' => $now, 'event' => substr( $event, 0, 40 ), 'error' => substr( $why, 0, 300 ), ) ); } if ( ! self::$failure_logged ) { self::$failure_logged = true; $line = 'CSDT: activity log entry lost (' . $event . '): ' . $why; if ( is_callable( array( 'CSDT_DevTools', 'log' ) ) ) { CSDT_DevTools::log( $line ); } else { error_log( $line ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- the plugin class is not loaded, so PHP's log is the only sink } } } catch ( \Throwable $e ) { unset( $e ); // Nothing left to tell: the options table is failing too. } } /** * Forgets the per-request "already logged" flag. For the tests, which run many requests in one process. * * @since 1.10.924 * @return void */ public static function reset_request_state(): void { self::$failure_logged = false; } /** * Page the owner for an event that warrants it, once per (event, object) per hour. * * Separate from the insert and wrapped in its own catch: a failure to notify * must never cost the log row, and a failure to log must never cost the alert * either, which is why record() calls this after the row is written rather than * the alert path calling record(). * * @param string $event Event name. * @param string $object Object. * @param string $detail Detail. * @param string $login Actor login, '' when nobody was signed in. * @param string $ip Actor address. * @return bool Whether an alert was sent. */ public static function maybe_alert( string $event, string $object, string $detail, string $login, string $ip ): bool { try { $is_option = 'option_changed' === $event && in_array( $object, self::ALERT_OPTIONS, true ); if ( ! $is_option && ! in_array( $event, self::ALERT_EVENTS, true ) ) { return false; } if ( '1' !== (string) get_option( self::OPTION_ALERTS, '1' ) ) { return false; } if ( ! is_callable( array( 'CSDT_Threat_Monitor', 'send_threat_alert' ) ) ) { return false; } // One alert per (event, object) per hour: a plugin toggled on and off a few // times while somebody works is one fact, not five pages. // // Held in an option, not a transient. Transients live in the object cache here, // and every deploy flushes it, so a deploy that tripped this alert also reset the // throttle that was meant to stop it repeating: one deploy-and-rollback cycle on // QA sent a dozen copies. See CSDT_Telegram::OPTION_RATE for the same fix. $key = md5( $event . '|' . $object ); $now = time(); $sent = function_exists( 'get_option' ) ? get_option( self::OPTION_ALERT_THROTTLE, array() ) : array(); $sent = is_array( $sent ) ? $sent : array(); $last = (int) ( $sent[ $key ] ?? 0 ); if ( $last > 0 && $last <= $now && ( $now - $last ) < HOUR_IN_SECONDS ) { return false; } $sent[ $key ] = $now; $sent = array_filter( $sent, static function ( $t ) use ( $now ) { return ( $now - (int) $t ) < DAY_IN_SECONDS; } ); if ( function_exists( 'update_option' ) ) { update_option( self::OPTION_ALERT_THROTTLE, $sent, false ); } $labels = self::event_labels(); $what = (string) ( $labels[ $event ] ?? $event ); $site = function_exists( 'get_bloginfo' ) ? wp_specialchars_decode( (string) ( get_bloginfo( 'name' ) ?: home_url() ), ENT_QUOTES ) : ''; $url = function_exists( 'admin_url' ) ? admin_url( 'admin.php?page=cloudscale-cyber-devtools&tab=security' ) : ''; $who = '' !== $login ? $login : 'nobody signed in (WP-CLI, cron, or a direct write)'; $urgent = in_array( $event, array( 'role_changed', 'user_deleted', 'file_edited' ), true ) || ( $is_option && in_array( $object, array( 'siteurl', 'home', 'admin_email', 'users_can_register', 'default_role' ), true ) ); CSDT_Threat_Monitor::send_threat_alert( sprintf( 'CSDT: %s: %s (%s)', $what, $object, $site ), sprintf( "%s\n\nWhat: %s\nDetail: %s\nBy: %s\nIP: %s\n\nIf this was not you or your host, treat the session that did it as compromised.\n\nActivity log: %s", $what, $object, '' !== $detail ? $detail : '(none)', $who, self::describe_origin_ip( $ip ), $url ), $urgent ? 'urgent' : 'high', 'warning,memo', $url, CSDT_Alert_Events::SECURITY ); return true; } catch ( \Throwable $e ) { return false; } } /** * The "IP" line of an alert, which says what kind of request made the change. * * An empty address printed "unknown", which reads as a lookup that failed or an * attacker who hid. For a command-line or cron write there is simply no client: say so. * * @param string $ip Client address as recorded, '' when there was none. * @return string */ public static function describe_origin_ip( string $ip ): string { if ( '' !== $ip ) { return $ip; } if ( 'cli' === PHP_SAPI ) { return 'none, not a web request (WP-CLI or a script on the server)'; } if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) { return 'none, not a web request (WP-Cron)'; } return 'not available for this request'; } /** * Strips control characters from a value before storing it. * * Object names come from plugin headers, theme names and option values, none of which is * guaranteed to be clean, and this text is later read in a browser and possibly a * terminal. * * @since 1.10.497 * @param string $value Raw value. * @return string */ public static function clean( string $value ): string { return (string) preg_replace( '/[\x00-\x1F\x7F]/', ' ', $value ); } /** * The login name for a user id, resolved at write time. * * Resolved now rather than at read time because the point of storing it is that the user * may not exist later. * * @since 1.10.497 * @param int $user_id User id. * @return string */ public static function login_for( int $user_id ): string { if ( $user_id < 1 || ! function_exists( 'get_userdata' ) ) { return ''; } $user = get_userdata( $user_id ); return is_object( $user ) && isset( $user->user_login ) ? (string) $user->user_login : ''; } // ── Handlers ───────────────────────────────────────────────────────── /** * A successful login. * * @param string $login User login. * @param mixed $user WP_User. */ public static function on_login( $login, $user = null ): void { $id = is_object( $user ) && isset( $user->ID ) ? (int) $user->ID : 0; self::record( 'login', (string) $login, self::roles_of( $user ), $id ); self::note_admin_login_address( (string) $login, $user, $id ); } /** Per-user meta: address => first-seen timestamp, most recent 20. */ const META_LOGIN_ADDRESSES = 'csdt_login_addresses'; /** * An administrator signing in from an address never seen for that account. * * A stolen password used from the attacker's own network is the commonest * takeover there is, and the login row it left looked identical to the owner's * own. The first sign-in from any address is reported, including the owner's * first after this shipped: one alert per address per account, ever, is the * price of the second one meaning something. Country is named when the * resolver knows it. * * @param string $login Login name. * @param mixed $user WP_User. * @param int $id User id. * @return bool Whether an alert went out. */ public static function note_admin_login_address( string $login, $user, int $id ): bool { try { if ( $id < 1 || ! is_object( $user ) || ! isset( $user->roles ) || ! in_array( 'administrator', (array) $user->roles, true ) ) { return false; } if ( '1' !== (string) get_option( self::OPTION_ALERTS, '1' ) ) { return false; } $ip = class_exists( 'CSDT_DevTools' ) ? (string) CSDT_DevTools::get_client_ip() : ''; if ( '' === $ip || ! function_exists( 'get_user_meta' ) ) { return false; } $seen = get_user_meta( $id, self::META_LOGIN_ADDRESSES, true ); $seen = is_array( $seen ) ? $seen : array(); if ( isset( $seen[ $ip ] ) ) { return false; } $seen[ $ip ] = time(); if ( count( $seen ) > 20 ) { asort( $seen ); $seen = array_slice( $seen, -20, null, true ); } update_user_meta( $id, self::META_LOGIN_ADDRESSES, $seen ); if ( ! is_callable( array( 'CSDT_Threat_Monitor', 'send_threat_alert' ) ) ) { return false; } $country = ''; if ( class_exists( 'CSDT_Geo' ) && method_exists( 'CSDT_Geo', 'get_country' ) ) { $country = (string) CSDT_Geo::get_country( $ip ); } $first = 1 === count( $seen ); $site = function_exists( 'get_bloginfo' ) ? ( wp_specialchars_decode( (string) ( get_bloginfo( 'name' ) ?: home_url() ), ENT_QUOTES ) ) : ''; $url = function_exists( 'admin_url' ) ? admin_url( 'users.php' ) : ''; CSDT_Threat_Monitor::send_threat_alert( sprintf( 'CSDT: Administrator %s signed in from a new address (%s)', $login, $site ), sprintf( "Administrator %s signed in from %s%s, an address not seen for this account before.%s\n\nIf this was not you: this session is somebody else's. Sign in, end all other sessions from your profile, change the password, and check Users for accounts you did not create.\n\nUsers: %s", $login, $ip, '' !== $country ? ' (' . $country . ')' : '', $first ? ' This is the first address recorded for the account; the next one will mean more.' : '', $url ), $first ? 'high' : 'urgent', 'warning,globe_with_meridians', $url, CSDT_Alert_Events::SECURITY ); return true; } catch ( \Throwable $e ) { return false; } } /** * A failed login. * * Recorded with actor 0: nobody is authenticated, and attributing a failure to the * account that was TARGETED would read as that user having done something. * * @param string $login Attempted login. */ public static function on_login_failed( $login ): void { $login = (string) $login; /* * WHAT WAS TYPED IS NOT ALWAYS A USERNAME, AND THIS LOG IS READ AND EXPORTED. * * The attempted login is stored in the `object` field, which render_panel() concatenates into * the Detail column and export_csv() writes to a downloadable file. That is fine while the * value really is a username, "someone is trying jsmith_admin" is exactly the signal an * administrator wants, and their own site's usernames are not a secret to them. * * It is not fine for the commonest mistake at a login form: typing the password into the * username box. The password then sits in plaintext in the activity log, visible on an admin * screen and carried into any CSV that gets emailed or attached to a ticket. Nothing else in * this plugin would ever write a password anywhere, and this path could do it by accident. * * So the value is kept only when it names an account that exists. Anything else, a bot's * dictionary guess, a typo, a password in the wrong field, is recorded as unrecognised. The * volume, the source IP and the timing are all still recorded, which is what the charts and * the auto-blocker actually use; the only thing given up is the text of guesses at accounts * that do not exist. * * NO LENGTH OR PREFIX IN THE MARKER, deliberately. "(unrecognised, 14 chars)" would leak the * length of the thing this exists to avoid recording. */ $exists = '' !== $login && false !== get_user_by( 'login', $login ); self::record( 'login_failed', $exists ? $login : '', $exists ? '' : 'unrecognised account', 0 ); } /** A new user. */ public static function on_user_created( $user_id ): void { $new = self::login_for( (int) $user_id ); self::record( 'user_created', $new, self::roles_of( (int) $user_id ) ); } /** * A user about to be deleted. * * Hooked on `delete_user`, which fires BEFORE the row goes, so the login can still be * resolved. `deleted_user` would be too late and the entry would name nobody. */ public static function on_user_deleted( $user_id ): void { self::record( 'user_deleted', self::login_for( (int) $user_id ), self::roles_of( (int) $user_id ) ); } /** * A role change. * * @param int $user_id Subject. * @param string $role New role. * @param array $old_roles Previous roles. */ public static function on_role_changed( $user_id, $role, $old_roles = array() ): void { $from = is_array( $old_roles ) && $old_roles ? implode( ',', $old_roles ) : 'none'; // set_user_role also fires when a user is created, where "none → subscriber" is not a // change worth its own high-severity row. The user_created entry already carries the // role. if ( 'none' === $from ) { return; } self::record( 'role_changed', self::login_for( (int) $user_id ), $from . ' → ' . (string) $role ); } /** A completed password reset. */ public static function on_password_reset( $user = null ): void { $id = is_object( $user ) && isset( $user->ID ) ? (int) $user->ID : 0; self::record( 'password_reset', self::login_for( $id ), '', $id ); } /** A plugin activated. */ public static function on_plugin_activated( $plugin ): void { self::record( 'plugin_activated', (string) $plugin ); } /** A plugin deactivated. */ public static function on_plugin_deactivated( $plugin ): void { self::record( 'plugin_deactivated', (string) $plugin ); } /** A plugin deleted. */ public static function on_plugin_deleted( $plugin, $deleted = true ): void { self::record( 'plugin_deleted', (string) $plugin, $deleted ? 'deleted' : 'delete failed' ); } /** The active theme changed. */ public static function on_theme_switched( $name ): void { self::record( 'theme_switched', (string) $name ); } /** A theme deleted. */ public static function on_theme_deleted( $stylesheet, $deleted = true ): void { self::record( 'theme_deleted', (string) $stylesheet, $deleted ? 'deleted' : 'delete failed' ); } /** * A plugin, theme or core install or update. * * @param mixed $upgrader Upgrader instance. * @param array $data What was processed. */ public static function on_upgrade( $upgrader = null, $data = array() ): void { if ( ! is_array( $data ) ) { return; } $type = (string) ( $data['type'] ?? '' ); $action = (string) ( $data['action'] ?? '' ); $items = array(); foreach ( array( 'plugins', 'themes' ) as $key ) { if ( ! empty( $data[ $key ] ) && is_array( $data[ $key ] ) ) { $items = array_merge( $items, array_map( 'strval', $data[ $key ] ) ); } } if ( ! empty( $data['plugin'] ) ) { $items[] = (string) $data['plugin']; } if ( ! empty( $data['theme'] ) ) { $items[] = (string) $data['theme']; } $object = $items ? implode( ', ', array_slice( $items, 0, 5 ) ) : $type; self::record( 'upgraded', $object, trim( $action . ' ' . $type ) ); } /** * Somebody used the built-in theme or plugin file editor. * * The single most security-relevant thing an administrator can do from the dashboard, * and the reason `DISALLOW_FILE_EDIT` exists. Recorded before core acts on it. */ public static function on_file_edit(): void { // Who is asking: only a user who could edit a file at all gets an attempt recorded, // so an anonymous POST cannot fill the log. if ( ! current_user_can( 'edit_plugins' ) && ! current_user_can( 'edit_themes' ) ) { return; } $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : ''; $plugin = isset( $_POST['plugin'] ) ? sanitize_text_field( wp_unslash( $_POST['plugin'] ) ) : ''; $theme = isset( $_POST['theme'] ) ? sanitize_text_field( wp_unslash( $_POST['theme'] ) ) : ''; $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; // The same nonce core's wp_edit_theme_plugin_file() checks (wp-admin/includes/file.php): // 'edit-plugin_' . $file for a plugin, 'edit-theme_' . $stylesheet . '_' . $file for a // theme. This observer runs at priority 1, before core, so it verifies that nonce itself // rather than recording a request core is about to refuse. $action = '' !== $plugin ? 'edit-plugin_' . $file : 'edit-theme_' . $theme . '_' . $file; if ( ! wp_verify_nonce( $nonce, $action ) ) { return; } $where = '' !== $plugin ? 'plugin ' . $plugin : ( '' !== $theme ? 'theme ' . $theme : '' ); self::record( 'file_edited', $file, $where ); } /** * A watched option changed. * * Values are NOT recorded, only the fact of the change and a readable label. Several * watched options hold sensitive material, the hidden login slug is a secret, and the * blocklist is large, so storing before-and-after would turn this table into both a * liability and a wp_options-sized blob. * * @param string $option Option name. * @param mixed $old Previous value. * @param mixed $new New value. */ public static function on_option_changed( $option, $old = null, $new = null ): void { $option = (string) $option; if ( ! isset( self::WATCHED_OPTIONS[ $option ] ) ) { return; } if ( self::is_automation_write( $option ) ) { return; } if ( 'active_plugins' === $option ) { self::on_active_plugins_changed( $old, $new ); return; } self::record( 'option_changed', $option, self::WATCHED_OPTIONS[ $option ] . ' changed' ); } /** * The active plugin list changed: alert only when a plugin was switched OFF. * * Every write to the list used to page the owner. A deploy rewrites it without * switching anything off (the main file of this plugin was renamed, so the entry moves * from one file to another inside the same folder), and one QA deploy-and-rollback * cycle sent a dozen "Security setting changed" emails about nothing. What matters is a * plugin that STOPPED running, a security plugin above all, so that is what alerts: * * - a folder that leaves the list is a deactivation, and alerts; * - the same folder under a different main file (a deploy, a rename) is nothing; * - a folder that joins the list is logged, not alerted. Activation through WordPress * also fires activated_plugin, which is recorded as its own event; * - WordPress's own upgrader switches a plugin off silently while it replaces the * files and on again afterwards, so a removal made during upgrader_pre_install is * an upgrade, not a deactivation. * * @param mixed $old Previous list. * @param mixed $new New list. */ private static function on_active_plugins_changed( $old, $new ): void { $folders = static function ( $list ): array { $out = array(); foreach ( is_array( $list ) ? $list : array() as $entry ) { $entry = (string) $entry; $dir = dirname( $entry ); $out[] = '.' === $dir ? $entry : $dir; } return array_values( array_unique( $out ) ); }; $before = $folders( $old ); $after = $folders( $new ); $removed = array_values( array_diff( $before, $after ) ); $added = array_values( array_diff( $after, $before ) ); $upgrading = function_exists( 'doing_filter' ) && doing_filter( 'upgrader_pre_install' ); if ( array() !== $removed && ! $upgrading ) { self::record( 'option_changed', 'active_plugins', 'switched off: ' . implode( ', ', $removed ) ); } if ( array() !== $added ) { self::record( 'plugin_list_added', 'active_plugins', 'switched on by a direct write: ' . implode( ', ', $added ) ); } } /** * Whether this write is the site's own automation rather than a person. * * `csdt_ip_blocklist` is rewritten every single time an address is blocked, and that * happens in the ATTACKER's request, no user signed in. Watching the option therefore * turned every automatic block into a red "Security setting changed" row: 137 of them * inside fifteen hours, each from a different address, burying the administrative acts * this log exists to surface. * * The irony is on record: the WATCHED_OPTIONS docblock warns that prefix-matching every * `csdt_` option "would bury the events that matter", and then this option was hand-picked * anyway. * * A blocklist change by a PERSON is still worth recording, someone unblocking an address * by hand is exactly the sort of act an audit trail is for, so the option stays watched * and only the unattended writes are dropped. Every block remains fully recorded in * CSDT_Auto_Block's own log and in the firewall log; nothing is lost, it simply stops * being reported twice in the place least suited to it. * * @since 1.10.512 * @param string $option Option name. * @return bool True when the write has no human behind it. */ public static function is_automation_write( string $option ): bool { // Only the high-volume ones. For everything else an unattended write is unusual // enough to be worth seeing. if ( 'csdt_ip_blocklist' !== $option && 'csdt_ip_blocklist_active' !== $option ) { return false; } $user_id = function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0; return $user_id < 1; } /** * A watched option created. * * @param string $option Option name. * @param mixed $value Value. */ public static function on_option_added( $option, $value = null ): void { $option = (string) $option; if ( ! isset( self::WATCHED_OPTIONS[ $option ] ) ) { return; } if ( self::is_automation_write( $option ) ) { return; } self::record( 'option_changed', $option, self::WATCHED_OPTIONS[ $option ] . ' set for the first time' ); } /** The roles of a user or user id, as a readable string. */ public static function roles_of( $user ): string { // is_object FIRST. The previous order reached ctype_digit( (string) $user ), which // throws "Object of class WP_User could not be converted to string", a fatal on // every single login, since on_login() passes the WP_User straight through. Caught by // the test that exercises on_login() with a user object rather than an id. if ( ! is_object( $user ) && ( is_int( $user ) || ( is_string( $user ) && ctype_digit( $user ) ) ) ) { $user = function_exists( 'get_userdata' ) ? get_userdata( (int) $user ) : null; } if ( ! is_object( $user ) || ! isset( $user->roles ) || ! is_array( $user->roles ) ) { return ''; } return implode( ',', array_map( 'strval', $user->roles ) ); } // ── Reading ────────────────────────────────────────────────────────── /** * Entries, newest first. * * @since 1.10.497 * @param array $args event, q (search), since, limit, offset. * @return array>|null Null when the table is unavailable, which * the caller must not read as "no activity". */ public static function entries( array $args = array() ): ?array { if ( ! class_exists( 'CSDT_Storage' ) || ! CSDT_Storage::is_available() ) { return null; } $limit = max( 1, min( 500, (int) ( $args['limit'] ?? self::PAGE_SIZE ) ) ); $offset = max( 0, (int) ( $args['offset'] ?? 0 ) ); $event = (string) ( $args['event'] ?? '' ); $since = (int) ( $args['since'] ?? 0 ); $q = self::sanitize_search( (string) ( $args['q'] ?? '' ) ); try { global $wpdb; $table = CSDT_Storage::activity_table(); $like = '' !== $q ? self::like_pattern( $q ) : ''; // Four explicit branches rather than one query assembled from a $vals array and // splatted into prepare(). The dynamic form worked, but PHPCS cannot count // placeholders through a spread and warned it could not verify the call, and a // prepare() nobody can statically check is precisely where a real placeholder // mismatch hides. `$since` defaults to 0, so the condition is always safe to // include; the event filter and the search each need a branch. The search is one // LIKE pattern, escaped by $wpdb->esc_like(), matched against the five text columns. if ( '' !== $event && '' !== $q ) { $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin read of a live audit trail $wpdb->prepare( 'SELECT ts, user_id, user_login, ip, event, object, detail, severity FROM %i WHERE event = %s AND ts >= %d AND ( user_login LIKE %s OR ip LIKE %s OR event LIKE %s OR object LIKE %s OR detail LIKE %s ) ORDER BY ts DESC, id DESC LIMIT %d OFFSET %d', $table, $event, $since, $like, $like, $like, $like, $like, $limit, $offset ), ARRAY_A ); } elseif ( '' !== $q ) { $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin read of a live audit trail $wpdb->prepare( 'SELECT ts, user_id, user_login, ip, event, object, detail, severity FROM %i WHERE ts >= %d AND ( user_login LIKE %s OR ip LIKE %s OR event LIKE %s OR object LIKE %s OR detail LIKE %s ) ORDER BY ts DESC, id DESC LIMIT %d OFFSET %d', $table, $since, $like, $like, $like, $like, $like, $limit, $offset ), ARRAY_A ); } elseif ( '' !== $event ) { $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin read of a live audit trail $wpdb->prepare( 'SELECT ts, user_id, user_login, ip, event, object, detail, severity FROM %i WHERE event = %s AND ts >= %d ORDER BY ts DESC, id DESC LIMIT %d OFFSET %d', $table, $event, $since, $limit, $offset ), ARRAY_A ); } else { $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin read of a live audit trail $wpdb->prepare( 'SELECT ts, user_id, user_login, ip, event, object, detail, severity FROM %i WHERE ts >= %d ORDER BY ts DESC, id DESC LIMIT %d OFFSET %d', $table, $since, $limit, $offset ), ARRAY_A ); } return is_array( $rows ) ? $rows : null; } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } return null; } } /** * A search term as the panel accepts it: one line of plain text, at most SEARCH_MAX characters. * * Takes unslashed input, so the panel (from $_GET) and the export (from $_POST) clean it the * same way: tags and line breaks go, surrounding space goes, anything past the limit is cut. * * @param string $raw Unslashed input. * @return string */ public static function sanitize_search( string $raw ): string { $q = trim( sanitize_text_field( $raw ) ); if ( function_exists( 'mb_substr' ) ) { return trim( mb_substr( $q, 0, self::SEARCH_MAX ) ); } return trim( substr( $q, 0, self::SEARCH_MAX ) ); } /** * The LIKE pattern for a search: the term with its wildcards escaped, wrapped in %...%. * * Without esc_like() a search for "50%" or "wp_options" would be a wildcard pattern, matching * rows that do not contain what was typed. * * @param string $q Sanitised term. * @return string */ public static function like_pattern( string $q ): string { global $wpdb; return '%' . $wpdb->esc_like( $q ) . '%'; } /** * Query arguments for a link back to this panel, keeping the filter and the search. * * add_query_arg() does not encode values, so the search is encoded here; a term holding "&" * or "#" would otherwise split the URL. Empty values are left out, so an unfiltered link * stays the plain signed URL. * * @param string $event Event filter. * @param string $q Search term. * @param int $page Page number, 0 to leave it out. * @return array */ public static function view_args( string $event, string $q, int $page = 0 ): array { $args = array(); if ( '' !== $event ) { $args['csdt_act_event'] = $event; } if ( '' !== $q ) { $args['csdt_act_q'] = rawurlencode( $q ); } if ( $page > 0 ) { $args['csdt_act_page'] = $page; } return $args; } /** * How many entries match, for paging and for the panel headline. * * @since 1.10.497 * @param string $event Optional event filter. * @param string $q Optional search, matched as entries() matches it. * @return int|null Null when unavailable. */ public static function count( string $event = '', string $q = '' ): ?int { if ( ! class_exists( 'CSDT_Storage' ) || ! CSDT_Storage::is_available() ) { return null; } $q = self::sanitize_search( $q ); try { global $wpdb; $table = CSDT_Storage::activity_table(); $like = '' !== $q ? self::like_pattern( $q ) : ''; // Explicit branches, for the reason given in entries(). if ( '' !== $event && '' !== $q ) { $n = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin count $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE event = %s AND ( user_login LIKE %s OR ip LIKE %s OR event LIKE %s OR object LIKE %s OR detail LIKE %s )', $table, $event, $like, $like, $like, $like, $like ) ); } elseif ( '' !== $q ) { $n = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin count $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE ( user_login LIKE %s OR ip LIKE %s OR event LIKE %s OR object LIKE %s OR detail LIKE %s )', $table, $like, $like, $like, $like, $like ) ); } elseif ( '' !== $event ) { $n = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin count $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE event = %s', $table, $event ) ); } else { $n = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin count $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); } return null === $n ? null : (int) $n; } catch ( \Throwable $e ) { return null; } } /** * Distinct events present, for the filter dropdown. * * Read from the data rather than from a hardcoded list, so a filter can never offer an * event this site has never recorded, and can never omit one it has. * * @since 1.10.497 * @return array */ public static function known_events(): array { if ( ! class_exists( 'CSDT_Storage' ) || ! CSDT_Storage::is_available() ) { return array(); } try { global $wpdb; $table = CSDT_Storage::activity_table(); $rows = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin read, populates a filter dropdown $wpdb->prepare( 'SELECT DISTINCT event FROM %i ORDER BY event ASC', $table ) ); return is_array( $rows ) ? array_map( 'strval', $rows ) : array(); } catch ( \Throwable $e ) { return array(); } } /** * Deletes entries past retention, then enforces the row ceiling oldest-first. * * @since 1.10.497 * @param int|null $now Unix time, for tests. * @return int Rows removed, so a prune that has stopped covering anything is visible. */ public static function prune( ?int $now = null ): int { if ( ! class_exists( 'CSDT_Storage' ) || ! CSDT_Storage::is_available() ) { return 0; } $now = null === $now ? time() : $now; $cutoff = $now - self::retention(); $removed = 0; try { global $wpdb; $table = CSDT_Storage::activity_table(); $deadline = $now + 5; do { $rows = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- maintenance delete $wpdb->prepare( 'DELETE FROM %i WHERE ts < %d LIMIT %d', $table, $cutoff, self::PRUNE_BATCH ) ); $removed += $rows; } while ( $rows >= self::PRUNE_BATCH && time() < $deadline ); $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- maintenance count $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); $excess = $total - self::MAX_ROWS; while ( $excess > 0 && time() < $deadline ) { $batch = (int) min( $excess, self::PRUNE_BATCH ); $rows = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- maintenance delete $wpdb->prepare( 'DELETE FROM %i ORDER BY id ASC LIMIT %d', $table, $batch ) ); if ( $rows <= 0 ) { break; } $removed += $rows; $excess -= $rows; } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } return $removed; } /** * The log as CSV. * * @since 1.10.497 * @param int $limit Rows to include. * @param string $event Event filter, as the panel shows it. * @param string $q Search, as the panel shows it. * @return string CSV text, header row first. */ public static function to_csv( int $limit = 5000, string $event = '', string $q = '' ): string { $rows = self::entries( array( 'limit' => min( 500, $limit ), 'event' => $event, 'q' => $q, ) ); $out = "date,user,ip,event,object,detail,severity\n"; foreach ( (array) $rows as $row ) { $out .= implode( ',', array_map( array( __CLASS__, 'csv_cell' ), array( gmdate( 'Y-m-d H:i:s', (int) ( $row['ts'] ?? 0 ) ), (string) ( $row['user_login'] ?? '' ), (string) ( $row['ip'] ?? '' ), (string) ( $row['event'] ?? '' ), (string) ( $row['object'] ?? '' ), (string) ( $row['detail'] ?? '' ), (string) ( $row['severity'] ?? '' ), ) ) ) . "\n"; } return $out; } /** * Quotes one CSV cell, and defuses spreadsheet formula injection. * * A cell beginning `=`, `+`, `-` or `@` is executed as a formula by Excel and Sheets. * Object names here come from plugin headers and user logins, so an attacker who can * choose one of those can otherwise put a formula into an exported audit log, the * classic CSV injection, and a real risk precisely because a security log is a file * somebody opens in a spreadsheet. * * @since 1.10.497 * @param string $value Cell value. * @return string Quoted cell. */ public static function csv_cell( string $value ): string { $value = str_replace( array( "\r", "\n" ), ' ', $value ); if ( '' !== $value && false !== strpos( "=+-@\t", $value[0] ) ) { $value = "'" . $value; } return '"' . str_replace( '"', '""', $value ) . '"'; } // ── Admin UI ───────────────────────────────────────────────────────── /** Human labels for the events this log records. */ public static function event_labels(): array { return array( 'login' => __( 'Signed in', 'cloudscale-cyber-devtools' ), 'login_failed' => __( 'Failed sign-in', 'cloudscale-cyber-devtools' ), 'user_created' => __( 'User created', 'cloudscale-cyber-devtools' ), 'user_deleted' => __( 'User deleted', 'cloudscale-cyber-devtools' ), 'role_changed' => __( 'Role changed', 'cloudscale-cyber-devtools' ), 'password_reset' => __( 'Password reset', 'cloudscale-cyber-devtools' ), 'plugin_activated' => __( 'Plugin activated', 'cloudscale-cyber-devtools' ), 'plugin_deactivated' => __( 'Plugin deactivated', 'cloudscale-cyber-devtools' ), 'plugin_deleted' => __( 'Plugin deleted', 'cloudscale-cyber-devtools' ), 'theme_switched' => __( 'Theme switched', 'cloudscale-cyber-devtools' ), 'theme_deleted' => __( 'Theme deleted', 'cloudscale-cyber-devtools' ), 'upgraded' => __( 'Installed or updated', 'cloudscale-cyber-devtools' ), 'file_edited' => __( 'File edited from the dashboard', 'cloudscale-cyber-devtools' ), 'option_changed' => __( 'Security setting changed', 'cloudscale-cyber-devtools' ), 'plugin_list_added' => __( 'Plugin switched on outside WordPress', 'cloudscale-cyber-devtools' ), 'persistence_changed' => __( 'wp-config, .htaccess, mu-plugin or drop-in changed', 'cloudscale-cyber-devtools' ), 'file_repaired' => __( 'File repaired from WordPress.org', 'cloudscale-cyber-devtools' ), 'file_quarantined' => __( 'File moved to quarantine', 'cloudscale-cyber-devtools' ), 'quarantine_restored' => __( 'File restored from quarantine', 'cloudscale-cyber-devtools' ), 'quarantine_deleted' => __( 'Quarantined file deleted', 'cloudscale-cyber-devtools' ), // Emergency Lockdown (CSDT_Lockdown). 'lockdown_engaged' => __( 'Emergency lockdown engaged', 'cloudscale-cyber-devtools' ), 'lockdown_released' => __( 'Emergency lockdown released', 'cloudscale-cyber-devtools' ), 'lockdown_refused' => __( 'Refused during lockdown', 'cloudscale-cyber-devtools' ), 'lockdown_would_engage' => __( 'Lockdown would have engaged', 'cloudscale-cyber-devtools' ), // CloudScale Edge Gateway (CSDT_Edge_Gateway). 'edge_gateway_paired' => __( 'Edge gateway paired', 'cloudscale-cyber-devtools' ), 'edge_gateway_unpaired' => __( 'Edge gateway unpaired', 'cloudscale-cyber-devtools' ), 'edge_gateway_settings' => __( 'Edge gateway settings changed', 'cloudscale-cyber-devtools' ), 'edge_gateway_critical' => __( 'Edge gateway saw a critical request', 'cloudscale-cyber-devtools' ), ); } /** Renders the Activity Log panel. */ public static function render_panel(): void { $enabled = self::is_enabled(); // The filter and page arguments come from links this panel prints, each signed with // a nonce; an unsigned URL renders the default view. $signed = false; if ( isset( $_GET['_wpnonce'] ) && wp_verify_nonce( sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ), 'csdt_activity_view' ) ) { $signed = true; } $filter = $signed && isset( $_GET['csdt_act_event'] ) ? sanitize_key( wp_unslash( $_GET['csdt_act_event'] ) ) : ''; // The search is signed by the same nonce as the filter; an unsigned one is ignored. $search = $signed && isset( $_GET['csdt_act_q'] ) ? self::sanitize_search( sanitize_text_field( wp_unslash( $_GET['csdt_act_q'] ) ) ) : ''; $paged = $signed && isset( $_GET['csdt_act_page'] ) ? max( 1, (int) $_GET['csdt_act_page'] ) : 1; $offset = ( $paged - 1 ) * self::PAGE_SIZE; $rows = self::entries( array( 'event' => $filter, 'q' => $search, 'limit' => self::PAGE_SIZE, 'offset' => $offset, ) ); $total = self::count( $filter, $search ); $narrowed = ( '' !== $filter || '' !== $search ); $labels = self::event_labels(); $broken = ( null === $rows || null === $total ); $write_failure = get_option( self::OPTION_WRITE_FAILURE, false ); $base = wp_nonce_url( admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=activity' ), 'csdt_activity_view' ); ?>
📜 'Why this is different from every other panel here', 'rec' => 'How it works', 'html' => 'Every other check in this plugin answers what is wrong now. None of them answers how did it get that way, and after an incident that is the only question that matters.

A two-factor setting turning itself off is already alerted on. This is what tells you which account did it, from which address, and what else that account touched in the same hour.', ), array( 'name' => 'What is recorded', 'rec' => 'Scope', 'html' => 'Sign-ins and failed sign-ins; users created, deleted, and their roles changed; password resets; plugins activated, deactivated and deleted; themes switched and deleted; installs and updates; use of the dashboard file editor; and changes to a curated list of security-relevant settings.

Each entry keeps the account name as text as well as its id, because "user 47 was deleted" is useless once user 47 is gone, and a deletion is the entry most worth reading months later.', ), array( 'name' => 'What is NOT recorded, and why', 'rec' => 'Honest limits', 'html' => 'Post and page edits are not logged. WordPress already keeps revisions with authorship, and duplicating that here would bury the security events in editorial noise. Media uploads are left out for the same reason.

Setting VALUES are not stored, only the fact that a setting changed. Several of the watched settings are secrets, the hidden login address is one, so keeping before-and-after would turn this table into a liability.

Changes made outside PHP cannot be seen. Editing the database directly, or files over SSH, is invisible to this and to every other activity log in every other plugin. It is worth saying plainly rather than letting an empty log imply nothing happened.', ), array( 'name' => 'Retention and size', 'rec' => 'How it works', 'html' => 'Entries are kept for ' . esc_html( (string) self::DEFAULT_RETENTION_DAYS ) . ' days by default, and there is a hard ceiling of ' . esc_html( number_format_i18n( self::MAX_ROWS ) ) . ' rows enforced oldest-first.

Two independent bounds on purpose: if scheduled tasks stop running, which is exactly what happens on a struggling site, nothing is yet older than the cutoff and a date-based limit alone would let the table grow without end.

Unlike the firewall log, entries here are never merged. Collapsing "changed the role three times" into one row would destroy the sequence that makes an audit trail worth keeping.', ), ) ); ?>

' . esc_html__( 'not signed in', 'cloudscale-cyber-devtools' ) . '', array( 'em' => array( 'style' => true ) ) ); ?>
1 ) : ?>

1 ) : ?> ← →

365 ) { // Refused rather than clamped: silently storing 365 when 4000 was asked for means // the screen and the stored value disagree. wp_send_json_error( __( 'Retention must be between 1 and 365 days. Nothing was saved.', 'cloudscale-cyber-devtools' ) ); return; } // Recorded BEFORE the option changes, so switching recording off is itself on record. // Otherwise the one act that blinds the log is the one act it never captures. if ( ! $enabled && self::is_enabled() ) { self::record( 'option_changed', self::OPTION_ENABLED, 'activity recording switched OFF' ); } update_option( self::OPTION_ENABLED, $enabled ? '1' : '0' ); update_option( self::OPTION_RETENTION_DAYS, $days ); if ( $enabled ) { self::record( 'option_changed', self::OPTION_ENABLED, 'activity recording is on, keeping ' . $days . ' days' ); } wp_send_json_success( array( 'msg' => __( 'Saved.', 'cloudscale-cyber-devtools' ) ) ); } /** Streams the log as a CSV download. */ public static function ajax_export(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } // The export is what the panel shows: the same event filter and search. $event = isset( $_POST['event'] ) ? sanitize_key( wp_unslash( $_POST['event'] ) ) : ''; $q = isset( $_POST['q'] ) ? self::sanitize_search( sanitize_text_field( wp_unslash( $_POST['q'] ) ) ) : ''; $csv = self::to_csv( 5000, $event, $q ); self::record( 'option_changed', 'activity_log', '' !== $event || '' !== $q ? 'exported a filtered view of the activity log' : 'exported the activity log' ); wp_send_json_success( array( 'csv' => $csv ) ); } }