'klant@example.com', * 'billing' => array( * 'first_name' => 'Jan', * 'country' => 'NL', * ), * ); * $safe = GC_Log_Redactor::redact( $payload ); * // => array( * // 'email' => '[REDACTED]', * // 'billing' => array( * // 'first_name' => '[REDACTED]', * // 'country' => 'NL', * // ), * // ) * ``` * * @package GeniusCommerce */ namespace GeniusCommerce; defined( 'ABSPATH' ) || exit; /** * GC_Log_Redactor class. */ class GC_Log_Redactor { /** * Placeholder written in place of redacted values. */ public const REDACTED = '[REDACTED]'; /** * Keys whose nested structure is walked into (rather than blanket-redacted), * with a per-subtree allow-list of fields that remain readable. * * @var array> */ private const NESTED_WALK_ALLOWLIST = array( 'billing' => array( 'country' ), 'shipping' => array( 'country' ), ); /** * Default PII-key set. Mutable via `add_redact_key` / `reset_redact_keys`. * * @var array|null */ private static $redact_keys = null; /** * Built-in default redact keys. * * @var array */ private const DEFAULT_REDACT_KEYS = array( 'email', 'phone', 'username', 'password', 'first_name', 'last_name', 'address_1', 'address_2', 'postcode', 'company', 'meta_data', ); /** * Get the current redact-key set (lazy-initialised to the default). * * @return array */ private static function get_redact_keys(): array { if ( null === self::$redact_keys ) { self::$redact_keys = self::DEFAULT_REDACT_KEYS; } return self::$redact_keys; } /** * Add a key to the redact-set at runtime (e.g. for tenant-specific PII). * * @param string $key Key name to mark as PII. * @return void */ public static function add_redact_key( string $key ): void { $keys = self::get_redact_keys(); if ( ! in_array( $key, $keys, true ) ) { $keys[] = $key; self::$redact_keys = $keys; } } /** * Reset the redact-set to the built-in default. Test-only helper. * * @return void */ public static function reset_redact_keys(): void { self::$redact_keys = self::DEFAULT_REDACT_KEYS; } /** * Recursively redact PII keys in a value. * * Behaviour: * - Associative array / object: for each key in the redact-set the value * becomes `[REDACTED]`; for `billing` / `shipping` we walk INTO the * structure and redact every nested key except `country`. * - Numerically-indexed array: each element is recursed into (batch * payloads). * - Scalar / null: returned unchanged. * * @param mixed $value Arbitrary value (typically an associative array). * @return mixed Redacted value with the same shape as the input. */ public static function redact( $value ) { // Objects → cast to array for walking; callers rarely log objects but // WP_REST_Request->get_params() may return stdClass for JSON bodies. if ( is_object( $value ) ) { $value = (array) $value; } if ( ! is_array( $value ) ) { return $value; } $redact_keys = self::get_redact_keys(); $is_list = array_keys( $value ) === range( 0, count( $value ) - 1 ); // List: recurse into each element (batch endpoints). if ( $is_list ) { return array_map( array( self::class, 'redact' ), $value ); } $out = array(); foreach ( $value as $key => $val ) { // Nested walk for billing/shipping: keep country, redact the rest. if ( is_string( $key ) && isset( self::NESTED_WALK_ALLOWLIST[ $key ] ) && is_array( $val ) ) { $allow = self::NESTED_WALK_ALLOWLIST[ $key ]; $sub = array(); foreach ( $val as $sub_key => $sub_val ) { if ( is_string( $sub_key ) && in_array( $sub_key, $allow, true ) ) { $sub[ $sub_key ] = $sub_val; continue; } $sub[ $sub_key ] = self::REDACTED; } $out[ $key ] = $sub; continue; } if ( is_string( $key ) && in_array( $key, $redact_keys, true ) ) { $out[ $key ] = self::REDACTED; continue; } $out[ $key ] = self::redact( $val ); } return $out; } }