*/ private $pending_roles = []; /** * Roles read in the "before" hook of a capabilities write, per user ID, * waiting for the matching "after" hook. * * @var array */ private $roles_before_write = []; /** * How many REST callbacks are running right now. While one is, role writes * are held and written as one row when it returns — see hold_roles_for_rest. * * @var int */ private $rest_depth = 0; /** * Users being deleted in this request. Their capabilities row goes with * every other meta row, and user_deleted already says so. * * @var array */ private $deleting_users = []; public static function instance() { if ( self::$instance === null ) { self::$instance = new self(); } return self::$instance; } private function __construct() { add_action( 'activated_plugin', [ $this, 'on_plugin_activated' ] ); add_action( 'deactivated_plugin', [ $this, 'on_plugin_deactivated' ] ); add_action( 'delete_plugin', [ $this, 'on_plugin_deleted' ] ); add_action( 'switch_theme', [ $this, 'on_theme_switched' ] ); add_action( 'upgrader_process_complete', [ $this, 'on_upgrader_complete' ], 10, 2 ); add_action( 'post_updated', [ $this, 'on_post_updated' ], 10, 3 ); add_action( 'delete_post', [ $this, 'on_post_deleted' ] ); add_action( 'updated_option', [ $this, 'on_option_updated' ] ); add_action( 'user_register', [ $this, 'on_user_registered' ] ); add_action( 'delete_user', [ $this, 'on_user_deleted' ] ); // Roles. Every way of changing them — the profile screen, the Users // list, REST, WP-CLI, wp_update_user, set_role, add_role, // remove_role, add_cap and a bare update_user_meta — ends in a // write of the user's {prefix}capabilities meta row, and only some of // them fire set_user_role. So the row itself is what is watched. add_action( 'add_user_meta', [ $this, 'before_caps_added' ], 10, 2 ); add_action( 'update_user_meta', [ $this, 'before_caps_written' ], 10, 3 ); add_action( 'delete_user_meta', [ $this, 'before_caps_written' ], 10, 3 ); add_action( 'added_user_meta', [ $this, 'on_caps_written' ], 10, 3 ); add_action( 'updated_user_meta', [ $this, 'on_caps_written' ], 10, 3 ); add_action( 'deleted_user_meta', [ $this, 'on_caps_written' ], 10, 3 ); add_filter( 'rest_request_before_callbacks', [ $this, 'hold_roles_for_rest' ] ); add_filter( 'rest_request_after_callbacks', [ $this, 'release_roles_for_rest' ] ); add_action( 'shutdown', [ $this, 'flush_role_changes' ] ); add_action( 'admin_post_guardforge_verify_chain', [ $this, 'handle_verify_chain' ] ); } /** * "Verify chain" button on admin/partials/audit-log.php. Runs the whole * table (verify_chain batches its own SELECTs) inside one request and * redirects back with the verdict in the query string, the same shape as * every other synchronous action on this screen (clear log, export). */ public function handle_verify_chain() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Access denied.', 'guardforge' ) ); } check_admin_referer( 'guardforge_verify_chain' ); $result = self::verify_chain(); $args = [ 'page' => 'guardforge-audit', 'gf_verify' => $result['ok'] ? 'ok' : 'broken', 'gf_verify_n' => (int) $result['checked'], // genesis_resets is 1 on a normal, unbroken chain (the very // first row is always its own genesis) — only worth surfacing // when it is more than that, i.e. the chain restarted somewhere // an operator might not expect (pre-upgrade rows, or a table // that was pruned and re-seeded). 'gf_verify_g' => (int) $result['genesis_resets'], ]; if ( ! $result['ok'] ) { $args['gf_verify_id'] = (int) $result['broken_id']; } elseif ( ! empty( $result['truncated'] ) ) { $args['gf_verify_trunc'] = '1'; } wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) ); exit; } public function record( $event_type, $object_type = '', $object_name = '', $details = '' ) { if ( ! GUARDFORGE_Options::get( 'audit_log_enabled' ) ) { return false; } global $wpdb; $user = function_exists( 'wp_get_current_user' ) ? wp_get_current_user() : null; $fields = [ 'event_time' => current_time( 'mysql' ), 'user_id' => $user && $user->ID ? (int) $user->ID : 0, 'user_login' => $user && $user->user_login ? substr( $user->user_login, 0, 200 ) : '', 'event_type' => substr( sanitize_text_field( (string) $event_type ), 0, 60 ), 'object_type' => substr( sanitize_text_field( (string) $object_type ), 0, 60 ), 'object_name' => substr( sanitize_text_field( (string) $object_name ), 0, 1000 ), 'details' => substr( sanitize_text_field( (string) $details ), 0, 1000 ), 'ip' => GUARDFORGE_Ip::client_ip(), ]; $lock_token = $this->acquire_chain_lock(); $prev_hash = $this->tail_row_hash(); $row_hash = self::compute_row_hash( $prev_hash, $fields ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- plugin-owned table; $wpdb escapes insert/update/replace/delete values itself, and there is nothing to cache on a write. $inserted = $wpdb->insert( $wpdb->prefix . 'guardforge_audit_log', array_merge( $fields, [ 'prev_hash' => $prev_hash, 'row_hash' => $row_hash, ] ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery $this->release_chain_lock( $lock_token ); return $inserted !== false; } // ───────────────────────── the hash chain ───────────────────────── /** * The row_hash of the most recently written row, or GENESIS_HASH when * there is none yet (an empty table) or the most recent row predates * this feature (empty row_hash — see the class docblock). * * @return string */ private function tail_row_hash() { global $wpdb; $table = $wpdb->prefix . 'guardforge_audit_log'; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is a prefixed constant; no user input. $hash = $wpdb->get_var( "SELECT row_hash FROM {$table} ORDER BY id DESC LIMIT 1" ); return ( $hash !== null && $hash !== '' ) ? (string) $hash : self::GENESIS_HASH; } /** * sha256(prev_hash . canonical JSON of the row's own fields). * * "Canonical" here means: only the columns a row actually owns (never id, * never prev_hash/row_hash themselves), every value cast to string so a * fresh insert (an int user_id) and a row read back from the database * (every column a string) hash identically, and keys sorted so the * result does not depend on the order an array literal happened to be * written in. * * @param string $prev_hash * @param array $fields event_time, user_id, user_login, event_type, * object_type, object_name, details, ip. * @return string 64-character lowercase hex sha256 digest. */ public static function compute_row_hash( $prev_hash, array $fields ) { return hash( 'sha256', (string) $prev_hash . self::canonical_json( $fields ) ); } /** * @param array $fields * @return array Every hashable field, cast to string. */ private static function hashable_fields( array $fields ) { return [ 'event_time' => (string) ( $fields['event_time'] ?? '' ), 'user_id' => (string) ( $fields['user_id'] ?? '' ), 'user_login' => (string) ( $fields['user_login'] ?? '' ), 'event_type' => (string) ( $fields['event_type'] ?? '' ), 'object_type' => (string) ( $fields['object_type'] ?? '' ), 'object_name' => (string) ( $fields['object_name'] ?? '' ), 'details' => (string) ( $fields['details'] ?? '' ), 'ip' => (string) ( $fields['ip'] ?? '' ), ]; } /** @return string JSON with sorted keys — see compute_row_hash. */ private static function canonical_json( array $fields ) { $fields = self::hashable_fields( $fields ); ksort( $fields ); return (string) wp_json_encode( $fields, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } /** * Mutex around the read-tail-then-insert section, via MySQL's named * locks. See the CHAIN_LOCK_* constants for what this does and does not * guarantee. * * @return string|null The lock name this call is holding, or null if * GET_LOCK itself failed or timed out — record * proceeds either way (see the constants' docblock). */ private function acquire_chain_lock() { global $wpdb; $name = $wpdb->prefix . self::CHAIN_LOCK_NAME; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- GET_LOCK is a server-side mutex, not a table read; both values are prepared. $acquired = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $name, self::CHAIN_LOCK_TIMEOUT_SECONDS ) ); // GET_LOCK returns 1 on success, 0 on timeout, NULL on error — cast // first so '1' (a real MySQL driver's string) and 1 (a test double's // int) both count as acquired. return ( (int) $acquired === 1 ) ? $name : null; } /** @param string|null $name Whatever acquire_chain_lock returned. */ private function release_chain_lock( $name ) { if ( $name === null ) { return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- RELEASE_LOCK is a server-side mutex, not a table read; the name is prepared. $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $name ) ); } /** * Rows a single "Verify chain" click will examine before giving up and * reporting a truncated result rather than risking max_execution_time * killing the request mid-walk and leaving the operator with a blank * screen instead of a verdict (see the class docblock discussion of * this trade-off). 1000-row batches, so this is at most 50 SELECTs. */ const VERIFY_MAX_ROWS_PER_RUN = 50000; /** * Walk the table from the oldest row forward in batches, verifying every * hashed row against its own recomputed hash and against the row before * it, up to VERIFY_MAX_ROWS_PER_RUN rows. * * @param int $batch_size Rows per SELECT — bounded so a large table is * never loaded into memory at once. * @param int $max_rows Hard ceiling on rows examined in this call — * see VERIFY_MAX_ROWS_PER_RUN. A table larger * than this reports 'truncated' => true rather * than risk the request timing out mid-walk; the * portion actually examined is still a genuine * pass/fail, it just is not the whole table. * @return array{ok:bool,checked:int,broken_id:int|null,genesis_resets:int,truncated:bool} * genesis_resets counts every point the chain (re)started * from GENESIS_HASH — 1 on an unbroken chain with no * pre-upgrade history, more if legacy unhashed rows are * mixed in further down the table. truncated is true only * when the table has more rows than $max_rows allowed * this call to reach. */ public static function verify_chain( $batch_size = 1000, $max_rows = self::VERIFY_MAX_ROWS_PER_RUN ) { global $wpdb; $table = $wpdb->prefix . 'guardforge_audit_log'; $batch_size = max( 1, (int) $batch_size ); $max_rows = max( 1, (int) $max_rows ); $checked = 0; $last_id = 0; $expected_prev = null; // null = "next hashed row is allowed to be a genesis". $genesis_resets = 0; do { // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is a prefixed constant; $last_id/$batch_size are prepared. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, event_time, user_id, user_login, event_type, object_type, object_name, details, ip, prev_hash, row_hash FROM {$table} WHERE id > %d ORDER BY id ASC LIMIT %d", $last_id, min( $batch_size, $max_rows - $checked ) ) ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( ! $rows ) { break; } foreach ( $rows as $row ) { $checked++; $last_id = (int) $row->id; if ( (string) $row->row_hash === '' ) { // Pre-upgrade row. Not verifiable, and it resets what the // next hashed row is allowed to point at. $expected_prev = null; continue; } $expected = $expected_prev === null ? self::GENESIS_HASH : $expected_prev; if ( $expected_prev === null ) { $genesis_resets++; } $recomputed = self::compute_row_hash( (string) $row->prev_hash, (array) $row ); if ( (string) $row->prev_hash !== $expected || (string) $row->row_hash !== $recomputed ) { return [ 'ok' => false, 'checked' => $checked, 'broken_id' => (int) $row->id, 'genesis_resets' => $genesis_resets, 'truncated' => false, ]; } $expected_prev = (string) $row->row_hash; } } while ( count( $rows ) === $batch_size && $checked < $max_rows ); // Reaching the cap does not by itself mean the table has more rows — // it could have exactly $max_rows. Ask directly rather than guess, // so a table that just happens to land on the ceiling is not // reported as truncated when every row in it was in fact checked. $truncated = false; if ( $checked >= $max_rows ) { // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is a prefixed constant; $last_id is prepared. $truncated = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$table} WHERE id > %d LIMIT 1", $last_id ) ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared } return [ 'ok' => true, 'checked' => $checked, 'broken_id' => null, 'genesis_resets' => $genesis_resets, 'truncated' => $truncated, ]; } public function on_plugin_activated( $plugin ) { $this->record( 'plugin_activated', 'plugin', $plugin ); } public function on_plugin_deactivated( $plugin ) { $this->record( 'plugin_deactivated', 'plugin', $plugin ); } public function on_plugin_deleted( $plugin ) { $this->record( 'plugin_deleted', 'plugin', $plugin ); } public function on_theme_switched( $name ) { $this->record( 'theme_switched', 'theme', $name ); } public function on_upgrader_complete( $upgrader, $options ) { if ( ! is_array( $options ) ) { return; } $type = $options['type'] ?? ''; if ( ! in_array( $type, [ 'plugin', 'theme', 'core' ], true ) ) { return; } $details = isset( $options['action'] ) ? (string) $options['action'] : ''; $this->record( $type . '_upgraded', $type, '', $details ); } public function on_post_updated( $post_id, $after, $before ) { if ( ! $after || $after->post_status === 'auto-draft' ) { return; } if ( wp_is_post_revision( $post_id ) ) { return; } $this->record( 'post_updated', (string) $after->post_type, (string) $after->post_title, 'ID:' . $post_id ); } public function on_post_deleted( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { return; } $this->record( 'post_deleted', (string) $post->post_type, (string) $post->post_title, 'ID:' . $post_id ); } public function on_option_updated( $option ) { if ( ! is_string( $option ) ) { return; } foreach ( self::SKIP_OPTION_SUBSTRINGS as $needle ) { if ( strpos( $option, $needle ) !== false ) { return; } } $this->record( 'option_updated', 'option', $option ); } public function on_user_registered( $user_id ) { $user = get_userdata( $user_id ); $this->record( 'user_registered', 'user', $user ? $user->user_login : '', 'ID:' . $user_id ); } public function on_user_deleted( $user_id ) { $user = get_userdata( $user_id ); // A role change made earlier in this request is still reported — a // user promoted and deleted in one go is exactly what should not // vanish — and the capabilities row the deletion is about to remove // is not reported as a demotion. $this->flush_role_changes(); $this->deleting_users[ (int) $user_id ] = true; $this->record( 'user_deleted', 'user', $user ? $user->user_login : '', 'ID:' . $user_id ); } // ───────────────────────── role changes ───────────────────────── // Up to 1.3.5 roles were read from set_user_role alone, and two things // went wrong. The REST API changes roles with set_role( false ) followed // by add_role for each requested role: the first half was written as // "role=, old=author", the second fired nothing, so promoting a user to // administrator through REST — or through any code calling add_role — // never raised the new-administrator alert. And add_cap( 'administrator' ) // or a bare update_user_meta on the capabilities row fired no role hook // at all. // So the row is watched instead, and each write is compared with what the // row held before it. Each write is its own operation and is recorded at // once, except inside a REST callback: there the controller's two steps // are one change, recorded as one row when the callback returns (or at // shutdown, if it never does). An administrator who holds the role after // an operation and did not before it is a new administrator, whichever // path got them there; one re-saved with the role they already had is not. /** * add_user_meta: a capabilities row is about to be created. Usually the * user has none yet — a new account, or an existing one being added to * this site — and whatever WordPress would read for them now is "before". * * @param int $user_id * @param string $meta_key */ public function before_caps_added( $user_id, $meta_key ) { $this->remember_roles_before_write( $user_id, $meta_key ); } /** * update_user_meta / delete_user_meta: the row is about to change, and the * cache still holds what it held. The first argument is the meta ID (or * IDs) and is not needed. * * @param int|int $meta_id * @param int $user_id * @param string $meta_key */ public function before_caps_written( $meta_id, $user_id, $meta_key ) { $this->remember_roles_before_write( $user_id, $meta_key ); } /** * added_user_meta / updated_user_meta / deleted_user_meta: the write * happened. The roles now in force are read back rather than taken from * the value that was written — that is what WordPress itself will see. * * @param int|int $meta_id * @param int $user_id * @param string $meta_key */ public function on_caps_written( $meta_id, $user_id, $meta_key ) { if ( ! self::is_caps_key( $meta_key ) ) { return; } $user_id = (int) $user_id; if ( $user_id <= 0 ) { return; } $before = $this->roles_before_write[ $user_id ] ?? []; unset( $this->roles_before_write[ $user_id ] ); if ( isset( $this->deleting_users[ $user_id ] ) ) { return; } $after = self::roles_in_caps( get_user_meta( $user_id, $meta_key, true ) ); // Held only on the site the REST request is for: a callback that // switches to another site of a network and writes roles there gets // its row there, now, exactly as before. if ( $this->rest_depth > 0 && ! self::is_switched_site() ) { if ( isset( $this->pending_roles[ $user_id ] ) ) { $this->pending_roles[ $user_id ]['after'] = $after; } else { $this->pending_roles[ $user_id ] = [ 'before' => $before, 'after' => $after ]; } return; } $this->report_role_change( $user_id, $before, $after ); } /** * rest_request_before_callbacks: a REST callback is about to run. Role * writes it makes are held until it returns. Returns the value unchanged. * * @param mixed $response * @return mixed */ public function hold_roles_for_rest( $response ) { $this->rest_depth++; return $response; } /** * rest_request_after_callbacks: the callback returned, successfully or * not. Whatever it did to roles is one change per user. Returns the value * unchanged. * * @param mixed $response * @return mixed */ public function release_roles_for_rest( $response ) { if ( $this->rest_depth > 0 ) { $this->rest_depth--; } if ( $this->rest_depth === 0 ) { $this->flush_role_changes(); } return $response; } /** * Write every held role change and raise the alert where one is due. * Also the shutdown hook: a REST callback that died half-way still leaves * its row and its alert behind. */ public function flush_role_changes() { while ( $this->pending_roles ) { $user_id = (int) array_key_first( $this->pending_roles ); $change = $this->pending_roles[ $user_id ]; unset( $this->pending_roles[ $user_id ] ); $this->report_role_change( $user_id, $change['before'], $change['after'] ); } } /** * @param int $user_id * @param string $before * @param string $after */ private function report_role_change( $user_id, array $before, array $after ) { if ( self::same_roles( $before, $after ) ) { // Re-saved with the roles it had (the REST API strips and re-adds // them on every edit that names roles): nothing changed. return; } $user = get_userdata( $user_id ); $login = $user ? $user->user_login : ''; $this->record( 'role_changed', 'user', $login, sprintf( 'role=%s, old=%s', self::role_list( $after ), self::role_list( $before ) ) ); if ( ! in_array( 'administrator', $after, true ) || in_array( 'administrator', $before, true ) ) { return; } // Fired unconditionally: audit_notify_new_admin is the e-mail switch, // and the add-on's Telegram / Slack / webhook channels have their own. do_action( 'guardforge/new_admin', $user_id, $login ); if ( GUARDFORGE_Options::get( 'audit_notify_new_admin' ) ) { $this->notify_new_admin( $user_id, $login ); } } /** * @param int $user_id * @param string $meta_key */ private function remember_roles_before_write( $user_id, $meta_key ) { if ( ! self::is_caps_key( $meta_key ) ) { return; } $user_id = (int) $user_id; if ( $user_id <= 0 ) { return; } $this->roles_before_write[ $user_id ] = self::roles_in_caps( get_user_meta( $user_id, $meta_key, true ) ); } /** @return bool True inside switch_to_blog on a network. */ private static function is_switched_site() { return function_exists( 'ms_is_switched' ) && ms_is_switched(); } /** * The meta key that holds roles on the site being written to. It follows * switch_to_blog, which is how add_user_to_blog reaches another site. * * @param mixed $meta_key * @return bool */ private static function is_caps_key( $meta_key ) { global $wpdb; return is_string( $meta_key ) && $meta_key === $wpdb->get_blog_prefix() . 'capabilities'; } /** * The roles in a capabilities array, read the way WP_User::get_role_caps * reads them: every key that names a registered role, in stored order, * whatever its value — so ( 'administrator' => false ) still counts, * because WordPress still hands that user every administrator capability. * Keys that are single capabilities ( 'edit_posts' => true ) are not roles. * * @param mixed $caps * @return string */ private static function roles_in_caps( $caps ) { $caps = maybe_unserialize( $caps ); if ( ! is_array( $caps ) ) { return []; } $wp_roles = wp_roles(); $roles = []; foreach ( array_keys( $caps ) as $name ) { $name = (string) $name; if ( $wp_roles->is_role( $name ) ) { $roles[] = $name; } } return $roles; } /** * @param string $a * @param string $b * @return bool The same roles, in any order. */ private static function same_roles( array $a, array $b ) { sort( $a ); sort( $b ); return $a === $b; } /** * @param string $roles * @return string */ private static function role_list( array $roles ) { return $roles ? implode( ',', $roles ) : self::NO_ROLES; } private function notify_new_admin( $user_id, $login ) { // A copy of the site is where a developer makes themselves an // administrator to reproduce something, so this is the notifier that // fires most often on staging and means least. The row is still // written above; only the e-mail is held back. if ( GUARDFORGE_Staging::is_active() ) { GUARDFORGE_Staging::note_suppressed( 'email', 'new_admin' ); return; } $to = GUARDFORGE_Options::get( 'notify_email' ); if ( ! $to ) { $to = get_option( 'admin_email' ); } if ( ! is_email( $to ) ) { return; } $subject = sprintf( '[%s] %s', get_bloginfo( 'name' ), __( 'GuardForge — new administrator', 'guardforge' ) ); $body = sprintf( /* translators: 1: login, 2: user id, 3: timestamp. */ __( "User %1\$s (ID: %2\$d) was granted the administrator role.\nTime: %3\$s", 'guardforge' ), $login, (int) $user_id, current_time( 'mysql' ) ); wp_mail( $to, $subject, $body ); } }