*/ private static $connectors_by_setting = array(); /** * Connector option values stashed before deletion so handle_deleted_option() * can reference the pre-delete value for masking. * * @var array */ private static $pre_delete_values = array(); /** @var bool */ private static $hooks_registered = false; /** * AI connector option values before a /wp/v2/settings write (REST snapshot). * * @var array|null */ private static $rest_settings_connector_snapshot = null; /** * Prevent duplicate log rows when option + REST hooks both observe the same change. * * @var array */ private static $processed_change_fingerprints = array(); /** * Per-setting pre_update_option filters already registered. * * @var array */ private static $registered_pre_update_filters = array(); /** * Per-setting update_option_{$name} actions already registered. * * @var array */ private static $registered_per_option_update_actions = array(); /** * Register option hooks at plugin bootstrap (do not wait for wp_get_connectors). */ public static function init(): void { if ( self::$hooks_registered ) { return; } self::$hooks_registered = true; add_action( 'added_option', array( __CLASS__, 'handle_added_option' ), 10, 2 ); add_action( 'updated_option', array( __CLASS__, 'handle_updated_option' ), 10, 3 ); add_action( 'delete_option', array( __CLASS__, 'capture_before_delete' ), 10, 1 ); add_action( 'deleted_option', array( __CLASS__, 'handle_deleted_option' ), 10, 1 ); add_action( 'wp_connectors_init', array( __CLASS__, 'refresh_connectors_map' ), 999 ); add_action( 'init', array( __CLASS__, 'refresh_connectors_map' ), 30 ); add_action( 'init', array( __CLASS__, 'register_connector_option_filters' ), 31 ); add_filter( 'rest_pre_dispatch', array( __CLASS__, 'rest_capture_connector_snapshot' ), 10, 3 ); add_filter( 'rest_post_dispatch', array( __CLASS__, 'rest_diff_connector_snapshot' ), 20, 3 ); } /** * Refresh connector metadata and register any new per-option update hooks. */ public static function refresh_connectors_map(): void { if ( ! function_exists( 'wp_get_connectors' ) ) { return; } self::build_connectors_map(); self::register_connector_option_filters(); } /** * Bind per-option pre_update and update_option hooks for each AI connector API key setting. */ public static function register_connector_option_filters(): void { if ( ! function_exists( 'wp_get_connectors' ) ) { return; } try { $connectors = wp_get_connectors(); } catch ( \Throwable $e ) { return; } if ( ! is_array( $connectors ) ) { return; } foreach ( $connectors as $connector_data ) { if ( ! is_array( $connector_data ) || ( $connector_data['type'] ?? '' ) !== 'ai_provider' ) { continue; } $auth = isset( $connector_data['authentication'] ) && is_array( $connector_data['authentication'] ) ? $connector_data['authentication'] : array(); if ( ( $auth['method'] ?? '' ) !== 'api_key' ) { continue; } $setting_name = $auth['setting_name'] ?? ''; if ( ! is_string( $setting_name ) || '' === $setting_name ) { continue; } if ( ! isset( self::$registered_pre_update_filters[ $setting_name ] ) ) { self::$registered_pre_update_filters[ $setting_name ] = true; add_filter( "pre_update_option_{$setting_name}", array( __CLASS__, 'filter_pre_update_connector_option' ), 1, 3 ); } if ( ! isset( self::$registered_per_option_update_actions[ $setting_name ] ) ) { self::$registered_per_option_update_actions[ $setting_name ] = true; add_action( "update_option_{$setting_name}", array( __CLASS__, 'handle_per_option_updated' ), 10, 3 ); } } } /** * Log connector credential changes before the option row is updated (always runs inside update_option()). * * @param mixed $new_value New value. * @param mixed $old_value Previous value. * @param string $option Option name. * @return mixed Unchanged new value. */ public static function filter_pre_update_connector_option( $new_value, $old_value, $option ) { if ( ! self::is_ai_tracking_enabled() || ! is_string( $option ) ) { return $new_value; } if ( ! self::is_ai_connector_option_name( $option ) ) { return $new_value; } self::process_connector_option_change( $option, $old_value, $new_value ); return $new_value; } /** * Per-option update_option_{$setting_name} handler (backup when global updated_option is skipped). * * @param mixed $old_value Previous value. * @param mixed $new_value New value. * @param string $option Option name (WP 6.6+). */ public static function handle_per_option_updated( $old_value, $new_value, $option = '' ): void { if ( ! self::is_ai_tracking_enabled() ) { return; } if ( ! is_string( $option ) || '' === $option ) { $hook = current_filter(); if ( is_string( $hook ) && 0 === strpos( $hook, 'update_option_' ) ) { $option = substr( $hook, strlen( 'update_option_' ) ); } } if ( '' === $option || ! self::is_ai_connector_option_name( $option ) ) { return; } self::process_connector_option_change( $option, $old_value, $new_value ); } /** * Index AI provider connector settings from wp_get_connectors() for fast option matching. */ private static function build_connectors_map(): void { if ( ! function_exists( 'wp_get_connectors' ) ) { return; } self::$connectors_by_setting = array(); try { $connectors = wp_get_connectors(); } catch ( \Throwable $e ) { return; } if ( ! is_array( $connectors ) ) { return; } foreach ( $connectors as $connector_id => $connector_data ) { if ( ! is_array( $connector_data ) ) { continue; } if ( ! self::is_trackable_ai_connector( $connector_data ) ) { continue; } $auth = $connector_data['authentication']; $setting_name = $auth['setting_name'] ?? ''; if ( ! is_string( $setting_name ) || '' === $setting_name ) { continue; } if ( isset( self::$connectors_by_setting[ $setting_name ] ) ) { continue; } self::$connectors_by_setting[ $setting_name ] = array( 'id' => is_string( $connector_id ) ? $connector_id : (string) $connector_id, 'data' => $connector_data, ); } } // ------------------------------------------------------------------------- // Option hook handlers // ------------------------------------------------------------------------- /** * Handle a connector API key being stored for the first time. * * @param string $option Option name. * @param mixed $value New option value. */ public static function handle_added_option( $option, $value ): void { if ( ! is_string( $option ) || ! self::is_ai_tracking_enabled() ) { return; } if ( ! self::is_ai_connector_option_name( $option ) ) { return; } self::process_connector_option_change( $option, '', $value ); } /** * Global updated_option handler (Connectors REST /wp/v2/settings save path). * * @param mixed $option Option name. * @param mixed $old_value Previous value. * @param mixed $new_value New value. */ public static function handle_updated_option( $option, $old_value, $new_value ): void { if ( ! is_string( $option ) || ! self::is_ai_tracking_enabled() ) { return; } if ( ! self::is_ai_connector_option_name( $option ) ) { return; } self::process_connector_option_change( $option, $old_value, $new_value ); } /** * Stash the current value before WordPress deletes the option row. * Fires on the global delete_option action, which runs before the DB delete. * * @param string $option Option name about to be deleted. */ public static function capture_before_delete( $option ): void { if ( ! is_string( $option ) || ! self::is_ai_tracking_enabled() ) { return; } if ( ! self::is_ai_connector_option_name( $option ) ) { return; } if ( null === self::get_connector_for_option( $option ) ) { return; } self::$pre_delete_values[ $option ] = get_option( $option, '' ); } /** * Handle a deleted connector option — uses the pre-delete stash for masking. * * @param string $option Option name that was deleted. */ public static function handle_deleted_option( $option ): void { if ( ! is_string( $option ) || ! self::is_ai_tracking_enabled() ) { return; } if ( ! self::is_ai_connector_option_name( $option ) ) { return; } if ( null === self::get_connector_for_option( $option ) ) { return; } $old_value = isset( self::$pre_delete_values[ $option ] ) ? self::$pre_delete_values[ $option ] : ''; unset( self::$pre_delete_values[ $option ] ); self::process_connector_option_change( $option, $old_value, '' ); } /** * Compare old/new connector credential values and write the appropriate audit event. * * @param string $option Connector API key option name. * @param mixed $old_value Value before the change. * @param mixed $new_value Value after the change. */ private static function process_connector_option_change( string $option, $old_value, $new_value ): void { $old_string = is_string( $old_value ) ? $old_value : ''; $new_string = is_string( $new_value ) ? $new_value : ''; $action = self::resolve_connector_change_action( $old_string, $new_string ); if ( null === $action ) { return; } $fingerprint = md5( $option . "\0" . $old_string . "\0" . $new_string ); if ( isset( self::$processed_change_fingerprints[ $fingerprint ] ) ) { return; } $entry = self::get_connector_for_option( $option ); if ( null === $entry ) { return; } $meta = self::connector_meta_from_entry( $entry ); if ( empty( $meta ) ) { return; } if ( ! self::ensure_logging_dependencies() ) { return; } self::$processed_change_fingerprints[ $fingerprint ] = true; $severity = ( 'connector_key_updated' === $action ) ? ACTLOGPRO_Severity::MEDIUM : ACTLOGPRO_Severity::HIGH; self::log_event( $action, $meta['name'], $meta['plugin'], $severity, self::credential_option_has_key( $new_string ) ? $new_string : null, self::credential_option_has_key( $old_string ) ? $old_string : null ); } /** * Snapshot current DB values for AI provider API key options. * * @return array */ private static function snapshot_ai_connector_option_values(): array { $values = array(); if ( ! function_exists( 'wp_get_connectors' ) ) { return $values; } try { $connectors = wp_get_connectors(); } catch ( \Throwable $e ) { return $values; } if ( ! is_array( $connectors ) ) { return $values; } foreach ( $connectors as $connector_data ) { if ( ! is_array( $connector_data ) || ( $connector_data['type'] ?? '' ) !== 'ai_provider' ) { continue; } $auth = isset( $connector_data['authentication'] ) && is_array( $connector_data['authentication'] ) ? $connector_data['authentication'] : array(); if ( ( $auth['method'] ?? '' ) !== 'api_key' ) { continue; } $setting_name = $auth['setting_name'] ?? ''; if ( ! is_string( $setting_name ) || '' === $setting_name ) { continue; } $stored = get_option( $setting_name, '' ); $values[ $setting_name ] = is_string( $stored ) ? $stored : ''; } return $values; } /** * Capture connector option values before a /wp/v2/settings POST or PUT. * * @param mixed $result Response to send, null if not hijacked. * @param WP_REST_Server $server Server instance. * @param WP_REST_Request $request Request. * @return mixed */ public static function rest_capture_connector_snapshot( $result, $server, $request ) { if ( ! $request instanceof \WP_REST_Request ) { return $result; } if ( '/wp/v2/settings' !== $request->get_route() ) { return $result; } if ( ! in_array( $request->get_method(), array( 'POST', 'PUT' ), true ) ) { return $result; } if ( ! self::is_ai_tracking_enabled() ) { return $result; } self::$rest_settings_connector_snapshot = self::snapshot_ai_connector_option_values(); return $result; } /** * Diff connector options after /wp/v2/settings saves (catches changes option hooks may miss). * * @param WP_REST_Response $response Response object. * @param WP_REST_Server $server Server instance. * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public static function rest_diff_connector_snapshot( $response, $server, $request ) { if ( null === self::$rest_settings_connector_snapshot || ! $request instanceof \WP_REST_Request ) { return $response; } if ( '/wp/v2/settings' !== $request->get_route() ) { self::$rest_settings_connector_snapshot = null; return $response; } if ( ! in_array( $request->get_method(), array( 'POST', 'PUT' ), true ) ) { self::$rest_settings_connector_snapshot = null; return $response; } $before = self::$rest_settings_connector_snapshot; self::$rest_settings_connector_snapshot = null; $after = self::snapshot_ai_connector_option_values(); foreach ( $before as $option => $old_value ) { $new_value = $after[ $option ] ?? ''; if ( $old_value === $new_value ) { continue; } self::process_connector_option_change( $option, $old_value, $new_value ); } return $response; } /** * Determine which connector audit action applies, if any. * * @param string $old_string Previous credential value. * @param string $new_string New credential value. */ private static function resolve_connector_change_action( string $old_string, string $new_string ): ?string { if ( $old_string === $new_string ) { return null; } $old_has = self::credential_option_has_key( $old_string ); $new_has = self::credential_option_has_key( $new_string ); if ( ! $old_has && ! $new_has ) { return null; } if ( ! $old_has && $new_has ) { return 'connector_credential_set'; } if ( $old_has && ! $new_has ) { return 'connector_credential_removed'; } if ( $old_has && $new_has ) { if ( self::is_masked_credential_display( $new_string ) ) { return null; } return 'connector_key_updated'; } return null; } /** * Whether a value is WordPress's masked API key display (bullets + last four). * * @param string $value Candidate value. */ private static function is_masked_credential_display( string $value ): bool { if ( '' === $value ) { return false; } return false !== strpos( $value, "\u{2022}" ) || false !== strpos( $value, '•' ); } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /** * Whether a connector definition should be audited (AI provider API key). * * @param array $connector_data Connector config from wp_get_connectors(). */ private static function is_trackable_ai_connector( array $connector_data ): bool { if ( ( $connector_data['type'] ?? '' ) !== 'ai_provider' ) { return false; } $auth = isset( $connector_data['authentication'] ) && is_array( $connector_data['authentication'] ) ? $connector_data['authentication'] : array(); if ( ( $auth['method'] ?? '' ) !== 'api_key' ) { return false; } $setting_name = $auth['setting_name'] ?? ''; return is_string( $setting_name ) && '' !== $setting_name; } /** * Whether an option name is a WordPress AI connector API key setting. * * @param string $option Option name. */ private static function is_ai_connector_option_name( string $option ): bool { if ( '' !== self::get_connector_slug( $option ) ) { return true; } if ( ! function_exists( 'wp_get_connectors' ) ) { return false; } try { $connectors = wp_get_connectors(); } catch ( \Throwable $e ) { return false; } if ( ! is_array( $connectors ) ) { return false; } foreach ( $connectors as $connector_data ) { if ( ! is_array( $connector_data ) ) { continue; } if ( ( $connector_data['type'] ?? '' ) !== 'ai_provider' ) { continue; } $auth = isset( $connector_data['authentication'] ) && is_array( $connector_data['authentication'] ) ? $connector_data['authentication'] : array(); if ( ( $auth['method'] ?? '' ) !== 'api_key' ) { continue; } if ( ( $auth['setting_name'] ?? '' ) === $option ) { return true; } } return false; } /** * Look up a registered AI connector by its credential option name. * * @param string $option Option name. * @return array{id: string, data: array}|null */ private static function get_connector_for_option( string $option ): ?array { if ( function_exists( 'wp_get_connectors' ) ) { self::build_connectors_map(); if ( isset( self::$connectors_by_setting[ $option ] ) ) { return self::$connectors_by_setting[ $option ]; } } $slug = self::get_connector_slug( $option ); if ( '' === $slug ) { return null; } if ( function_exists( 'wp_get_connectors' ) ) { try { $connectors = wp_get_connectors(); } catch ( \Throwable $e ) { $connectors = array(); } if ( is_array( $connectors ) ) { foreach ( $connectors as $connector_id => $connector_data ) { if ( ! is_array( $connector_data ) ) { continue; } $auth = isset( $connector_data['authentication'] ) && is_array( $connector_data['authentication'] ) ? $connector_data['authentication'] : array(); if ( ( $auth['setting_name'] ?? '' ) !== $option ) { continue; } if ( ( $connector_data['type'] ?? '' ) !== 'ai_provider' ) { return null; } return array( 'id' => is_string( $connector_id ) ? $connector_id : (string) $connector_id, 'data' => $connector_data, ); } } } return array( 'id' => $slug, 'data' => array( 'name' => sanitize_text_field( ucwords( str_replace( array( '-', '_' ), ' ', $slug ) ) ), 'type' => 'ai_provider', 'authentication' => array( 'method' => 'api_key', 'setting_name' => $option, ), ), ); } /** * Resolve display name and plugin file from a connectors map entry. * * @param array{id: string, data: array} $entry Map entry from build_connectors_map(). * @return array{name: string, plugin: string} */ private static function connector_meta_from_entry( array $entry ): array { $connector_data = $entry['data']; $connector_id = $entry['id']; $name = ( ! empty( $connector_data['name'] ) && is_string( $connector_data['name'] ) ) ? sanitize_text_field( $connector_data['name'] ) : sanitize_text_field( ucwords( str_replace( array( '-', '_' ), ' ', $connector_id ) ) ); $plugin_file = ''; $plugin_arr = isset( $connector_data['plugin'] ) ? $connector_data['plugin'] : array(); if ( is_array( $plugin_arr ) && ! empty( $plugin_arr['file'] ) && is_string( $plugin_arr['file'] ) ) { $plugin_file = sanitize_text_field( $plugin_arr['file'] ); } return array( 'name' => $name, 'plugin' => $plugin_file, ); } /** * Whether AI connector activity logging is enabled in plugin settings. */ private static function is_ai_tracking_enabled(): bool { $value = get_option( 'actlogpro_track_ai_events', '1' ); return in_array( $value, array( '1', 1, true, 'true', 'yes' ), true ); } /** * Whether a stored connector credential option represents a configured key. * * @param string $value Raw option value. */ private static function credential_option_has_key( string $value ): bool { return '' !== trim( $value ); } /** * Extract the AI connector slug from an option name. * e.g. "connectors_ai_anthropic_api_key" → "anthropic". * Returns an empty string when the option is not an AI connector key. * * @param mixed $option Option name to test. */ public static function get_connector_slug( $option ): string { if ( ! is_string( $option ) ) { return ''; } $prefix = 'connectors_ai_'; $suffix = '_api_key'; if ( strpos( $option, $prefix ) !== 0 ) { return ''; } if ( substr( $option, - strlen( $suffix ) ) !== $suffix ) { return ''; } $slug = substr( $option, strlen( $prefix ), - strlen( $suffix ) ); return ( '' !== $slug ) ? sanitize_key( $slug ) : ''; } /** * Build the masked last-four suffix for a credential string. * Returns an empty string when the value is too short to mask safely (≤ 4 chars). * * @param string $value Raw credential value. */ private static function mask_key( string $value ): string { $trimmed = trim( $value ); return ( strlen( $trimmed ) > 4 ) ? substr( $trimmed, -4 ) : ''; } /** * Write a single AI connector log entry via ACTLOGPRO_Database::log_activity(). * API key values are NEVER stored; only masked suffixes are kept in metadata. * * @param string $action Action slug (connector_credential_set etc.). * @param string $connector_name Human-readable provider name. * @param string $connector_plugin Plugin file that registers the connector. * @param string $severity ACTLOGPRO_Severity constant. * @param mixed $new_value New credential value for masking; null when not applicable. * @param mixed $old_value Previous credential value for masking; null when not applicable. */ private static function ensure_logging_dependencies(): bool { if ( ! defined( 'ACTLOGPRO_PLUGIN_DIR' ) ) { return false; } if ( ! class_exists( 'ACTLOGPRO_Severity' ) ) { require_once ACTLOGPRO_PLUGIN_DIR . 'includes/class-alp-severity.php'; } if ( ! class_exists( 'ACTLOGPRO_Logger' ) ) { require_once ACTLOGPRO_PLUGIN_DIR . 'includes/class-alp-logger.php'; } if ( ! class_exists( 'ACTLOGPRO_Database' ) ) { require_once ACTLOGPRO_PLUGIN_DIR . 'includes/class-alp-database.php'; } return class_exists( 'ACTLOGPRO_Database' ) && class_exists( 'ACTLOGPRO_Logger' ); } private static function log_event( string $action, string $connector_name, string $connector_plugin, string $severity, $new_value, $old_value ): void { if ( ! self::ensure_logging_dependencies() ) { return; } $current_user = wp_get_current_user(); $user_id = (int) ( $current_user->ID ?? 0 ); $user_login = $current_user->user_login ?? ''; $user_display = $current_user->display_name ?? ''; $user_role = ! empty( $current_user->roles ) ? implode( ', ', (array) $current_user->roles ) : ''; $ip_address = ACTLOGPRO_Logger::get_ip_address(); // Build human-readable description. if ( 'connector_credential_set' === $action ) { $description = sprintf( '%1$s %2$s', __( 'AI Connector API Key Saved:', 'activity-log-pro' ), esc_html( $connector_name ) ); } elseif ( 'connector_credential_removed' === $action ) { $description = sprintf( '%1$s %2$s', __( 'AI Connector API Key Removed:', 'activity-log-pro' ), esc_html( $connector_name ) ); } else { $description = sprintf( '%1$s %2$s', __( 'AI Connector API Key Updated:', 'activity-log-pro' ), esc_html( $connector_name ) ); } // Build metadata — only safe, non-secret values are stored. $metadata = array( 'connector_name' => $connector_name ); if ( ! empty( $connector_plugin ) ) { $metadata['connector_plugin'] = $connector_plugin; } if ( null !== $new_value && is_string( $new_value ) ) { $suffix = self::mask_key( $new_value ); if ( '' !== $suffix ) { $metadata['api_key_new_last4'] = $suffix; } } if ( null !== $old_value && is_string( $old_value ) ) { $suffix = self::mask_key( $old_value ); if ( '' !== $suffix ) { $metadata['api_key_prev_last4'] = $suffix; } } ACTLOGPRO_Database::log_activity( array( 'user_id' => $user_id, 'user_login' => $user_login, 'user_display_name' => $user_display, 'user_role' => $user_role, 'ip_address' => $ip_address, 'event_type' => 'ai', 'object_type' => 'connector', 'object_id' => 0, 'object_name' => $connector_name, 'action' => $action, 'severity' => $severity, 'description' => $description, 'created_at' => current_time( 'mysql' ), 'metadata' => maybe_serialize( $metadata ), ) ); } }