*/ private const CONTEXT_KEYS = [ 'billing', 'shipping', 'customer' ]; /** * Anonymize personal data in an array. * * Removes payment/card data and masks every recognised PII field. Safe to * call on any array shape; unknown keys pass through untouched. * * @since 1.3.0 * * @param array $data The array to anonymize. * @param array $opts Optional. `fields` merges category overrides OVER * the filtered allow-list (so the * `albert/privacy/pii_fields` filter is still honoured); * `mask_context_names` (bool) masks the ambiguous * `name` key everywhere, not only inside a * billing/shipping/customer context — use for * person-record results that carry no product data. * * @return array The anonymized array. */ public static function anonymize( array $data, array $opts = [] ): array { // Start from the FILTERED allow-list so add-on `pii_fields` registrations // are honoured even when a caller passes its own `fields` override. $fields = self::get_pii_fields(); if ( isset( $opts['fields'] ) && is_array( $opts['fields'] ) ) { $fields = array_merge( $fields, $opts['fields'] ); } // Resolve the payment/card matchers ONCE per call and thread them through // the recursion — walk() runs per key, so we must not filter per key. $payment = self::get_payment_matchers(); return self::walk( $data, $fields, $payment, '', ! empty( $opts['mask_context_names'] ) ); } /** * Remove only payment/card data, leaving everything else intact. * * Used when anonymisation is disabled or personal data is deliberately * revealed — payment data is never allowed through regardless. * * @since 1.3.0 * * @param array $data The array to sanitise. * * @return array The array with payment keys removed. */ public static function strip_payment_data( array $data ): array { // Resolve the payment/card matchers ONCE and recurse with them. return self::strip( $data, self::get_payment_matchers() ); } /** * Recursively remove payment/card keys using pre-resolved matchers. * * @since 1.3.0 * * @param array $data The array to sanitise. * @param array{keys: array, prefixes: array} $payment The resolved payment matchers. * * @return array The array with payment keys removed. */ private static function strip( array $data, array $payment ): array { $out = []; foreach ( $data as $key => $value ) { if ( is_string( $key ) && self::is_payment_key( $key, $payment ) ) { continue; } $out[ $key ] = is_array( $value ) ? self::strip( $value, $payment ) : $value; } return $out; } /** * Recursively walk and mask an array node. * * @since 1.3.0 * * @param array $node The current array node. * @param array> $fields The PII allow-list. * @param array{keys: array, prefixes: array} $payment The resolved payment matchers. * @param string $context Current PII context ('' or 'customer'). * @param bool $always Mask context-name keys regardless of context. * * @return array */ private static function walk( array $node, array $fields, array $payment, string $context, bool $always ): array { $ref = self::node_customer_ref( $node ); $out = []; foreach ( $node as $key => $value ) { // Payment/card data is hard-removed at any depth, in any mode. if ( is_string( $key ) && self::is_payment_key( $key, $payment ) ) { continue; } if ( is_array( $value ) ) { $out[ $key ] = self::walk( $value, $fields, $payment, self::child_context( $key, $context ), $always ); continue; } if ( ! is_string( $key ) ) { $out[ $key ] = $value; continue; } // Keys removed entirely during anonymisation (e.g. IP address). if ( in_array( $key, $fields['strip'], true ) ) { continue; } // Keys emptied but kept for output shape (e.g. street address). if ( in_array( $key, $fields['empty'], true ) ) { $out[ $key ] = ''; continue; } // Free-text redacted to a marker (e.g. customer note). if ( in_array( $key, $fields['redact'], true ) ) { $out[ $key ] = ( is_string( $value ) && $value === '' ) ? '' : self::REDACTED; continue; } if ( in_array( $key, $fields['email'], true ) ) { $out[ $key ] = self::mask_email( self::to_string( $value ) ); continue; } if ( in_array( $key, $fields['phone'], true ) ) { $out[ $key ] = self::mask_phone( self::to_string( $value ) ); continue; } if ( in_array( $key, $fields['postcode'], true ) ) { $out[ $key ] = self::mask_postcode( self::to_string( $value ) ); continue; } $is_name = in_array( $key, $fields['name'], true ) || ( ( $always || $context !== '' ) && in_array( $key, $fields['context_name'], true ) ); if ( $is_name ) { $out[ $key ] = $ref !== null ? 'Customer #' . $ref : self::mask_name( self::to_string( $value ) ); continue; } $out[ $key ] = $value; } return $out; } /** * Resolve the child PII context for a nested array. * * Numeric keys preserve the parent context so arrays-of-records inherit * it; a recognised context key ('billing'/'shipping'/'customer') opens a * personal-data context; any other string key resets it so unrelated * branches (e.g. `items`) are never treated as personal data. * * @since 1.3.0 * * @param int|string $key The key being descended into. * @param string $current The current context. * * @return string */ private static function child_context( int|string $key, string $current ): string { if ( is_int( $key ) ) { return $current; } return in_array( $key, self::CONTEXT_KEYS, true ) ? 'customer' : ''; } /** * Find a customer reference in the current node for name substitution. * * @since 1.3.0 * * @param array $node The node to inspect. * * @return string|null Numeric reference as a string, or null when absent. */ private static function node_customer_ref( array $node ): ?string { foreach ( [ 'customer_id', 'id' ] as $candidate ) { if ( ! isset( $node[ $candidate ] ) ) { continue; } $value = $node[ $candidate ]; if ( is_int( $value ) && $value > 0 ) { return (string) $value; } if ( is_string( $value ) && $value !== '' && ctype_digit( $value ) ) { return (string) (int) $value; } } return null; } /** * Get the (filtered) PII allow-list. * * @since 1.3.0 * * @return array> */ private static function get_pii_fields(): array { $defaults = self::default_pii_fields(); /** * Filters the PII field allow-list used by the anonymizer. * * Each category maps to a list of exact array-key names. Returned * categories are merged OVER the defaults at the category level: a * returned category REPLACES that category wholesale — it does not append * to it. To extend a category, read the incoming value and return it with * your keys added (e.g. `$fields['name'][] = 'customer_name';`). Categories * you omit keep their default values. * * @since 1.3.0 * * @param array> $defaults The default allow-list. */ $filtered = apply_filters( 'albert/privacy/pii_fields', $defaults ); return is_array( $filtered ) ? array_merge( $defaults, $filtered ) : $defaults; } /** * The default PII allow-list, grouped by masking rule. * * @since 1.3.0 * * @return array> */ private static function default_pii_fields(): array { return [ 'email' => [ 'email', 'billing_email', 'shipping_email' ], 'phone' => [ 'phone', 'billing_phone', 'shipping_phone' ], 'name' => [ 'first_name', 'last_name', 'display_name', 'username', 'user_login', 'billing_first_name', 'billing_last_name', 'shipping_first_name', 'shipping_last_name', ], 'context_name' => [ 'name' ], 'postcode' => [ 'postcode', 'billing_postcode', 'shipping_postcode', 'zip', 'postal_code' ], 'empty' => [ 'address', 'address_1', 'address_2', 'billing_address_1', 'billing_address_2', 'shipping_address_1', 'shipping_address_2', 'url', ], 'strip' => [ 'ip_address', 'customer_ip_address' ], 'redact' => [ 'customer_note', 'description' ], ]; } /** * Resolve the payment/card key matchers for this call. * * Free ships NO payment keys of its own — generic PII is the free basis, and * payment/card protection is contributed by add-ons (e.g. the WooCommerce * add-on) via the `albert/privacy/payment_keys` filter. The result is * resolved once per {@see anonymize()} / {@see strip_payment_data()} call and * threaded through the recursion, so the filter is never fired per key. * * @since 1.3.0 * * @return array{keys: array, prefixes: array} Lower-cased matchers. */ private static function get_payment_matchers(): array { /** * Filters the payment/card key matchers that are hard-removed at every * depth, in every mode (including reveal/off). * * Add-ons contribute the concrete keys and prefixes for their payment * gateways. Return an array shaped `[ 'keys' => [...], 'prefixes' => [...] ]`; * `keys` are matched exactly (case-insensitively) and `prefixes` match the * start of a key. Free's default is empty. * * @since 1.3.0 * * @param array $matchers The default (empty) matchers, shaped * `[ 'keys' => [], 'prefixes' => [] ]`. An * add-on's return is validated defensively. */ $matchers = apply_filters( 'albert/privacy/payment_keys', [ 'keys' => [], 'prefixes' => [], ] ); $keys = ( isset( $matchers['keys'] ) && is_array( $matchers['keys'] ) ) ? $matchers['keys'] : []; $prefixes = ( isset( $matchers['prefixes'] ) && is_array( $matchers['prefixes'] ) ) ? $matchers['prefixes'] : []; return [ 'keys' => array_values( array_map( 'strtolower', array_filter( $keys, 'is_string' ) ) ), 'prefixes' => array_values( array_map( 'strtolower', array_filter( $prefixes, 'is_string' ) ) ), ]; } /** * Determine whether a key holds payment/card data. * * @since 1.3.0 * * @param string $key The array key. * @param array{keys: array, prefixes: array} $payment The resolved payment matchers. * * @return bool */ private static function is_payment_key( string $key, array $payment ): bool { $normalized = strtolower( $key ); if ( in_array( $normalized, $payment['keys'], true ) ) { return true; } foreach ( $payment['prefixes'] as $prefix ) { if ( $prefix !== '' && str_starts_with( $normalized, $prefix ) ) { return true; } } return false; } /** * Coerce a scalar value to a string for masking. * * @since 1.3.0 * * @param mixed $value The value. * * @return string */ private static function to_string( $value ): string { return is_scalar( $value ) ? (string) $value : ''; } /** * Mask an email address (e.g. `john@example.com` -> `j***@e***.com`). * * @since 1.3.0 * * @param string $value The email address. * * @return string */ private static function mask_email( string $value ): string { if ( $value === '' || strpos( $value, '*' ) !== false ) { return $value; } $at = strpos( $value, '@' ); if ( $at === false ) { return self::mask_token( $value ); } $local = substr( $value, 0, $at ); $domain = substr( $value, $at + 1 ); $dot = strrpos( $domain, '.' ); if ( $dot === false ) { return self::mask_token( $local ) . '@' . self::mask_token( $domain ); } $host = substr( $domain, 0, $dot ); $tld = substr( $domain, $dot + 1 ); return self::mask_token( $local ) . '@' . self::mask_token( $host ) . '.' . $tld; } /** * Reduce a token to its first character plus a fixed mask. * * @since 1.3.0 * * @param string $token The token. * * @return string */ private static function mask_token( string $token ): string { if ( $token === '' ) { return '***'; } return substr( $token, 0, 1 ) . '***'; } /** * Mask a phone number, keeping only the last two digits. * * @since 1.3.0 * * @param string $value The phone number. * * @return string */ private static function mask_phone( string $value ): string { if ( $value === '' || strpos( $value, '*' ) !== false ) { return $value; } $digits = (string) preg_replace( '/\D+/', '', $value ); $length = strlen( $digits ); if ( $length === 0 ) { return $value; } if ( $length <= 2 ) { return str_repeat( '*', $length ); } return str_repeat( '*', $length - 2 ) . substr( $digits, -2 ); } /** * Mask a personal name down to its initials (e.g. `John Doe` -> `J.D.`). * * @since 1.3.0 * * @param string $value The name. * * @return string */ private static function mask_name( string $value ): string { $trimmed = trim( $value ); if ( $trimmed === '' ) { return $value; } // Already initials — leave untouched to stay idempotent. if ( preg_match( '/^(?:\p{Lu}\.)+$/u', $trimmed ) ) { return $value; } $parts = preg_split( '/\s+/u', $trimmed ); if ( ! is_array( $parts ) ) { return $value; } $initials = ''; foreach ( $parts as $part ) { $first = mb_substr( $part, 0, 1 ); if ( $first !== '' ) { $initials .= mb_strtoupper( $first ) . '.'; } } return $initials !== '' ? $initials : $value; } /** * Mask a postcode, keeping only its first two characters. * * @since 1.3.0 * * @param string $value The postcode. * * @return string */ private static function mask_postcode( string $value ): string { if ( $value === '' || strpos( $value, '*' ) !== false ) { return $value; } $length = strlen( $value ); if ( $length <= 2 ) { return $value; } return substr( $value, 0, 2 ) . str_repeat( '*', $length - 2 ); } }