request_id = wp_generate_uuid4(); add_action( 'admin_init', [ $this, 'install' ] ); add_action( 'atk_ai_logs_cleanup_daily_event', [ $this, 'cleanup' ] ); add_action( 'user_register', [ $this, 'registered' ] ); add_action( 'profile_update', [ $this, 'profile' ], 10, 2 ); add_action( 'updated_user_meta', [ $this, 'profile_meta' ], 10, 3 ); add_action( 'added_user_meta', [ $this, 'profile_meta' ], 10, 3 ); add_action( 'set_user_role', [ $this, 'role' ], 10, 3 ); add_action( 'woocommerce_checkout_order_processed', [ $this, 'classic_checkout' ], 10, 3 ); add_action( 'woocommerce_store_api_checkout_order_processed', [ $this, 'checkout' ] ); add_action( 'woocommerce_payment_complete', [ $this, 'paid' ] ); add_action( 'wc_gateway_stripe_process_payment_error', [ $this, 'payment_error' ], 10, 2 ); add_action( 'wc_gateway_stripe_process_redirect_payment_error', [ $this, 'payment_error' ], 10, 2 ); // The gateway validates the webhook before invoking this hook. Never read raw webhook POST data. add_action( 'wc_stripe_webhook_received', [ $this, 'stripe_webhook' ], 10, 3 ); add_action( 'woocommerce_stripe_add_payment_method', [ $this, 'saved_method' ], 10, 2 ); } public static function table() { global $wpdb; return $wpdb->prefix . 'atk_security_events'; } public static function enabled( $category ) { $options = \ATKAI_OPTIONS\Option::get( 'options' ); return ! empty( $options['activity_types'][ $category ] ); } public function install() { if ( self::VERSION === get_option( 'atk_security_db_version' ) ) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $collate = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE $table ( event_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, event_key varchar(64) NOT NULL, subject_id bigint(20) unsigned NOT NULL DEFAULT 0, actor_id bigint(20) unsigned NOT NULL DEFAULT 0, order_id bigint(20) unsigned NOT NULL DEFAULT 0, event_type varchar(40) NOT NULL, source varchar(40) NOT NULL, outcome varchar(30) NOT NULL, reason varchar(60) NOT NULL DEFAULT '', gateway_ref varchar(100) NOT NULL DEFAULT '', mode varchar(10) NOT NULL DEFAULT '', incident_only tinyint(1) unsigned NOT NULL DEFAULT 0, context text NOT NULL, occurred_at datetime NOT NULL, recorded_at datetime NOT NULL, PRIMARY KEY (event_id), UNIQUE KEY event_key (event_key), KEY account_time (subject_id,occurred_at), KEY order_time (order_id,occurred_at), KEY occurred_at (occurred_at), KEY incident_cursor (incident_only,event_id), KEY actor_time (actor_id,occurred_at) ) $collate;" ); if ( $wpdb->get_var( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $table, 'incident_only' ) ) === 'incident_only' && $wpdb->get_var( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $table, 'context' ) ) === 'context' ) { update_option( 'atk_security_db_version', self::VERSION, false ); } } public function cleanup() { if ( self::VERSION !== get_option( 'atk_security_db_version' ) ) { return; } global $wpdb; $options = get_option( 'atk_options', [] ); $days = max( 1, (int) ( $options['atk_logs_days'] ?? 30 ) ); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE incident_only = 0 AND occurred_at < %s', self::table(), gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) ) ); } /** Idempotent delivery by event ID; distinct events are never suppressed by a time window. */ private function record( $category, $type, $subject, $order = 0, $extra = [], $key = '' ) { $enabled = 'incidents' === $category ? ( class_exists( 'ATKAI_Incidents' ) && ATKAI_Incidents::enabled() ) : self::enabled( $category ); if ( ! $enabled || self::VERSION !== get_option( 'atk_security_db_version' ) ) { return; } global $wpdb; $data = array_merge( [ 'subject_id' => absint( $subject ), 'actor_id' => get_current_user_id(), 'order_id' => absint( $order ), 'event_type' => $type, 'source' => $this->source(), 'outcome' => 'recorded', 'reason' => '', 'gateway_ref' => '', 'mode' => '', 'context' => '', 'occurred_at' => gmdate( 'Y-m-d H:i:s' ), ], $extra ); $data['event_key'] = hash( 'sha256', $key ?: $this->request_id . '|' . $type . '|' . $subject . '|' . $order ); $data['recorded_at'] = gmdate( 'Y-m-d H:i:s' ); // Logging failures must never emit database errors into a checkout response. $previous = $wpdb->suppress_errors( true ); try { $columns = array_keys( $data ); $sql = 'INSERT INTO %i (`' . implode( '`,`', $columns ) . '`) VALUES (' . implode( ',', array_fill( 0, count( $data ), '%s' ) ) . ') ON DUPLICATE KEY UPDATE event_key = event_key'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column names come from the fixed internal data array; all values and the table use placeholders. return $wpdb->query( $wpdb->prepare( $sql, array_merge( [ self::table() ], array_values( $data ) ) ) ); } catch ( Throwable $error ) { // Observability must not change an account or payment outcome. return false; } finally { $wpdb->suppress_errors( $previous ); } } /** Minimal incident-only evidence; never enables unrelated activity categories. */ public function incident( $type, $subject, $actor, $context = [], $key = '' ) { return $this->record( 'incidents', $type, $subject, 0, [ 'actor_id' => absint( $actor ), 'incident_only' => 1, 'context' => wp_json_encode( $context ), ], $key ); } private function source() { if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) return 'REST API'; if ( defined( 'DOING_CRON' ) && DOING_CRON ) return 'Scheduled task'; if ( function_exists( 'is_checkout' ) && is_checkout() ) return 'Checkout'; if ( function_exists( 'is_account_page' ) && is_account_page() ) return 'My account'; if ( wp_doing_ajax() ) return 'AJAX request'; return is_admin() ? 'Administration' : 'Website'; } public function registered( $user_id ) { $this->record( 'user', 'account_created', $user_id, 0, [], 'account_created|' . $user_id ); } public function profile( $user_id, $old_user = null ) { $categories = []; if ( $old_user && self::enabled( 'user' ) ) { $user = get_userdata( $user_id ); if ( $user ) { foreach ( [ 'user_email' => 'contact', 'display_name' => 'name', 'user_pass' => 'password' ] as $field => $category ) { if ( $old_user->$field !== $user->$field ) $categories[] = $category; } } } $this->record( 'user', 'profile_updated', $user_id, 0, [ 'reason' => implode( ',', $categories ) ] ); } public function profile_meta( $meta_id, $user_id, $key ) { // Store category names only, never addresses, contact values or arbitrary metadata. $fields = [ 'first_name' => 'name', 'last_name' => 'name', 'billing_email' => 'contact', 'billing_phone' => 'contact', ]; foreach ( [ 'billing', 'shipping' ] as $prefix ) { foreach ( [ 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country' ] as $field ) { $fields[ $prefix . '_' . $field ] = $prefix . '_address'; } } if ( ! isset( $fields[ $key ] ) ) return; $category = $fields[ $key ]; $this->record( 'user', 'profile_updated', $user_id, 0, [ 'reason' => $category ], $this->request_id . '|profile_category|' . $user_id . '|' . $category ); } public function role( $user_id, $role, $old_roles ) { if ( ! empty( $old_roles ) && ! in_array( $role, $old_roles, true ) ) { $this->record( 'user', 'role_changed', $user_id ); } } public function classic_checkout( $order_id, $posted_data, $order ) { $this->checkout( $order ); // Deliberately ignore form data. } public function checkout( $order ) { if ( ! is_a( $order, 'WC_Order' ) ) return; $this->record( 'woocommerce', 'checkout_submitted', $order->get_customer_id(), $order->get_id() ); } public function paid( $order_id ) { $order = function_exists( 'wc_get_order' ) ? wc_get_order( $order_id ) : false; if ( ! $order ) return; $this->record( 'woocommerce', 'order_paid', $order->get_customer_id(), $order_id, [ 'outcome' => 'succeeded' ], 'order_paid|' . $order_id . '|' . $order->get_transaction_id() ); } public function payment_error( $error, $order ) { if ( ! is_a( $order, 'WC_Order' ) ) return; // Gateway exceptions also include network/configuration errors. Do not call them card declines. $this->record( 'woocommerce', 'payment_error', $order->get_customer_id(), $order->get_id(), [ 'outcome' => 'error', 'source' => 'Stripe gateway' ] ); } public function saved_method( $user_id, $method ) { $this->record( 'woocommerce', 'payment_method_added', $user_id, 0, [ 'source' => 'Stripe gateway' ] ); } public function stripe_webhook( $type, $notification, $order = null ) { $outcomes = [ 'payment_intent.payment_failed' => 'failed', 'payment_intent.succeeded' => 'succeeded', 'payment_intent.requires_action' => 'requires_action', 'payment_intent.processing' => 'processing', 'setup_intent.setup_failed' => 'failed', 'setup_intent.succeeded' => 'succeeded', ]; if ( ! isset( $outcomes[ $type ] ) || ! self::enabled( 'woocommerce' ) ) return; $object = $notification->data->object ?? null; if ( ! is_object( $object ) || ! preg_match( '/^evt_[A-Za-z0-9]+$/D', $notification->id ?? '' ) ) return; $reference = $object->id ?? ''; if ( ! preg_match( '/^(pi|seti)_[A-Za-z0-9]+$/D', $reference ) || strlen( $reference ) > 100 ) return; $is_setup = strpos( $type, 'setup_intent.' ) === 0; $subject = is_a( $order, 'WC_Order' ) ? $order->get_customer_id() : 0; if ( ! $subject ) { // Saved-card setup may have no order. Match only a site-scoped Stripe customer ID. global $wpdb; $customer = is_string( $object->customer ?? null ) ? $object->customer : ''; if ( preg_match( '/^cus_[A-Za-z0-9]+$/D', $customer ) ) { $users = get_users( [ 'blog_id' => get_current_blog_id(), 'meta_key' => $wpdb->prefix . '_stripe_customer_id', 'meta_value' => $customer, 'fields' => 'ID', 'number' => 2 ] ); if ( count( $users ) === 1 ) $subject = (int) $users[0]; } } // A shared Stripe account can deliver events from other stores. Require a local link. if ( ! $subject && ! is_a( $order, 'WC_Order' ) ) return; $error = $is_setup ? ( $object->last_setup_error ?? null ) : ( $object->last_payment_error ?? null ); // Only provider codes; never store the error message, payment method, or raw webhook. $codes = [ 'card_declined', 'expired_card', 'incorrect_cvc', 'incorrect_number', 'invalid_number', 'processing_error', 'authentication_required', 'insufficient_funds', 'do_not_honor', 'generic_decline', 'fraudulent', 'lost_card', 'stolen_card' ]; $code = $error->decline_code ?? $error->code ?? ''; $reason = in_array( $code, $codes, true ) ? $code : ''; $created = (int) ( $notification->created ?? 0 ); $this->record( 'woocommerce', $is_setup ? 'card_setup' : 'stripe_payment', $subject, is_a( $order, 'WC_Order' ) ? $order->get_id() : 0, [ 'actor_id' => 0, 'source' => 'Stripe webhook', 'outcome' => $outcomes[ $type ], 'reason' => $reason, 'gateway_ref' => $reference, 'mode' => isset( $notification->livemode ) ? ( $notification->livemode ? 'live' : 'test' ) : '', 'occurred_at' => $created > 0 && $created <= time() + 300 ? gmdate( 'Y-m-d H:i:s', $created ) : gmdate( 'Y-m-d H:i:s' ) ], 'stripe|' . $notification->id ); } }