base_prefix . 'logged_ai_events'; } /** * Format the current UTC instant with six fractional-second digits. * * @return string Canonical UTC timestamp used for activity storage. */ public static function now(): string { return ( new \DateTimeImmutable( 'now', new \DateTimeZone( 'UTC' ) ) )->format( 'Y-m-d H:i:s.u' ); } /** * Install and verify the activity table while holding the schema mutex. * * @return void */ public static function activate(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $lock = 'logged_ai_schema_' . substr( hash( 'sha256', DB_NAME . $table ), 0, 32 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema serialization requires a live connection lock; a cached result cannot acquire it. if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 5)', $lock ) ) ) { wp_die( esc_html__( 'Logged.ai schema setup is busy. Retry activation.', 'logged-ai' ) ); } try { dbDelta( "CREATE TABLE {$table} (\n id bigint unsigned NOT NULL AUTO_INCREMENT,\n uid char(36) NOT NULL,\n schema_version smallint unsigned NOT NULL,\n network_id bigint unsigned NOT NULL,\n site_id bigint unsigned NOT NULL,\n occurred_at datetime(6) NOT NULL,\n ingested_at datetime(6) NOT NULL,\n code varchar(80) NOT NULL,\n category varchar(32) NOT NULL,\n action varchar(32) NOT NULL,\n outcome varchar(24) NOT NULL,\n severity varchar(16) NOT NULL,\n actor_kind varchar(24) NOT NULL,\n actor_id bigint unsigned NULL,\n subject_id bigint unsigned NULL,\n object_type varchar(24) NOT NULL,\n object_id bigint unsigned NOT NULL,\n channel varchar(24) NOT NULL,\n request_id char(36) NOT NULL,\n operation_id char(36) NOT NULL,\n processing_state varchar(16) NOT NULL DEFAULT 'pending',\n alert_revision bigint unsigned NOT NULL DEFAULT 0,\n alert_state varchar(16) NOT NULL DEFAULT 'skipped',\n payload longtext NOT NULL,\n PRIMARY KEY (id),\n UNIQUE KEY uid (uid),\n KEY site_time (network_id,site_id,occurred_at,id),\n KEY actor_time (network_id,site_id,actor_id,occurred_at,id),\n KEY code_time (network_id,site_id,code,occurred_at,id),\n KEY object_time (network_id,site_id,object_type,object_id,occurred_at,id),\n KEY request_scope (network_id,site_id,request_id),\n KEY alerts_pending (network_id,site_id,alert_state,id),\n KEY processing (processing_state,id)\n ) ENGINE=InnoDB " . $wpdb->get_charset_collate() . ';' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Verify actual event columns after dbDelta; cached metadata cannot establish schema readiness. Store::table() is the native validated base_prefix plus literal logged_ai_events; this SHOW statement has no caller-supplied identifier or value. $columns = $wpdb->get_col( $wpdb->prepare( "SHOW COLUMNS FROM %i", $table ) ); $required = array( 'id', 'uid', 'schema_version', 'network_id', 'site_id', 'occurred_at', 'ingested_at', 'code', 'category', 'action', 'outcome', 'severity', 'actor_kind', 'actor_id', 'subject_id', 'object_type', 'object_id', 'channel', 'request_id', 'operation_id', 'processing_state', 'payload' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify actual event storage engine after dbDelta; cached metadata cannot establish readiness. $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); if ( array_diff( array_merge( $required, array( 'alert_revision', 'alert_state' ) ), $columns ) || 'InnoDB' !== $engine ) { self::degraded( 'schema_verification_failed' ); wp_die( esc_html__( 'Logged.ai could not verify its schema. Activation did not complete.', 'logged-ai' ) ); } update_option( 'logged_ai_schema_version', 1, false ); } finally { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release the held schema connection lock; a cached result cannot release it. $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } /** * Record only an allowlisted collection-degradation code and current UTC time. * * @param string $code Allowlisted degradation code; unsupported values are ignored. * * @return void */ public static function degraded( string $code ): void { // Fixed codes only; never copy SQL/errors/attacker input to the diagnostic sink. $allowed = array( 'schema_verification_failed', 'event_write_failed', 'role_context_incomplete', 'role_payload_rejected', 'query_failed', 'event_payload_rejected', 'privacy_failed', 'retention_failed', 'settings_invalid', 'collector_context_incomplete', 'export_failed', 'alert_failed', 'extension_failed' ); if ( in_array( $code, $allowed, true ) ) { update_option( 'logged_ai_collection_health', array( 'state' => 'degraded', 'reason' => $code, 'time_utc' => self::now(), ), false ); } } /** * Reuse one generated correlation UUID throughout the current PHP request. * * @return string Request-local correlation UUID. */ public static function request_id(): string { static $id = null; $id ??= wp_generate_uuid4(); return $id; } /** * Select the supported native execution channel with explicit precedence. * * @return string CLI, cron, REST, XML-RPC, administration or frontend channel. */ public static function channel(): string { if ( defined( 'WP_CLI' ) && WP_CLI ) { return 'cli'; } if ( wp_doing_cron() ) { return 'cron'; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return 'rest'; } if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { return 'xmlrpc'; } return is_admin() ? 'wp_admin' : 'frontend'; } /** * Capture the current native user identifier and execution channel. * * @return array Actor identifier and supported channel for the current operation. */ public static function actor_context(): array { return array( 'id' => get_current_user_id(), 'channel' => self::channel(), ); } /** * Combine the fixed loaded collector schemas and registered extension definitions. * * Definitions are shipping code, never a user-provided payload schema. * * @return array Allowlisted event definitions indexed by their event code. */ public static function definitions(): array { $definitions = array( 'identity.roles_changed' => array( 'category' => 'identity', 'action' => 'roles_changed', 'severity' => 'notice', 'object_type' => 'user', 'fields' => array(), 'diffs' => array( 'roles' => array( 'list' => 'identifier' ) ), ), 'privacy.retention_pruned' => array( 'category' => 'privacy', 'action' => 'retention_pruned', 'severity' => 'notice', 'object_type' => 'site', 'fields' => array( 'removed_count' => 'count', 'retention_days' => 'count', ), 'diffs' => array(), ), 'privacy.identity_erased' => array( 'category' => 'privacy', 'action' => 'identity_erased', 'severity' => 'notice', 'object_type' => 'site', 'fields' => array( 'removed_count' => 'count' ), 'diffs' => array(), ), 'configuration.uninstall_choice_changed' => array( 'category' => 'configuration', 'action' => 'uninstall_choice_changed', 'severity' => 'warning', 'object_type' => 'site', 'fields' => array( 'enabled' => 'bool' ), 'diffs' => array(), ), 'configuration.privacy_changed' => array( 'category' => 'configuration', 'action' => 'privacy_changed', 'severity' => 'notice', 'object_type' => 'site', 'fields' => array( 'changed_fields' => array( 'list' => array( 'event_days', 'request_days', 'request_group_days', 'policy_history_days', 'exact_ip_days', 'diagnostic_days', 'ip_mode' ) ) ), 'diffs' => array(), ), ); if ( class_exists( CoreCollectors::class ) ) { $definitions += CoreCollectors::definitions(); } if ( class_exists( ContentCollectors::class ) ) { $definitions += ContentCollectors::definitions(); } if ( class_exists( AuthCollectors::class ) ) { $definitions += AuthCollectors::definitions(); } if ( class_exists( AccountAuthCollectors::class ) ) { $definitions += AccountAuthCollectors::definitions(); } if ( class_exists( RegistrationCollectors::class ) ) { $definitions += RegistrationCollectors::definitions(); } if ( class_exists( WooCollectors::class ) ) { $definitions += WooCollectors::definitions(); } if ( class_exists( Exports::class ) ) { $definitions += Exports::definitions(); } if ( class_exists( Alerts::class ) ) { $definitions += Alerts::definitions(); } if ( class_exists( ExtensionCollectors::class ) ) { $definitions += ExtensionCollectors::definitions(); } if ( class_exists( Health::class ) ) { $definitions += Health::definitions(); } if ( class_exists( NetworkPrivacy::class ) ) { $definitions += NetworkPrivacy::definitions(); } if ( class_exists( MultisiteCollectors::class ) ) { $definitions += MultisiteCollectors::definitions(); } if ( class_exists( Collection::class ) ) { $definitions += Collection::definitions(); } if ( class_exists( RequestsSource::class ) ) { $definitions += RequestsSource::definitions(); } if ( class_exists( EventAPI::class ) ) { $definitions += EventAPI::definitions(); } return $definitions; } /** * Validate event-code grammar and the bounded storage-facing definition fields. * * Match the actual storage widths before any collector can attempt an insert. * * @param string $code Fixed event or operation code selected by installed code. * @param array $definition Shipping or registered event schema metadata to validate. * * @return bool Whether the definition fits the supported metadata and field-count bounds. */ public static function definition_valid( string $code, array $definition ): bool { if ( ! preg_match( '/\A[a-z][a-z0-9_.]{1,79}\z/D', $code ) ) { return false; } foreach ( array( 'category' => 32, 'action' => 32, 'object_type' => 24, ) as $key => $width ) { if ( ! is_string( $definition[ $key ] ?? null ) || strlen( $definition[ $key ] ) > $width || ! preg_match( '/\A[a-z][a-z0-9_]*\z/D', $definition[ $key ] ) ) { return false; } } return in_array( $definition['severity'] ?? null, array( 'info', 'notice', 'warning', 'high', 'critical' ), true ) && is_array( $definition['fields'] ?? null ) && is_array( $definition['diffs'] ?? null ) && count( $definition['fields'] ) <= 32 && count( $definition['diffs'] ) <= 32; } /** * Check a minimized value against a supported scalar, enum or bounded-list descriptor. * * @param mixed $value Supplied native value to validate, persist or render for this operation. * @param mixed $type Supported scalar, enum or bounded-list descriptor from the event schema. * * @return bool Whether the value satisfies the supplied finite schema descriptor. */ private static function value_valid( mixed $value, mixed $type ): bool { if ( is_array( $type ) && isset( $type['list'] ) ) { if ( ! is_array( $value ) || ! array_is_list( $value ) || count( $value ) > 32 ) { return false; } foreach ( $value as $item ) { if ( ! self::value_valid( $item, $type['list'] ) ) { return false; } } return true; } if ( is_array( $type ) ) { return is_string( $value ) && in_array( $value, $type, true ); } return match ( $type ) { 'redacted' => null === $value, 'bool' => is_bool( $value ), 'id' => ( is_int( $value ) && $value >= 0 ) || ( is_string( $value ) && 1 === preg_match( '/\A[0-9]{1,19}\z/D', $value ) ), 'count' => is_int( $value ) && $value >= 0 && $value <= PHP_INT_MAX, 'decimal' => is_string( $value ) && strlen( $value ) <= 24 && 1 === preg_match( '/\A-?(?:0|[1-9][0-9]{0,14})(?:\.[0-9]{1,8})?\z/D', $value ), 'currency' => is_string( $value ) && 1 === preg_match( '/\A[A-Z]{3}\z/D', $value ), 'identifier' => is_string( $value ) && 1 === preg_match( '/\A[a-zA-Z0-9_.:\/-]{1,160}\z/D', $value ) && ! str_contains( $value, '..' ), default => false, }; } /** * Prepare and synchronously insert one supported minimized activity event. * * Synchronous, bounded, allowlisted evidence only. Never owns a caller transaction. * * @param array $event Collector-supplied activity fields subject to the fixed event schema. * * @return bool Whether preparation and insertion succeeded on the caller connection. */ public static function capture( array $event ): bool { $data = self::prepare_event( $event ); return false !== $data && self::insert_prepared( $data ); } /** * Validate collector input and construct bounded minimized storage fields before insertion. * * Validate and minimize on the native caller connection before a private writer inserts. * * @param array $event Collector-supplied activity fields subject to the fixed event schema. * * @return array|false Prepared event fields, or false for excluded, unavailable or rejected input. */ public static function prepare_event( array $event ): array|false { if ( ( class_exists( EventAPI::class ) && EventAPI::dispatching() ) || ( class_exists( Migrations::class ) && ! Migrations::ready( 'events' ) ) ) { return false; } global $wpdb; $custom = is_string( $event['code'] ?? null ) && isset( EventAPI::definitions()[ $event['code'] ] ); if ( $custom ) { $event['actor_context'] = self::actor_context(); $event['outcome'] = 'observed'; } $definition = self::definitions()[ $event['code'] ?? '' ] ?? null; if ( $definition && class_exists( Collection::class ) && ! Collection::allows( $definition['category'] ) ) { return false; } $context = $event['actor_context'] ?? self::actor_context(); $payload = $event['payload'] ?? array(); if ( $custom && is_array( $payload ) ) { $payload['confidence'] = 'observed'; $payload['observed_via'] = 'custom_extension_v1'; } $reject = static function () { self::degraded( 'event_payload_rejected' ); return false; }; if ( ! $definition || ! self::definition_valid( $event['code'], $definition ) || ! is_array( $context ) || ! is_int( $context['id'] ?? null ) || $context['id'] < 0 || ! in_array( $context['channel'] ?? '', array( 'cli', 'cron', 'rest', 'xmlrpc', 'wp_admin', 'frontend' ), true ) || ! is_array( $payload ) ) { return $reject(); } if ( array_diff( array_keys( $payload ), array( 'facts', 'diffs', 'confidence', 'observed_via', 'before_consistency' ) ) || ! in_array( $event['outcome'] ?? '', array( 'stored_change', 'completed', 'failed', 'attempted', 'observed' ), true ) ) { return $reject(); } if ( array_key_exists( 'logical_uid', $event ) && ( 'identity.account_auth_failed_burst' !== $event['code'] || ! is_string( $event['logical_uid'] ) || 1 !== preg_match( '/\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/D', $event['logical_uid'] ) ) ) { return $reject(); } $facts = $payload['facts'] ?? array(); if ( ! is_array( $facts ) || count( $facts ) > 32 ) { return $reject(); } foreach ( $facts as $key => $value ) { if ( ! isset( $definition['fields'][ $key ] ) || ! self::value_valid( $value, $definition['fields'][ $key ] ) ) { return $reject(); } } $diffs = $payload['diffs'] ?? array(); if ( ! is_array( $diffs ) || count( $diffs ) > 32 ) { return $reject(); } $clean_diffs = array(); foreach ( $diffs as $diff ) { if ( ! is_array( $diff ) || ! is_string( $diff['field'] ?? null ) || ! isset( $definition['diffs'][ $diff['field'] ] ) || ! array_key_exists( 'before', $diff ) || ! array_key_exists( 'after', $diff ) ) { return $reject(); } $availability = $diff['availability'] ?? 'captured'; if ( ! in_array( $availability, array( 'captured', 'redacted', 'before_unavailable', 'after_unavailable' ), true ) ) { return $reject(); } $type = $definition['diffs'][ $diff['field'] ]; foreach ( array( 'before', 'after' ) as $side ) { $absent = 'redacted' === $availability || $side . '_unavailable' === $availability; if ( $absent ? null !== $diff[ $side ] : ! self::value_valid( $diff[ $side ], $type ) ) { return $reject(); } } $clean_diffs[] = array( 'field' => $diff['field'], 'type' => is_array( $type ) && isset( $type['list'] ) ? 'string_list' : 'scalar', 'before' => $diff['before'], 'after' => $diff['after'], 'availability' => $availability, 'redacted' => 'redacted' === $availability, ); } $confidence = $payload['confidence'] ?? 'successful_post_hook'; if ( ! in_array( $confidence, array( 'observed', 'successful_post_hook', 'successful_write_with_prewrite_snapshot' ), true ) || ! self::value_valid( $payload['observed_via'] ?? '', 'identifier' ) ) { return $reject(); } $actor = $context['id']; $subject = $event['subject_id'] ?? null; $object = $event['object_id'] ?? 0; if ( ( null !== $subject && ( ! is_int( $subject ) || $subject < 1 ) ) || ! is_int( $object ) || $object < 0 ) { return $reject(); } $kind = $actor ? 'authenticated_user' : ( 'cron' === $context['channel'] ? 'system' : 'unknown' ); $network_event = is_multisite() && 'network' === ( $definition['event_scope'] ?? 'site' ); $clean = array( 'event_scope' => $network_event ? 'network' : 'site', 'source' => defined( 'LOGGED_AI_SYNTHETIC_TEST_ENVIRONMENT' ) && true === LOGGED_AI_SYNTHETIC_TEST_ENVIRONMENT ? 'synthetic_test' : 'wordpress', 'actor_label' => $actor ? 'User #' . $actor : ( 'system' === $kind ? 'WordPress cron' : 'Unknown actor' ), 'subject_label' => $subject ? 'User #' . $subject : 'Not applicable', 'facts' => $facts, 'diffs' => $clean_diffs, 'collector' => $custom ? 'custom_extension_v1' : ( 'commerce' === $definition['category'] ? 'woocommerce_crud_v1' : 'core_allowlisted_v1' ), 'observed_via' => $payload['observed_via'], 'confidence' => $confidence, 'ip_context' => array( 'mode' => 'unavailable', 'reason' => 'not_collected_by_this_collector', ), 'incomplete_context' => false, 'truncated' => false, ); if ( $custom ) { $clean['extension'] = array( 'contract_version' => EventAPI::VERSION, 'description' => $definition['description'], 'explanation' => $definition['explanation'], 'provenance' => 'installed_extension_assertion_not_independently_verified', ); } if ( isset( $payload['before_consistency'] ) ) { if ( ! in_array( $payload['before_consistency'], array( 'prewrite_snapshot_not_serializable_against_concurrent_writes', 'hook_supplied_snapshot_not_serializable_against_concurrent_writes', 'not_applicable' ), true ) ) { return $reject(); } $clean['before_consistency'] = $payload['before_consistency']; } if ( class_exists( Collection::class ) ) { $clean['maintenance'] = Collection::status()['maintenance']; } $json = wp_json_encode( $clean, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ( false === $json || strlen( $json ) > 16384 ) { return $reject(); } $data = array( 'uid' => $event['logical_uid'] ?? wp_generate_uuid4(), 'schema_version' => 1, 'network_id' => Scope::network(), 'site_id' => get_current_blog_id(), 'occurred_at' => self::now(), 'ingested_at' => self::now(), 'code' => $event['code'], 'category' => $definition['category'], 'action' => $definition['action'], 'severity' => $definition['severity'], 'outcome' => $event['outcome'], 'actor_kind' => $kind, 'actor_id' => $actor ? $actor : null, 'subject_id' => $subject, 'object_type' => $definition['object_type'], 'object_id' => $object, 'channel' => $context['channel'], 'request_id' => self::request_id(), 'operation_id' => wp_generate_uuid4(), 'processing_state' => 'pending', 'payload' => $json, ); if ( $network_event ) { $data['site_id'] = 0; } return self::prepare_storage( $data ); } /** * Persist a supported changed role list with its captured actor and snapshot provenance. * * @param int $subject Positive affected user identifier for the captured role change. * @param array $before Observed role identifiers before the successful native write. * @param array $after Role identifiers written by the successful native operation. * @param array $actor_context Native actor identifier and execution channel captured for this operation. * * @return bool Whether a distinct valid role change was inserted. */ public static function role_change( int $subject, array $before, array $after, array $actor_context ): bool { if ( ( class_exists( EventAPI::class ) && EventAPI::dispatching() ) || ( class_exists( Migrations::class ) && ! Migrations::ready( 'events' ) ) ) { return false; } global $wpdb; if ( $before === $after || $subject <= 0 ) { return false; } foreach ( array_merge( $before, $after ) as $role ) { if ( ! is_string( $role ) || ! preg_match( '/\A[a-zA-Z0-9_-]{1,64}\z/D', $role ) ) { self::degraded( 'role_payload_rejected' ); return false; } } $actor = (int) $actor_context['id']; $channel = (string) $actor_context['channel']; $kind = $actor ? 'authenticated_user' : ( 'cron' === $channel ? 'system' : 'unknown' ); $payload = array( 'event_scope' => 'site', 'source' => defined( 'LOGGED_AI_SYNTHETIC_TEST_ENVIRONMENT' ) && true === LOGGED_AI_SYNTHETIC_TEST_ENVIRONMENT ? 'synthetic_test' : 'wordpress', 'actor_label' => $actor ? sprintf( 'User #%d', $actor ) : ( 'system' === $kind ? 'WordPress cron' : 'Unknown actor' ), 'subject_label' => sprintf( 'User #%d', $subject ), 'diffs' => array( array( 'field' => 'roles', 'type' => 'string_list', 'before' => array_values( $before ), 'after' => array_values( $after ), 'availability' => 'captured', 'redacted' => false, ), ), 'collector' => 'user_capabilities_metadata_v1', 'observed_via' => 'updated_user_meta', 'confidence' => 'successful_write_with_prewrite_snapshot', 'before_consistency' => 'prewrite_snapshot_not_serializable_against_concurrent_writes', 'ip_context' => array( 'mode' => 'unavailable', 'reason' => 'not_collected_by_this_collector', ), 'incomplete_context' => false, 'truncated' => false, ); if ( class_exists( Collection::class ) ) { $payload['maintenance'] = Collection::status()['maintenance']; } $json = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ( false === $json || strlen( $json ) > 16384 ) { self::degraded( 'role_payload_rejected' ); return false; } $now = self::now(); $data = array( 'uid' => wp_generate_uuid4(), 'schema_version' => 1, 'network_id' => Scope::network(), 'site_id' => get_current_blog_id(), 'occurred_at' => $now, 'ingested_at' => self::now(), 'code' => 'identity.roles_changed', 'category' => 'identity', 'action' => 'roles_changed', 'outcome' => 'stored_change', 'severity' => in_array( 'administrator', $after, true ) && ! in_array( 'administrator', $before, true ) ? 'high' : 'notice', 'actor_kind' => $kind, 'actor_id' => $actor ? $actor : null, 'subject_id' => $subject, 'object_type' => 'user', 'object_id' => $subject, 'channel' => $channel, 'request_id' => self::request_id(), 'operation_id' => wp_generate_uuid4(), 'processing_state' => 'pending', 'payload' => $json, ); return self::persist( $data ); } /** * Apply storage callbacks and alert admission metadata before inserting prepared role evidence. * * @param array $data Prepared minimized event storage fields. * * @return bool Whether storage preparation and insertion succeeded. */ private static function persist( array $data ): bool { $data = self::prepare_storage( $data ); return false !== $data && self::insert_prepared( $data ); } /** * Apply pre-store callbacks and alert revision binding while checking native context. * * Extension callbacks run before persistence and never receive a private worker transaction. * * @param array $data Prepared minimized event storage fields. * * @return array|false Prepared event fields, or false when a callback rejects data or changes context. */ private static function prepare_storage( array $data ): array|false { global $wpdb; $connection = $wpdb; $network = Scope::network(); $site = get_current_blog_id(); $principal = get_current_user_id(); $data = EventAPI::pre_store( $data ); if ( false === $data ) { return false; } $data['alert_revision'] = class_exists( Alerts::class ) ? Alerts::capture_revision( $data ) : 0; $data['alert_state'] = $data['alert_revision'] ? 'pending' : 'skipped'; if ( $wpdb !== $connection || Scope::network() !== $network || get_current_blog_id() !== $site || get_current_user_id() !== $principal ) { self::degraded( 'collector_context_incomplete' ); return false; } return $data; } /** * Insert a prepared event on the current caller connection and notify local consumers. * * @param array $data Prepared minimized event storage fields. * * @return bool Whether the native insertion succeeded; caller transaction ownership is unchanged. */ private static function insert_prepared( array $data ): bool { global $wpdb; $old = $wpdb->suppress_errors( true ); try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Native wpdb insert persists the validated custom event row on the caller connection. $ok = false !== $wpdb->insert( self::table(), $data ); $id = (int) $wpdb->insert_id; } finally { $wpdb->suppress_errors( $old ); } if ( ! $ok ) { self::degraded( 'event_write_failed' ); return false; } self::notify_persisted( $id, $data ); return true; } /** * Schedule eligible alert work and dispatch the event post-store notification. * * Deferred writers call this only after their own successful COMMIT, on the original native connection. * * @param int $id Stored event or request observation identifier for this operation. * @param array $data Prepared minimized event storage fields. * * @return void */ public static function notify_persisted( int $id, array $data ): void { if ( $data['alert_revision'] ) { Alerts::schedule_for_event( $data ); } EventAPI::post_store( $id, $data ); } /** * Build the finite event-code and scalar vocabulary shared by activity readers. * * The same finite scalar vocabulary is used by lists, exports and API validation. * * @return array Supported choices indexed by filter key. */ public static function filter_choices(): array { $definitions = self::definitions(); $choices = array( 'code' => array_keys( $definitions ), 'severity' => array( 'info', 'notice', 'warning', 'high', 'critical' ), 'actor_kind' => array( 'authenticated_user', 'system', 'unknown' ), 'channel' => array( 'cli', 'cron', 'rest', 'xmlrpc', 'wp_admin', 'frontend' ), 'outcome' => array( 'stored_change', 'completed', 'failed', 'attempted', 'observed' ), ); foreach ( array( 'category', 'action', 'object_type' ) as $key ) { $choices[ $key ] = array_values( array_unique( array_column( $definitions, $key ) ) ); sort( $choices[ $key ] ); } return $choices; } /** * List supported activity filter keys including identity, time and catalog search. * * @return array Finite accepted filter-key list. */ public static function filter_keys(): array { return array_merge( array( 'actor_id', 'subject_id', 'object_id', 'from', 'to', 'request_id', 'q' ), array_keys( self::filter_choices() ) ); } /** * Validate and normalize supported scalar activity filters without broadening invalid input. * * @param array $input Structured incoming fields to validate under the operation contract. * * @return array|\WP_Error Sorted validated filters, or an explicit unsupported-value/range error. */ public static function filters( array $input ): array|\WP_Error { $result = array(); $allowed = self::filter_keys(); $choices = self::filter_choices(); foreach ( $input as $key => $value ) { if ( ! in_array( $key, $allowed, true ) || ( ! is_string( $value ) && ! is_int( $value ) ) ) { return new \WP_Error( 'logged_ai_filter', __( 'Invalid filter. Scope has not been broadened.', 'logged-ai' ), array( 'status' => 400 ) ); } $value = (string) $value; if ( '' === $value ) { continue; } if ( strlen( $value ) > 80 ) { return new \WP_Error( 'logged_ai_filter', __( 'Filter too long.', 'logged-ai' ), array( 'status' => 400 ) ); } if ( 'q' === $key ) { if ( ! preg_match( '/\A[a-zA-Z0-9_ .:-]{2,80}\z/D', $value ) ) { return new \WP_Error( 'logged_ai_filter', 'Search event types with two to eighty letters, numbers or identifier separators.', array( 'status' => 400 ) ); } $value = strtolower( preg_replace( '/ +/', ' ', trim( $value ) ) ); if ( strlen( $value ) < 2 || count( explode( ' ', $value ) ) > 4 ) { return new \WP_Error( 'logged_ai_filter', 'Use up to four event-type search terms.', array( 'status' => 400 ) ); } } elseif ( in_array( $key, array( 'actor_id', 'subject_id', 'object_id' ), true ) ) { if ( ! preg_match( '/\A[1-9][0-9]{0,18}\z/D', $value ) ) { return new \WP_Error( 'logged_ai_filter', __( 'Use a positive user or object ID.', 'logged-ai' ), array( 'status' => 400 ) ); } } elseif ( isset( $choices[ $key ] ) && ! in_array( $value, $choices[ $key ], true ) ) { return new \WP_Error( 'logged_ai_filter', __( 'Unknown filter value. Scope has not been broadened.', 'logged-ai' ), array( 'status' => 400 ) ); } elseif ( 'request_id' === $key && ! preg_match( '/\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/D', $value ) ) { return new \WP_Error( 'logged_ai_filter', __( 'Use a recorded request correlation ID.', 'logged-ai' ), array( 'status' => 400 ) ); } elseif ( in_array( $key, array( 'from', 'to' ), true ) ) { if ( ! preg_match( '/\A[0-9]{4}-[0-9]{2}-[0-9]{2}\z/D', $value ) ) { return new \WP_Error( 'logged_ai_filter', __( 'Use an ISO date (YYYY-MM-DD).', 'logged-ai' ), array( 'status' => 400 ) ); } $date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value, new \DateTimeZone( 'UTC' ) ); if ( ! $date || $date->format( 'Y-m-d' ) !== $value ) { return new \WP_Error( 'logged_ai_filter', __( 'Use an ISO date (YYYY-MM-DD).', 'logged-ai' ), array( 'status' => 400 ) ); } } $result[ $key ] = $value; } if ( isset( $result['from'], $result['to'] ) && $result['from'] >= $result['to'] ) { return new \WP_Error( 'logged_ai_filter', __( 'The end date must follow the start date.', 'logged-ai' ), array( 'status' => 400 ) ); } ksort( $result ); return $result; } /** * Resolve normalized search terms against the finite event-definition catalog. * * Resolve text against a finite code catalog, never a wildcard scan over stored JSON. * * @param string $query Normalized event-catalog search terms from the supported filter parser. * * @return array Event codes whose catalog text contains every supplied search term. */ public static function search_codes( string $query ): array { $words = explode( ' ', $query ); $result = array(); foreach ( self::definitions() as $code => $definition ) { $text = strtolower( implode( ' ', array( $code, $definition['category'], $definition['action'], $definition['object_type'], $definition['description'] ?? '', $definition['explanation'] ?? '' ) ) ); $text .= ' ' . str_replace( array( '.', '_', ':' ), ' ', $text ); foreach ( $words as $word ) { if ( ! str_contains( $text, $word ) ) { continue 2; } } $result[] = $code; } return $result; } /** * Build fixed scoped SQL grammar and separate values from validated activity filters. * * @param Scope $scope Current operation scope whose native grants are checked. * @param array $filters Validated normalized activity filters. * * @return array Predicate template followed by the ordered placeholder values. * * @throws \LogicException When an internal filter key is outside the fixed supported grammar. */ private static function predicate( Scope $scope, array $filters ): array { $where = 'network_id=%d AND site_id=%d'; $values = array( $scope->network_id, $scope->site_id ); foreach ( $filters as $key => $value ) { if ( 'q' === $key ) { $codes = self::search_codes( $value ); if ( ! $codes ) { $where .= ' AND 1=0'; } else { $where .= ' AND code IN (' . implode( ',', array_fill( 0, count( $codes ), '%s' ) ) . ')'; array_push( $values, ...$codes ); } } elseif ( 'from' === $key || 'to' === $key ) { $where .= 'from' === $key ? ' AND occurred_at >= %s' : ' AND occurred_at < %s'; $values[] = $value . ' 00:00:00.000000'; } else { $columns = array_fill_keys( self::filter_keys(), true ); if ( ! isset( $columns[ $key ] ) ) { throw new \LogicException( 'invalid_filter' ); } $where .= ' AND ' . $key . '=%s'; $values[] = $value; } } return array( $where, $values ); } /** * Read a bounded scoped activity page using a signed filter-bound chronological cursor. * * @param Scope $scope Current operation scope whose native grants are checked. * @param array $input Structured incoming fields to validate under the operation contract. * @param string $cursor Optional signed chronological cursor bound to the selected scope and filters. * @param int $limit Requested page size clamped to the supported finite bound. * @param bool $payload Whether to include retained minimized payload data with listed events. * * @return array|\WP_Error Decoded event page, next cursor and scope, or an access/filter/storage error. */ public static function listing( Scope $scope, array $input = array(), string $cursor = '', int $limit = 50, bool $payload = false ): array|\WP_Error { global $wpdb; if ( ! $scope->valid() ) { return new \WP_Error( 'logged_ai_forbidden', __( 'Access changed. Reload and sign in again.', 'logged-ai' ), array( 'status' => 403 ) ); } $filters = self::filters( $input ); if ( is_wp_error( $filters ) ) { return $filters; } $limit = max( 1, min( 501, $limit ) ); list($where, $values) = self::predicate( $scope, $filters ); $binding = hash( 'sha256', wp_json_encode( array( $scope->network_id, $scope->site_id, $filters ) ) ); if ( '' !== $cursor ) { if ( strlen( $cursor ) > 1024 ) { return new \WP_Error( 'logged_ai_cursor', 'Invalid cursor.', array( 'status' => 400 ) ); } $parts = explode( '.', $cursor ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Strict base64 decoding is the bounded signed cursor/preview wire codec, not executable obfuscation; signature, shape and current scope checks remain required. $raw = 2 === count( $parts ) ? base64_decode( $parts[0], true ) : false; $c = is_string( $raw ) ? json_decode( $raw, true ) : null; if ( ! is_array( $c ) || ! isset( $c['id'], $c['time'], $c['binding'] ) || ! is_string( $c['id'] ) || ! is_string( $c['time'] ) || ! hash_equals( hash_hmac( 'sha256', $parts[0], wp_salt( 'nonce' ) ), $parts[1] ) || $c['binding'] !== $binding || ! preg_match( '/\A[1-9][0-9]{0,19}\z/D', $c['id'] ) || ! preg_match( '/\A[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{6}\z/D', $c['time'] ) ) { return new \WP_Error( 'logged_ai_cursor', __( 'Invalid cursor or changed filters.', 'logged-ai' ), array( 'status' => 400 ) ); } $where .= ' AND (occurred_at < %s OR (occurred_at = %s AND id < %s))'; array_push( $values, $c['time'], $c['time'], $c['id'] ); } $columns = 'id,uid,schema_version,network_id,site_id,occurred_at,ingested_at,code,category,action,outcome,severity,actor_kind,actor_id,subject_id,object_type,object_id,channel,request_id,operation_id,processing_state'; if ( $payload ) { $columns .= ',payload'; } $values[] = $limit + 1; $old = $wpdb->suppress_errors( true ); try { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed fields and allowlisted predicates append their corresponding values together; table is prepended and limit appended. WPCS cannot count the unpacked computed array. Read current scoped evidence; a result cache could retain erased rows or stale cursor pages. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT ' . $columns . ' FROM %i WHERE ' . $where . ' ORDER BY occurred_at DESC,id DESC LIMIT %d', ...array_merge( array( self::table() ), $values ) ), ARRAY_A ); $failed = '' !== $wpdb->last_error; } finally { $wpdb->suppress_errors( $old ); } if ( ! $scope->valid() ) { return new \WP_Error( 'logged_ai_forbidden', 'Evidence access changed while loading records.', array( 'status' => 403 ) ); } if ( $failed || ! is_array( $rows ) ) { self::degraded( 'query_failed' ); return new \WP_Error( 'logged_ai_storage', __( 'Evidence storage is unavailable. Try again after checking System status.', 'logged-ai' ), array( 'status' => 503 ) ); } $more = count( $rows ) > $limit; if ( $more ) { array_pop( $rows ); } $next = null; if ( $more && $rows ) { $last = end( $rows ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Base64 transports a signed finite JSON cursor/preview envelope; it is not an executable payload or a substitute for authentication. $token = base64_encode( wp_json_encode( array( 'id' => (string) $last['id'], 'time' => $last['occurred_at'], 'binding' => $binding, ) ) ); $next = $token . '.' . hash_hmac( 'sha256', $token, wp_salt( 'nonce' ) ); } return array( 'events' => array_map( array( self::class, 'decode' ), $rows ), 'next_cursor' => $next, 'scope' => array( 'network_id' => (string) $scope->network_id, 'site_id' => (string) $scope->site_id, ), 'timezone' => 'UTC', ); } /** * Read one validated event identifier within the current authorized activity scope. * * @param Scope $scope Current operation scope whose native grants are checked. * @param string $id Positive decimal event identifier, retained as a string for SQL binding. * * @return array|\WP_Error Decoded retained event, or an access, missing-event or storage error. */ public static function detail( Scope $scope, string $id ): array|\WP_Error { global $wpdb; if ( ! $scope->valid() ) { return new \WP_Error( 'logged_ai_forbidden', 'Access denied.', array( 'status' => 403 ) ); } if ( ! preg_match( '/\A[1-9][0-9]{0,19}\z/D', $id ) ) { return new \WP_Error( 'logged_ai_missing', 'Event unavailable.', array( 'status' => 404 ) ); } $old = $wpdb->suppress_errors( true ); try { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed native-prefix event table; current network/site and validated event ID are bound placeholders. Read current scoped evidence; a persistent result cache could return an erased event. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND id=%s', self::table(), $scope->network_id, $scope->site_id, $id ), ARRAY_A ); $failed = '' !== $wpdb->last_error; } finally { $wpdb->suppress_errors( $old ); } if ( ! $scope->valid() ) { return new \WP_Error( 'logged_ai_forbidden', 'Evidence access changed while loading the record.', array( 'status' => 403 ) ); } if ( $failed ) { return new \WP_Error( 'logged_ai_storage', 'Evidence storage unavailable.', array( 'status' => 503 ) ); } return $row ? self::decode( $row ) : new \WP_Error( 'logged_ai_missing', __( 'Event unavailable in this site.', 'logged-ai' ), array( 'status' => 404 ) ); } /** * Revalidate one buffered activity result through the protected grouped-read boundary. * * Rebind buffered activity to bounded current rows after native output hooks. * Supplied readers remain caller-owned; no global connection swap or caller * transaction command is used. Locking reads bypass an old RR snapshot. * * @phpstan-impure * * @param Scope $scope Current operation scope whose native grants are checked. * @param array $result Previously decoded activity result to revalidate before disclosure. * @param bool $detail Whether the supplied result represents one complete event detail. * @param \wpdb|null $reader Optional independent worker reader retained and closed by its caller. * * @return bool Whether the result still matches current authorized source rows. */ public static function render_current( Scope $scope, array $result, bool $detail = false, ?\wpdb $reader = null ): bool { return self::render_groups( $scope, array( array( 'value' => $result, 'detail' => $detail, ), ), $reader ); } /** * Recheck bounded buffered result groups against current source rows and native grants. * * Validate all protected sections together, including network detail + list. * * @param Scope $scope Current operation scope whose native grants are checked. * @param array $groups At most two protected activity result groups to revalidate together. * @param \wpdb|null $reader Optional independent worker reader retained and closed by its caller. * * @param callable|null $validate_context Optional trusted display-only validation, run inside the protected read boundary. * * @return bool Whether all protected groups remain current without an observed mutation or drift. */ public static function render_groups( Scope $scope, array $groups, ?\wpdb $reader = null, ?callable $validate_context = null ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $caller = $wpdb; $origin = Derived::native_context( $caller ); if ( count( $groups ) > 2 ) { return false; } $list_keys = explode( ',', 'id,uid,schema_version,network_id,site_id,occurred_at,ingested_at,code,category,action,outcome,severity,actor_kind,actor_id,subject_id,object_type,object_id,channel,request_id,operation_id,processing_state' ); $ids = array(); $bound = array(); foreach ( $groups as $group ) { if ( ! is_array( $group ) || ! is_bool( $group['detail'] ?? null ) || ! is_array( $group['value'] ?? null ) ) { return false; } $payload = array_key_exists( 'payload', $group ) ? $group['payload'] : false; if ( ! is_bool( $payload ) || ( $group['detail'] && $payload ) ) { return false; } $projection_keys = $payload ? array_merge( $list_keys, array( 'detail' ) ) : $list_keys; $expected = $group['detail'] ? array( $group['value'] ) : $group['value']['events'] ?? null; if ( ! is_array( $expected ) || count( $expected ) > 50 ) { return false; } $rows = array(); foreach ( $expected as $row ) { if ( ! is_array( $row ) || ! is_string( $row['id'] ?? null ) || ! preg_match( '/\A[1-9][0-9]{0,19}\z/D', $row['id'] ) || ! is_string( $row['uid'] ?? null ) || (int) ( $row['network_id'] ?? -1 ) !== $scope->network_id || (int) ( $row['site_id'] ?? -1 ) !== $scope->site_id || isset( $rows[ $row['id'] ] ) || ( ! $group['detail'] && array_keys( $row ) !== $projection_keys ) ) { return false; } $ids[ $row['id'] ] = true; $rows[ $row['id'] ] = $row; } $bound[] = array( 'detail' => $group['detail'], 'keys' => $projection_keys, 'rows' => $rows, ); } if ( count( $ids ) > 51 ) { return false; } require_once __DIR__ . '/class-workerconnection.php'; $owned = null === $reader; $changed = false; $observe = null; $valid = false; try { if ( $owned ) { $reader = new WorkerConnection( DB_USER, DB_PASSWORD, DB_NAME, DB_HOST ); $reader->set_prefix( $caller->base_prefix ); $reader->set_blog_id( (int) $caller->blogid, $scope->network_id ); $reader->suppress_errors( true ); } if ( ! $reader instanceof WorkerConnection || $reader === $caller || ! $reader->ready || Derived::native_context( $caller ) !== $origin ) { return false; } $reader_origin = Derived::native_context( $reader ); $caller_map = $origin; $reader_map = $reader_origin; // Core bookkeeping IDs differ for single-site private connections; physical maps must not. unset( $caller_map['blogid'], $caller_map['siteid'], $reader_map['blogid'], $reader_map['siteid'] ); if ( $caller_map !== $reader_map ) { return false; } $pinned = static fn(): bool => Derived::native_context( $caller ) === $origin && Derived::native_context( $reader ) === $reader_origin; if ( $owned ) { foreach ( array( 'SET SESSION autocommit=1', 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED', 'SET SESSION innodb_lock_wait_timeout=2' ) as $setup ) { if ( ! $pinned() || false === $reader->query( $setup ) || ! $pinned() ) { return false; } } } $observe = static function ( string $sql ) use ( &$changed, $caller, $reader ): string { $statement = trim( $sql ); $setup = false; if ( in_array( $statement, array( 'SET SESSION autocommit=1', 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED' ), true ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect at most 24 argument-free frames to distinguish separate Scope setup from writes during the protected group rendering read. $trace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 24 ); foreach ( $trace as $index => $frame ) { if ( 'query' === $frame['function'] && ( $frame['object'] ?? null ) instanceof WorkerConnection && $frame['object'] !== $caller && $frame['object'] !== $reader ) { $next = $trace[ $index + 1 ] ?? array(); $setup = Scope::class === ( $next['class'] ?? '' ) && 'committed_capabilities' === $next['function']; break; } } } if ( ! $setup && ! preg_match( '/\A(?:SELECT|SHOW|DESCRIBE|EXPLAIN)\s/i', $statement ) ) { $changed = true; } return $sql; }; add_filter( 'query', $observe, PHP_INT_MAX ); if ( ! $pinned() || DB_NAME !== $reader->get_var( 'SELECT DATABASE()' ) || ! $pinned() ) { return false; } $read = static function () use ( $reader, $scope, $ids, $bound, $pinned ): bool { if ( ! $pinned() ) { return false; } if ( ! $ids ) { return true; } $marks = implode( ',', array_fill( 0, count( $ids ), '%s' ) ); $values = array_merge( array( $scope->network_id, $scope->site_id ), array_keys( $ids ) ); $rows = $reader->get_results( $reader->prepare( "SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND id IN ({$marks}) ORDER BY id LIMIT 51 FOR UPDATE", ...array_merge( array( $reader->base_prefix . 'logged_ai_events' ), $values ) ), ARRAY_A ); if ( '' !== $reader->last_error || ! is_array( $rows ) || ! $pinned() || count( $rows ) !== count( $ids ) ) { return false; } $actual = array(); foreach ( $rows as $row ) { $actual[ $row['id'] ] = self::decode( $row ); } foreach ( $bound as $group ) { foreach ( $group['rows'] as $id => $expected ) { if ( ! isset( $actual[ $id ] ) ) { return false; } $row = $actual[ $id ]; if ( ! $group['detail'] ) { $row = array_intersect_key( $row, array_fill_keys( $group['keys'], true ) ); } if ( $row !== $expected ) { return false; } } } return true; }; // Current item disclosures share the write observer and the final evidence/access seal. if ( null !== $validate_context && ( ! $pinned() || true !== $validate_context() || ! $pinned() ) ) { return false; } $cap = 0 === $scope->site_id ? 'logged_ai_view_network_events' : 'logged_ai_view_events'; $valid = $pinned() && $scope->valid_for_all( array( $cap ) ) && $pinned() && $read() && $scope->valid_for_all( array( $cap ) ) && $pinned() && $read() && $pinned(); } catch ( \Throwable $error ) { $valid = false; } finally { if ( $observe ) { remove_filter( 'query', $observe, PHP_INT_MAX ); } if ( $owned && $reader instanceof WorkerConnection ) { try { if ( ! $reader->close() ) { $valid = false; } } catch ( \Throwable $error ) { $valid = false; } } } return $valid && ! $changed && Derived::native_context( $caller ) === $origin; } /** * Preserve database identifiers as strings and decode optional minimized payload JSON. * * @param array $row Native retained event or request row selected for this operation. * * @return array Event row with string identifiers and optional decoded detail in place of payload. */ public static function decode( array $row ): array { foreach ( array( 'id', 'network_id', 'site_id', 'actor_id', 'subject_id', 'object_id' ) as $key ) { $row[ $key ] = null === $row[ $key ] ? null : (string) $row[ $key ]; } if ( isset( $row['payload'] ) ) { $row['detail'] = json_decode( $row['payload'], true ); unset( $row['payload'] ); } return $row; } }