the $_SERVER field it reads. REMOTE_ADDR is * the only address the server itself saw. Each header can be sent by * any visitor, so one is read only when the site owner says a proxy * that sets it sits in front of the site. */ const IP_SOURCES = array( 'remote_addr' => 'REMOTE_ADDR', 'x_forwarded_for' => 'HTTP_X_FORWARDED_FOR', 'x_real_ip' => 'HTTP_X_REAL_IP', 'cf_connecting_ip' => 'HTTP_CF_CONNECTING_IP', ); /** * Core settings whose changes are logged => what the log calls them. * Each one either moves the site (its addresses), reaches its owner * (the admin email), or changes who can get in, what they get, or what * search engines see. Those last are what someone who has broken in * changes first: open registration with administrator as the default * role is a new admin account for anyone who asks. */ const WATCHED_SETTINGS = array( 'siteurl' => 'Site URL', 'home' => 'Home URL', 'admin_email' => 'Admin Email', 'blogname' => 'Site Title', 'blogdescription' => 'Tagline', 'users_can_register' => 'Anyone can register', 'default_role' => 'New User Default Role', 'blog_public' => 'Search engine visibility', 'permalink_structure' => 'Permalink structure', ) + self::CORE_SCREEN_SETTINGS; /** * The rest of core's own settings screens (#5): General, Writing, * Reading, Discussion and Permalinks, each option under the label its * screen shows. These change what visitors see and what the site accepts * from them (comment moderation, who may comment, what a feed exposes), * which is why the leaders log them. Media settings (image sizes, upload * folders) are a named deferral; see docs/event-coverage.md. */ const CORE_SCREEN_SETTINGS = array( // General. 'timezone_string' => 'Timezone', 'gmt_offset' => 'UTC offset', 'date_format' => 'Date format', 'time_format' => 'Time format', 'start_of_week' => 'Week starts on', 'WPLANG' => 'Site language', // Writing. 'default_category' => 'Default post category', 'default_post_format' => 'Default post format', 'mailserver_url' => 'Post via email: mail server', 'mailserver_port' => 'Post via email: port', 'mailserver_login' => 'Post via email: login name', 'mailserver_pass' => 'Post via email: password', 'default_email_category' => 'Post via email: default category', 'ping_sites' => 'Update services', // Reading. 'show_on_front' => 'Your homepage displays', 'page_on_front' => 'Homepage', 'page_for_posts' => 'Posts page', 'posts_per_page' => 'Blog pages show at most', 'posts_per_rss' => 'Syndication feeds show the most recent', 'rss_use_excerpt' => 'For each post in a feed, include', // Discussion. 'default_pingback_flag' => 'Attempt to notify linked blogs', 'default_ping_status' => 'Allow link notifications (pingbacks and trackbacks)', 'default_comment_status' => 'Allow people to submit comments on new posts', 'require_name_email' => 'Comment author must fill out name and email', 'comment_registration' => 'Users must be registered and logged in to comment', 'close_comments_for_old_posts' => 'Automatically close comments on old posts', 'close_comments_days_old' => 'Close comments on posts older than (days)', 'show_comments_cookies_opt_in' => 'Show comments cookies opt-in checkbox', 'thread_comments' => 'Enable threaded comments', 'thread_comments_depth' => 'Threaded comments depth', 'page_comments' => 'Break comments into pages', 'comments_per_page' => 'Top level comments per page', 'default_comments_page' => 'Comments page displayed by default', 'comment_order' => 'Comments order', 'comments_notify' => 'Email me when anyone posts a comment', 'moderation_notify' => 'Email me when a comment is held for moderation', 'comment_moderation' => 'Comment must be manually approved', 'comment_previously_approved' => 'Comment author must have a previously approved comment', 'comment_max_links' => 'Hold a comment with this many links', 'moderation_keys' => 'Comment moderation list', 'disallowed_keys' => 'Disallowed comment keys', 'show_avatars' => 'Show avatars', 'avatar_rating' => 'Maximum avatar rating', 'avatar_default' => 'Default avatar', // Permalinks. 'category_base' => 'Category base', 'tag_base' => 'Tag base', ); /** * Settings whose summary names only the setting, not its values: the * admin email is personal data, and a summary is shown everywhere the * log is. */ const SETTINGS_SUMMARISED_WITHOUT_VALUES = array( 'siteurl', 'home', 'admin_email' ); /** * Settings whose values are never stored ANYWHERE -- not the summary, not * the details. The Post via email login and password are a mailbox * credential; the two comment lists routinely hold email addresses and IP * addresses of the people being blocked, and run to any length. The row * records that the setting changed and who changed it. * * is_secret_setting() also withholds any option whose NAME reads as a * credential, so a setting added to WATCHED_SETTINGS later cannot leak one * by being left off this list. */ const SETTINGS_WITHOUT_VALUES = array( 'mailserver_login', 'mailserver_pass', 'moderation_keys', 'disallowed_keys' ); /** * Checkbox settings, whose stored '1' / '' / '0' the summary shows as on * or off -- and which are compared as on/off, so core rewriting an * unchecked '' as '0' is not a change. */ const ON_OFF_SETTINGS = array( 'users_can_register', 'default_pingback_flag', 'require_name_email', 'comment_registration', 'close_comments_for_old_posts', 'show_comments_cookies_opt_in', 'thread_comments', 'page_comments', 'comments_notify', 'moderation_notify', 'comment_moderation', 'comment_previously_approved', 'show_avatars', ); /** * Post types that are not content someone edits, and so are never post * events (#5). Revisions and attachments were always out (media has its * own events, Events\MediaEvents). Menu items are a menu's parts, logged * as one `menu_updated` per save (Events\AppearanceEvents), not one * `post_updated` per item. Customizer changesets and the oEmbed cache are * core's own bookkeeping, written on visitors' page views. A * `user_request` post's TITLE is the requester's email address -- a row * about it would copy personal data no erasure could find (see * Events\ToolsEvents). WooCommerce orders stored as posts are logged as * their status changes (Events\WooCommerceEvents). */ const INTERNAL_POST_TYPES = array( 'revision', 'attachment', 'nav_menu_item', 'customize_changeset', 'oembed_cache', 'user_request', 'shop_order', 'shop_order_refund', 'shop_order_placehold', ); /** * How many role changes a summary lists before "and N more". */ const ROLE_CHANGES_IN_SUMMARY = 3; /** * How many characters an excerpt before/after value keeps in details. * Post content itself is never stored (see diff_post_fields()); an * excerpt is short-form text the site owner already wrote to be shown * elsewhere, so a bounded amount of it is not the same as a content copy. */ const EXCERPT_DETAIL_LENGTH = 200; /** * The id of the most recent non-autosave revision created for a given * parent post this request, captured from core's own * `_wp_put_post_revision` action -- fired exactly when a revision row is * inserted, autosave or not, regardless of WHEN that happens to occur: * core creates it synchronously during `post_updated` on WordPress before * 6.4, and during the later `wp_after_insert_post` on 6.4+ (moved there, * along with the revisioned-meta save, to fix meta being stale at * `post_updated` time -- see `wp_save_post_revision_on_insert()`). * log_post_update() runs on `wp_after_insert_post`, which fires after * BOTH possible timings, which is what makes reading this map there * correct on every supported core version. Read and cleared the moment * log_post_update() consumes it. * * @var array */ private static array $latest_revision_id = array(); /** * Log a generic event. * * @param string $event_type Event type identifier. * @param string $summary Human-readable event summary. * @param string|null $object_type Object type (post, plugin, theme, user, etc). * @param int|null $object_id Object ID if applicable. * @param array|null $details Additional details as JSON. * @param int|null $actor_user_id Who acted, when that is not the current user. */ public static function log( string $event_type, string $summary, ?string $object_type = null, ?int $object_id = null, ?array $details = null, ?int $actor_user_id = null ): void { $row = self::build_row( $event_type, $summary, $object_type, $object_id, $details, $actor_user_id ); if ( null === $row ) { return; } // The row goes in sealed (Chain\Sealer): it fixes the timestamp // itself so the digest covers it, and falls back to an unsealed row // rather than lose the event. $timestamp = gmdate( 'Y-m-d H:i:s' ); $id = Chain\Sealer::insert( $row, $timestamp ); if ( null === $id ) { return; } /** * An event has just been written to the log. * * The Pro add-on's forwarding seam. Fires once per stored row, after * the insert and only when it succeeded -- a refused insert leaves * `insert_id` at 0 rather than the previous row's id, so firing on * failure would forward an event that is not in the log. * * The payload is the stored row plus its new `id`, keyed exactly as * `ActivityLog::get_events()` returns it, so anything reading a row * from the query reads this identically. `ip_address` is the value * AS STORED -- truncated unless the site opted into full addresses -- * because that is the whole of what this plugin knows and a forwarder * must not be able to obtain more than the log itself holds. * * `timestamp` is the UTC clock value the row was sealed and stored * with (the chain's digest covers it), so it is the stored column * exactly, with no read-back SELECT. The plugin treats that column as * UTC everywhere it compares against it (`purge_old_events()`, * `ActivityLog::date_bound()`). * * Since #7 the row also carries the context columns -- * country, city, device_class, browser, os -- each null when unknown. * Additive: no existing key changed. * * @param array $event The stored row: id, timestamp, user_id, user_email, ip_address, event_type, object_type, object_id, summary, details, country, city, device_class, browser, os. */ $event = array_merge( array( 'id' => $id, 'timestamp' => $timestamp, ), $row ); // The network log's copy (#8), when the plugin is network-active. Network\NetworkLog::mirror_site_row( (int) $event['id'] ); // The free plugin's own high-risk alerts (#3) are invoked directly, // NOT on the action below: 'ops404_activity_log_event_recorded' is // free's published seam to the Pro add-on, and the // ProBootstrapTest canary pins that free leaves that seam silent -- // a free-side listener on it would blur exactly the boundary the // canary exists to keep legible. Same payload, both dispatches. \ActivityLog404\Alerts::on_event_recorded( $event ); do_action( 'ops404_activity_log_event_recorded', $event ); } /** * The `$wpdb->insert()` formats of a build_row() row, in its key order. * * @return array */ public static function row_formats(): array { return array( '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ); } /** * The row an event would be stored as, or null when it is not stored at * all (an excluded actor). Everything a row's columns are held to lives * here -- the actor, the exclusions, the address and its truncation, the * request context and the login flags -- so a network row (#8, * Network\NetworkLog::log()) is held to exactly the rules a site row is. * * @param string $event_type Event type identifier. * @param string $summary Human-readable event summary. * @param string|null $object_type Object type. * @param int|null $object_id Object ID if applicable. * @param array|null $details Additional details. * @param int|null $actor_user_id Who acted, when that is not the current user. * @return array|null Column => value, in row_formats() order. */ public static function build_row( string $event_type, string $summary, ?string $object_type = null, ?int $object_id = null, ?array $details = null, ?int $actor_user_id = null ): ?array { list( $user_id, $email ) = self::actor( $actor_user_id ); // The administrator's exclusions: never an unattributed event, and // never the record of a change to the exclusions themselves. if ( null !== $user_id && Exclusions::excludes( $user_id ) && ! Exclusions::is_change_to_exclusions( $event_type, $details ) ) { return null; } // The request context (#7). Geolocation reads the address BEFORE it // is truncated -- a truncated address is a different address, and // can sit in a different network -- and the untruncated address goes // no further than this line: only the stored (truncated unless the // site opted out) address and the location's answer are written. $client_ip = self::client_ip(); $context = RequestContext::capture( $client_ip ); // A login says whether its country or device is new for this user, // and the login-alerts markers (impossible travel, dormancy, an // anonymising network). The anonymity lookup reads the same // untruncated address, on logins only, and keeps only a boolean. if ( 'user_login' === $event_type && null !== $user_id ) { $anonymous = null !== $client_ip && null !== $context['country'] && Geolocator::is_anonymous( $client_ip ); $flags = LoginSignal::flags( $user_id, $context, $anonymous ); if ( array() !== $flags ) { $details = array_merge( $details ?? array(), $flags ); } } return array( 'user_id' => $user_id, 'user_email' => $email, 'ip_address' => self::stored_ip( $client_ip ), 'event_type' => $event_type, 'object_type' => $object_type, 'object_id' => $object_id, 'summary' => $summary, 'details' => $details ? wp_json_encode( $details ) : null, ) + $context; } /** * Who an event is attributed to: the current user, unless the caller * names the actor. Login and logout must name them, because core fires * wp_login before the current user is set and wp_logout after it has been * cleared, so the current user is nobody at both moments. * * @param int|null $actor_user_id Explicit actor, or null for the current user. * @return array{0: int|null, 1: string|null} User ID and email. */ private static function actor( ?int $actor_user_id ): array { if ( null !== $actor_user_id && $actor_user_id > 0 ) { $user = get_user_by( 'id', $actor_user_id ); $email = $user ? (string) $user->user_email : ''; return array( $actor_user_id, '' !== $email ? $email : null ); } // (string): for a logged-out visitor WP_User::__get() returns false, // which '' !== used to let through, storing '' instead of NULL. $user = wp_get_current_user(); $email = (string) $user->user_email; return array( $user->ID > 0 ? $user->ID : null, '' !== $email ? $email : null, ); } /** * The client IP address as the request presents it, untruncated, or * null. Never stored as it is: see stored_ip(). */ private static function client_ip(): ?string { return self::address_from( self::IP_SOURCES[ self::ip_source() ] ) ?? self::address_from( 'REMOTE_ADDR' ); } /** * The address as a row keeps it: truncated for anonymization unless the * "store full IPs" setting is on. A context lookup sees client_ip(); a * row sees this. * * @param string|null $ip The client address, untruncated. */ private static function stored_ip( ?string $ip ): ?string { if ( null === $ip ) { return null; } if ( self::should_store_full_ip() ) { return $ip; } $anonymized = IpAnonymizer::truncate( $ip ); return '' !== $anonymized ? $anonymized : null; } /** * The chosen IP source, or the direct connection for anything unknown. */ public static function ip_source(): string { $source = get_option( self::OPTION_IP_SOURCE, 'remote_addr' ); return is_string( $source ) && isset( self::IP_SOURCES[ $source ] ) ? $source : 'remote_addr'; } /** * One valid address from a $_SERVER field, or null. A forwarded chain * ("client, proxy1, proxy2") yields its left-most entry, the client the * first proxy saw. * * @param string $key $_SERVER key. */ private static function address_from( string $key ): ?string { if ( empty( $_SERVER[ $key ] ) || ! is_string( $_SERVER[ $key ] ) ) { return null; } $value = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ); if ( str_contains( $value, ',' ) ) { $value = trim( explode( ',', $value )[0] ); } return false !== filter_var( $value, FILTER_VALIDATE_IP ) ? $value : null; } /** * Whether the "store full IP addresses" opt-in setting is on. Off * (falsy/unset) by default, so anonymization is the out-of-the-box * behaviour. */ private static function should_store_full_ip(): bool { return (bool) get_option( self::OPTION_STORE_FULL_IPS, false ); } /** * Register all event hooks. */ public static function register_hooks(): void { // User login/auth events. add_action( 'wp_login', array( __CLASS__, 'log_user_login' ), 10, 2 ); add_action( 'wp_logout', array( __CLASS__, 'log_user_logout' ) ); add_action( 'wp_login_failed', array( __CLASS__, 'log_login_failed' ) ); add_action( 'user_register', array( __CLASS__, 'log_user_creation' ) ); add_action( 'delete_user', array( __CLASS__, 'log_user_deletion' ) ); add_action( 'set_user_role', array( __CLASS__, 'log_role_change' ), 10, 3 ); // Post/page events. add_action( 'wp_insert_post', array( __CLASS__, 'log_post_change' ), 10, 3 ); // Captures the id of a revision core creates, autosave or not, from // its own dedicated action -- see $latest_revision_id's docblock. add_action( '_wp_put_post_revision', array( __CLASS__, 'capture_revision_id' ), 10, 2 ); // What an edit changed (issue #2), logged once the update -- and, // when core created one, its revision -- both fully exist. Priority // 20: after core's own revision-saving listener, registered at // priority 9. See log_post_update()'s own docblock for why this // hangs off wp_after_insert_post rather than post_updated. add_action( 'wp_after_insert_post', array( __CLASS__, 'log_post_update' ), 20, 4 ); add_action( 'wp_trash_post', array( __CLASS__, 'log_post_trashed' ) ); add_action( 'untrashed_post', array( __CLASS__, 'log_post_restored' ) ); add_action( 'before_delete_post', array( __CLASS__, 'log_post_deletion' ), 10, 2 ); // Plugin events. add_action( 'activate_plugin', array( __CLASS__, 'log_plugin_activation' ), 10, 2 ); add_action( 'deactivate_plugin', array( __CLASS__, 'log_plugin_deactivation' ), 10, 2 ); add_action( 'switch_theme', array( __CLASS__, 'log_theme_switch' ) ); // Settings changes. foreach ( array_keys( self::WATCHED_SETTINGS ) as $option ) { add_action( 'update_option_' . $option, array( __CLASS__, 'log_settings_change' ), 10, 3 ); } // The exclusion lists themselves: a log that can be muted without // trace is not an audit log, so their first setting and every change // is recorded, whoever makes it. foreach ( array_keys( Exclusions::LABELS ) as $option ) { add_action( 'add_option_' . $option, array( __CLASS__, 'log_exclusions_first_set' ), 10, 2 ); add_action( 'update_option_' . $option, array( __CLASS__, 'log_settings_change' ), 10, 3 ); } // Roles and their capabilities, stored as one option per site. global $wpdb; add_action( 'update_option_' . $wpdb->prefix . 'user_roles', array( __CLASS__, 'log_roles_change' ), 10, 3 ); // Broader coverage (#5), one class per area; see each class's own // docblock for its hooks and what it deliberately does not store. Events\SoftwareEvents::register(); Events\AccountEvents::register(); Events\AppearanceEvents::register(); Events\MediaEvents::register(); Events\ToolsEvents::register(); Events\WooCommerceEvents::register(); // Forms, SEO, ACF, LMS, membership and page-builder plugins (#5), // each only while its plugin is active (Events\Integrations). Events\Integrations::register(); // The network's own events (#8), into the network log; each listener // is a no-op unless the plugin is network-active. Network\NetworkEvents::register(); } /** * Whether a post type is left out of the post events. See * INTERNAL_POST_TYPES. * * @param string $post_type A post type. */ public static function is_internal_post_type( string $post_type ): bool { // An active integration's own internal types too (an ACF field). return in_array( $post_type, self::INTERNAL_POST_TYPES, true ) || Events\Integrations::is_internal_post_type( $post_type ); } /** * Text cut to a length, for anything a row keeps that someone else chose * the length of (a file name, an application password's name). * * @param string $value Text. * @param int $limit Maximum characters kept. */ public static function bounded( string $value, int $limit ): string { return self::truncate( $value, $limit ); } /** * Log user login event, attributed to the user who logged in. * * @param string $user_login Username. * @param \WP_User|null $user The user who logged in (wp_login's second argument). */ public static function log_user_login( string $user_login, $user = null ): void { if ( ! $user instanceof \WP_User ) { $user = get_user_by( 'login', $user_login ); } $user_id = $user ? (int) $user->ID : null; self::log( 'user_login', sprintf( 'User %s logged in', $user_login ), 'user', $user_id, null, $user_id ); } /** * Log user logout event. * * Core's wp_logout() has passed the logged-out user's ID as the action's * first argument since WordPress 5.5; before that (and defensively, * still today) it fires only after wp_set_current_user( 0 ) has already * run, so wp_get_current_user() can never be used here to identify who * logged out — it would always report a logged-out user. Accepting the * argument directly (defaulting to 0 for the pre-5.5 no-argument case) * is the only reliable way to capture the acting user. A 0 id means no * real user was identified, so nothing is logged. * * @param int $user_id ID of the user that was logged out. */ public static function log_user_logout( int $user_id = 0 ): void { if ( $user_id <= 0 ) { return; } $user = get_user_by( 'id', $user_id ); self::log( 'user_logout', sprintf( 'User %s logged out', $user ? $user->user_login : (string) $user_id ), 'user', $user_id, null, $user_id ); } /** * Log failed login attempt. * * @param string $username Username attempted. */ public static function log_login_failed( string $username ): void { self::log( 'login_failed', sprintf( 'Failed login attempt for %s', $username ), 'user', null, array( 'attempted_login' => $username ) ); } /** * Log user creation. * * @param int $user_id User ID. */ public static function log_user_creation( int $user_id ): void { $user = get_user_by( 'id', $user_id ); self::log( 'user_created', sprintf( 'User %s created', $user ? $user->user_login : $user_id ), 'user', $user_id ); } /** * Log user deletion. * * @param int $user_id User ID. */ public static function log_user_deletion( int $user_id ): void { $user = get_user_by( 'id', $user_id ); self::log( 'user_deleted', sprintf( 'User %s deleted', $user ? $user->user_login : $user_id ), 'user', $user_id ); } /** * Log user role changes (fires on set_user_role). * * @param int $user_id User ID. * @param string $role New role. * @param string[] $old_roles Previous roles. */ public static function log_role_change( int $user_id, string $role, array $old_roles ): void { if ( array() === $old_roles ) { // Initial role assignment during user creation, not a change. return; } if ( in_array( $role, $old_roles, true ) && 1 === count( $old_roles ) ) { // Role re-assigned to the same single role: no effective change. return; } $user = get_user_by( 'id', $user_id ); if ( ! $user ) { return; } self::log( 'user_role_changed', sprintf( 'User %s role changed to %s', $user->user_login, $role ), 'user', $user_id, array( 'old_roles' => $old_roles, 'new_role' => $role, ) ); } /** * Log post creation. Updates are logged separately, by log_post_update() * on `wp_after_insert_post` -- see that method's own docblock for why an * update can't be logged from here, on `wp_insert_post`, any more. * * @param int $post_id Post ID. * @param \WP_Post $post Post object. * @param bool $update Whether this is an update. */ public static function log_post_change( int $post_id, \WP_Post $post, bool $update ): void { if ( self::is_internal_post_type( $post->post_type ) ) { return; } if ( $update ) { return; } // A post type an integration owns (a form, an ACF field group) is // logged as that object, once per request (Events\Integrations\PostChanges). if ( Events\Integrations\PostChanges::created( $post ) ) { return; } self::log( 'post_created', sprintf( '%s "%s" created', ucfirst( $post->post_type ), $post->post_title ), $post->post_type, $post_id ); } /** * Captures the id of a freshly-created, non-autosave revision, keyed by * its parent post -- see $latest_revision_id's docblock for why. * `_wp_put_post_revision` fires for both autosaves and real revisions * through this one action, distinguished only by wp_is_post_autosave(). * * The $post_id parameter was added to this action in WordPress 6.4; on * an older core only $revision_id arrives, and the parent post id is * read off the revision post itself instead. * * @param int $revision_id The newly-created revision's post id. * @param int|null $post_id Its parent post's id (WordPress 6.4+), or null on an older core. */ public static function capture_revision_id( int $revision_id, ?int $post_id = null ): void { if ( wp_is_post_autosave( $revision_id ) ) { return; } if ( null === $post_id ) { $revision = get_post( $revision_id ); $post_id = $revision ? (int) $revision->post_parent : 0; } if ( $post_id > 0 ) { self::$latest_revision_id[ $post_id ] = $revision_id; } } /** * Logs what an edit changed (issue #2), once the update -- and, when * core created one, its revision -- both fully exist. * * Hung off `wp_after_insert_post` rather than `wp_insert_post` / * `post_updated`: since WordPress 6.4, core itself defers revision * creation from `post_updated` to `wp_after_insert_post` * (`wp_save_post_revision_on_insert()`, added to fix revisioned meta * being stale at the older, earlier point) -- so on 6.4+ the revision * this update creates does not exist yet at `post_updated` time, only * once `wp_after_insert_post` runs. On an older core the revision is * still created synchronously during `post_updated`, earlier still. * `wp_after_insert_post` fires after BOTH possible timings, which is * what makes reading $latest_revision_id here correct on every * supported core version. Priority 20 (see register_hooks()) keeps this * after core's own revision-saving listener on the same hook. * * @param int $post_id Post ID. * @param \WP_Post $post_after Post object following the update. * @param bool $update Whether this was an update to an existing post. * @param \WP_Post|null $post_before Post object before the update, or null for a new post. */ public static function log_post_update( int $post_id, \WP_Post $post_after, bool $update, ?\WP_Post $post_before ): void { if ( ! $update || null === $post_before ) { return; } if ( self::is_internal_post_type( $post_after->post_type ) ) { return; } $diff = self::diff_post_fields( $post_before, $post_after ); $revision_id = self::$latest_revision_id[ $post_id ] ?? null; unset( self::$latest_revision_id[ $post_id ] ); if ( isset( $diff['content'] ) && null !== $revision_id ) { $diff['content']['revision_id'] = $revision_id; } // An owned post type is coalesced into one row per request, and a // claimed save (Elementor's editor) is written by its claimant // (Events\Integrations\PostChanges). if ( Events\Integrations\PostChanges::updated( $post_after, $diff ) ) { return; } // SEO fields or custom field values saved with the post (#5 // integrations): with the post's own changes they join this row; // alone, this row would say nothing and they write their own. if ( Events\Integrations\PostChanges::has_extra( $post_id ) ) { if ( array() === $diff ) { return; } $diff += Events\Integrations\PostChanges::take_extra( $post_id ); } // A product save from its edit screen is a post save AND a // WooCommerce save; when the latter already wrote the price/stock row // and the post itself changed nothing tracked, this row would only // repeat it (#5, Events\WooCommerceEvents). if ( array() === $diff && Events\WooCommerceEvents::was_logged_this_request( $post_id ) ) { return; } self::log( 'post_updated', sprintf( '%s "%s" updated', ucfirst( $post_after->post_type ), $post_after->post_title ), $post_after->post_type, $post_id, array() !== $diff ? $diff : null ); } /** * Which of the tracked fields differ between the post's two states, and * how. Content is never stored (see Product invariants / issue #2): * only a before/after character count, so what changed is auditable * without a copy of what changed to. * * @param \WP_Post $before Post object before the update. * @param \WP_Post $after Post object following the update. * @return array Empty when nothing tracked changed. */ private static function diff_post_fields( \WP_Post $before, \WP_Post $after ): array { $diff = array(); if ( $before->post_title !== $after->post_title ) { $diff['title'] = array( 'before' => $before->post_title, 'after' => $after->post_title, ); } if ( $before->post_status !== $after->post_status ) { $diff['status'] = array( 'before' => $before->post_status, 'after' => $after->post_status, ); } if ( $before->post_name !== $after->post_name ) { $diff['slug'] = array( 'before' => $before->post_name, 'after' => $after->post_name, ); } if ( (int) $before->post_author !== (int) $after->post_author ) { $diff['author'] = array( 'before' => (int) $before->post_author, 'after' => (int) $after->post_author, ); } if ( $before->post_excerpt !== $after->post_excerpt ) { $diff['excerpt'] = array( 'before' => self::truncate( (string) $before->post_excerpt, self::EXCERPT_DETAIL_LENGTH ), 'after' => self::truncate( (string) $after->post_excerpt, self::EXCERPT_DETAIL_LENGTH ), ); } if ( (int) $before->menu_order !== (int) $after->menu_order ) { $diff['menu_order'] = array( 'before' => (int) $before->menu_order, 'after' => (int) $after->menu_order, ); } if ( (int) $before->post_parent !== (int) $after->post_parent ) { $diff['parent'] = array( 'before' => (int) $before->post_parent, 'after' => (int) $after->post_parent, ); } if ( $before->post_content !== $after->post_content ) { $diff['content'] = array( 'characters_before' => self::character_count( (string) $before->post_content ), 'characters_after' => self::character_count( (string) $after->post_content ), ); } return $diff; } /** * @param string $value Text to shorten. * @param int $limit Maximum characters kept. */ private static function truncate( string $value, int $limit ): string { if ( function_exists( 'mb_substr' ) ) { return mb_substr( $value, 0, $limit ); } return substr( $value, 0, $limit ); } /** * @param string $value Text to measure. */ private static function character_count( string $value ): int { return function_exists( 'mb_strlen' ) ? mb_strlen( $value ) : strlen( $value ); } /** * Log a post/page moved to trash. Distinct from post_updated: WordPress * core's wp_trash_post() also internally calls wp_insert_post() to flip * the status, which still fires log_post_change() as a generic * post_updated entry — that incidental duplicate is intentionally left * alone (see this method's registration site / the commit message for * why), but a reader scanning for "who trashed this" now has an * unambiguous post_trashed entry to find. * * @param int $post_id Post ID. */ public static function log_post_trashed( int $post_id ): void { $post = get_post( $post_id ); if ( ! $post || self::is_internal_post_type( $post->post_type ) ) { return; } if ( Events\Integrations\PostChanges::ended( 'trashed', $post ) ) { return; } self::log( 'post_trashed', sprintf( '%s "%s" moved to trash', ucfirst( $post->post_type ), $post->post_title ), $post->post_type, $post_id ); } /** * Log a post/page restored from trash. * * @param int $post_id Post ID. */ public static function log_post_restored( int $post_id ): void { $post = get_post( $post_id ); if ( ! $post || self::is_internal_post_type( $post->post_type ) ) { return; } if ( Events\Integrations\PostChanges::ended( 'restored', $post ) ) { return; } self::log( 'post_restored', sprintf( '%s "%s" restored from trash', ucfirst( $post->post_type ), $post->post_title ), $post->post_type, $post_id ); } /** * Log permanent post deletion (a direct delete on a post type that * doesn't support trash, or emptying an already-trashed post). * * Hooked to before_delete_post rather than the post-id-only delete_post * this used before: before_delete_post fires with the full $post object * already resolved, so there's no separate get_post( $post_id ) call * here that could in principle run right as the row is being removed. * * @param int $post_id Post ID. * @param \WP_Post $post Post object. */ public static function log_post_deletion( int $post_id, \WP_Post $post ): void { if ( self::is_internal_post_type( $post->post_type ) ) { return; } if ( Events\Integrations\PostChanges::ended( 'deleted', $post ) ) { return; } self::log( 'post_deleted', sprintf( '%s "%s" deleted', ucfirst( $post->post_type ), $post->post_title ), $post->post_type, $post_id ); } /** * Log plugin activation. * * A NETWORK activation (#8) is an event of the network, not of the site * whose request carried it: with the network log in use it is written * there, once, as `plugin_activated` with `network_wide: true` -- and no * longer into the main site's own log, where it used to land because the * network admin runs on the main site. Without the network log (the * plugin activated site by site) nothing changes. * * @param string $plugin Plugin path. * @param bool $network_wide Whether it was activated for the whole network. */ public static function log_plugin_activation( string $plugin, $network_wide = false ): void { if ( true === (bool) $network_wide && Network\NetworkLog::is_active() ) { Network\NetworkLog::log( 'plugin_activated', sprintf( 'Plugin "%s" network activated', self::plugin_display_name( $plugin ) ), 'plugin', null, array( 'plugin' => $plugin, 'network_wide' => true, ) ); return; } self::log( 'plugin_activated', sprintf( 'Plugin "%s" activated', self::plugin_display_name( $plugin ) ), 'plugin', null, array( 'plugin' => $plugin ) ); } /** * Log plugin deactivation. A network deactivation goes to the network * log, as log_plugin_activation() explains. * * @param string $plugin Plugin path. * @param bool $network_wide Whether it was deactivated for the whole network. */ public static function log_plugin_deactivation( string $plugin, $network_wide = false ): void { if ( true === (bool) $network_wide && Network\NetworkLog::is_active() ) { Network\NetworkLog::log( 'plugin_deactivated', sprintf( 'Plugin "%s" network deactivated', self::plugin_display_name( $plugin ) ), 'plugin', null, array( 'plugin' => $plugin, 'network_wide' => true, ) ); return; } self::log( 'plugin_deactivated', sprintf( 'Plugin "%s" deactivated', self::plugin_display_name( $plugin ) ), 'plugin', null, array( 'plugin' => $plugin ) ); } /** * Resolve a plugin file path to its display name, falling back to the path. * * @param string $plugin Plugin path relative to the plugins directory. */ private static function plugin_display_name( string $plugin ): string { if ( ! function_exists( 'get_plugin_data' ) ) { return $plugin; } $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin ); return '' !== $plugin_data['Name'] ? $plugin_data['Name'] : $plugin; } /** * Log theme switch. * * @param string $new_name New theme name. */ public static function log_theme_switch( string $new_name ): void { $theme = wp_get_theme( $new_name ); self::log( 'theme_switched', sprintf( 'Theme switched to "%s"', $theme->get( 'Name' ) ), 'theme', null, array( 'theme' => $new_name ) ); } /** * Log an exclusion list being stored for the first time, which core * reports through add_option_{$option} rather than update_option_. * * @param string $option Option name. * @param mixed $value What is stored. */ public static function log_exclusions_first_set( string $option, $value ): void { // Saving the form with nothing excluded creates the option empty; // that is not a change worth a row. if ( array() === $value ) { return; } self::log_settings_change( array(), $value, $option ); } /** * Log a change to a watched setting, or to one of the exclusion lists. * * @param mixed $old_value What was stored. * @param mixed $value What is stored now. * @param string $option Option name. */ public static function log_settings_change( $old_value, $value, string $option ): void { if ( isset( self::WATCHED_SETTINGS[ $option ] ) && self::same_setting_value( $option, $old_value, $value ) ) { // Core stores what it was handed; '10' over 10, or '0' over an // unchecked '', is a write but not a change. return; } $withheld = self::is_secret_setting( $option ); $label = self::WATCHED_SETTINGS[ $option ] ?? Exclusions::LABELS[ $option ] ?? $option; $summary = isset( self::WATCHED_SETTINGS[ $option ] ) && ! $withheld && ! in_array( $option, self::SETTINGS_SUMMARISED_WITHOUT_VALUES, true ) ? sprintf( 'Setting "%s" changed from %s to %s', $label, self::setting_value( $option, $old_value ), self::setting_value( $option, $value ) ) : sprintf( 'Setting "%s" updated', $label ); self::log( 'setting_changed', $summary, 'setting', null, $withheld ? array( 'option' => $option, 'values_withheld' => true, ) : array( 'option' => $option, 'old_value' => $old_value, 'new_value' => $value, ) ); } /** * Whether a setting's values must never be stored: listed in * SETTINGS_WITHOUT_VALUES, or named like a credential. The name test is * the backstop, not the list -- see SETTINGS_WITHOUT_VALUES. * * @param string $option Option name. */ public static function is_secret_setting( string $option ): bool { return in_array( $option, self::SETTINGS_WITHOUT_VALUES, true ) || 1 === preg_match( '/pass(word|wd)?($|_)|secret|token|salt|credential|(^|_)(api|auth|private|license|licence)_?keys?($|_)|(^|_)keys?$/i', $option ); } /** * Whether two stored values of a watched setting are the same setting. * * @param string $option Option name. * @param mixed $old_value Before. * @param mixed $value After. */ private static function same_setting_value( string $option, $old_value, $value ): bool { $scalar = static fn( $v ): bool => null === $v || is_scalar( $v ); if ( ! $scalar( $old_value ) || ! $scalar( $value ) ) { return $old_value === $value; } if ( in_array( $option, self::ON_OFF_SETTINGS, true ) ) { return ( '1' === (string) $old_value ) === ( '1' === (string) $value ); } return (string) $old_value === (string) $value; } /** * A setting's value as the log's summary shows it: what the Settings * screens call it where that differs from what is stored. * * @param string $option Option name. * @param mixed $value Stored value. */ private static function setting_value( string $option, $value ): string { $value = is_scalar( $value ) ? (string) $value : ''; if ( in_array( $option, self::ON_OFF_SETTINGS, true ) ) { return '1' === $value ? 'on' : 'off'; } switch ( $option ) { case 'blog_public': return '0' === $value ? '"discourage search engines"' : '"allow search engines"'; case 'permalink_structure': if ( '' === $value ) { return '"Plain"'; } break; } if ( function_exists( 'mb_strlen' ) && mb_strlen( $value ) > 80 ) { $value = mb_substr( $value, 0, 79 ) . '…'; } return '"' . $value . '"'; } /** * Log a change to the site's roles or what they may do. * * Plugins add and adjust roles when they are activated, so these are * logged as the change itself ("subscriber gained manage_options"), * never as the whole roles table before and after. * * @param mixed $old_value Roles before. * @param mixed $value Roles after. * @param string $option Option name (prefix + "user_roles"). */ public static function log_roles_change( $old_value, $value, string $option ): void { $changes = self::role_changes( is_array( $old_value ) ? $old_value : array(), is_array( $value ) ? $value : array() ); if ( array() === $changes ) { return; } $shown = array_slice( $changes, 0, self::ROLE_CHANGES_IN_SUMMARY ); $summary = 'Roles changed: ' . implode( '; ', $shown ); if ( count( $changes ) > count( $shown ) ) { $summary .= sprintf( '; and %d more', count( $changes ) - count( $shown ) ); } self::log( 'setting_changed', $summary, 'setting', null, array( 'option' => $option, 'changes' => $changes, ) ); } /** * What changed between two roles tables: roles added or removed, and * capabilities each remaining role gained or lost. A capability counts * as held only when it is granted (true); one stored as false is denied. * * @param array $before Roles before. * @param array $after Roles after. * @return string[] */ private static function role_changes( array $before, array $after ): array { $changes = array(); foreach ( array_diff( array_keys( $after ), array_keys( $before ) ) as $role ) { $changes[] = sprintf( 'role "%s" added', $role ); } foreach ( array_diff( array_keys( $before ), array_keys( $after ) ) as $role ) { $changes[] = sprintf( 'role "%s" removed', $role ); } foreach ( array_intersect( array_keys( $before ), array_keys( $after ) ) as $role ) { $held_before = self::granted_capabilities( $before[ $role ] ); $held_after = self::granted_capabilities( $after[ $role ] ); $gained = array_values( array_diff( $held_after, $held_before ) ); $lost = array_values( array_diff( $held_before, $held_after ) ); if ( array() !== $gained ) { $changes[] = sprintf( '%s gained %s', $role, implode( ', ', $gained ) ); } if ( array() !== $lost ) { $changes[] = sprintf( '%s lost %s', $role, implode( ', ', $lost ) ); } } return $changes; } /** * @param mixed $role One role's entry in the roles table. * @return string[] The capabilities it grants. */ private static function granted_capabilities( $role ): array { $capabilities = is_array( $role ) && isset( $role['capabilities'] ) && is_array( $role['capabilities'] ) ? $role['capabilities'] : array(); return array_keys( array_filter( $capabilities ) ); } }