diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f16a53..0a2b2cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,23 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +## [2.3.0] — 2026-10-16 + +### Added +- `#[Sensitive]` with the `HasSensitiveFields` trait: the value is kept out of + hydration error messages, `serialize()` throws while it is set, and + `var_dump()` / `print_r()` show `[redacted]`. `toArray()` is unchanged. + +### Security +- Card numbers (13–19 digits) are masked to the last four digits in every + library exception message and in `HydrationResult::errors()`. +- `UuidCast`, `MoneyCast`, `DateTimeCast` and `DateTimeImmutableCast` escape + control characters in their messages. + ### Changed +- Messages that contain a 13–19 digit number now show it masked. +- Documentation: `EncryptedCast` page corrected (format, tamper error, key + requirements, a working key-rotation procedure). - Releases ship with a source archive, a CycloneDX SBOM and a build provenance attestation (`gh attestation verify`). Security reports go through GitHub private vulnerability reporting; 2.x is the supported line. @@ -552,7 +568,8 @@ Initial release. `toResponse()`). - CI pipeline: tests across Laravel 10–13 and a scheduled `composer audit`. -[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.2.1...HEAD +[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.3.0...HEAD +[2.3.0]: https://github.com/std-out/simple-data-objects/compare/v2.2.1...v2.3.0 [2.2.1]: https://github.com/std-out/simple-data-objects/compare/v2.2.0...v2.2.1 [1.8.0]: https://github.com/std-out/simple-data-objects/compare/v1.7.2...v1.8.0 [1.7.2]: https://github.com/std-out/simple-data-objects/compare/v1.4.3...v1.7.2 diff --git a/README.md b/README.md index 19a7970..95a8174 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,7 @@ export interface OrderData { | `#[Pipe(TrimStringsPipe::class)]` | class | array-level preprocessing pipeline | | `#[Flatten]` | property | inline nested DTO fields into parent | | `#[Hidden(except: ['admin'])]` | property | exclude from `toArray()` / JSON, optionally per `toArray(context:)` | +| `#[Sensitive]` | property | keep the value out of exception messages, `errors()`, `serialize()` and `var_dump()` (with `use HasSensitiveFields`) | | `#[IgnoreIfNull]` | property | omit from output when `null` | | `#[Computed]` | method | add a derived, method-backed field to `toArray()` | | `#[MapPropertyName('input_key', ...)]` | property | map input key(s) (aliases) → property, same name on output | diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index d493d06..4ff4128 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -80,6 +80,7 @@ const attributes = [ text: 'Output', items: [ { text: '#[Hidden]', link: '/attributes/hidden' }, + { text: '#[Sensitive]', link: '/attributes/sensitive' }, { text: '#[IgnoreIfNull]', link: '/attributes/ignore-if-null' }, { text: '#[Computed]', link: '/attributes/computed' }, { text: '#[WrapIn]', link: '/attributes/wrap-in' }, diff --git a/docs/attributes/index.md b/docs/attributes/index.md index b2f1473..52efa63 100644 --- a/docs/attributes/index.md +++ b/docs/attributes/index.md @@ -9,6 +9,7 @@ All behaviour in Simple Data Objects is declared via PHP attributes on construct | [`#[InferRules]`](./infer-rules.md) | Class | Auto-generate validation rules from property types | | [`#[Flatten]`](./flatten.md) | Parameter or property | Inline nested DTO fields into the parent array | | [`#[Hidden]`](./hidden.md) | Parameter or property | Exclude from `toArray()` / JSON output | +| [`#[Sensitive]`](./sensitive.md) | Parameter or property | Keep a value out of exception messages, `serialize()` and `var_dump()` | | [`#[IgnoreIfNull]`](./ignore-if-null.md) | Parameter or property | Omit from output when value is `null` | | [`#[Computed]`](./computed.md) | Method | Add a derived, method-backed field to serialization output | | [`#[MapPropertyName]`](./map-property-name.md) | Parameter or property | Map a different input key (or several aliases) to this property | diff --git a/docs/attributes/sensitive.md b/docs/attributes/sensitive.md new file mode 100644 index 0000000..61c981b --- /dev/null +++ b/docs/attributes/sensitive.md @@ -0,0 +1,66 @@ +# #[Sensitive] + +Marks a field whose value must never leak through an implicit channel: exception messages, `HydrationResult::errors()`, `serialize()`, `var_dump()` and `print_r()`. Use it for card numbers (PAN), CVV, tokens and personal data. + +The value is still hydrated, readable on the object and included in `toArray()` / `toJson()` — combine it with [`#[Hidden]`](./hidden.md) to keep it out of output as well. + +## Syntax + +The class must use the `HasSensitiveFields` trait. A class with `#[Sensitive]` fields but without the trait throws `InvalidArgumentException` on its first hydration, so a forgotten trait can never leak silently. + +```php +use StdOut\SimpleDataObjects\Attributes\Hidden; +use StdOut\SimpleDataObjects\Attributes\Sensitive; +use StdOut\SimpleDataObjects\Concerns\HasSensitiveFields; + +class CardData extends BaseData +{ + use HasSensitiveFields; + + public function __construct( + public readonly string $holder, + #[Sensitive, Hidden] + public readonly string $pan, + #[Sensitive] + public readonly CardBrand $brand, + ) {} +} +``` + +## What it changes + +| Channel | Behavior | +|---|---| +| Hydration errors | `from()`, `fromResult()` and `with()` report `Invalid value for sensitive field 'brand' of CardData.` — the rejected value and the cast's original exception are dropped | +| `serialize()` | Throws `LogicException` while a sensitive field is not `null`, so the value cannot reach a queue, cache or session in plaintext | +| `var_dump()` / `print_r()` | Shows `[redacted]` instead of the value | +| `toArray()` / `toJson()` | Unchanged — add `#[Hidden]` to exclude the field | + +```php +$card = CardData::from(['holder' => 'Alice', 'pan' => '4111111111111111', 'brand' => 'visa']); + +print_r($card); +// CardData Object ( [holder] => Alice [pan] => [redacted] [brand] => [redacted] ) + +serialize($card); +// LogicException: Cannot serialize CardData: field 'pan' is #[Sensitive]. + +CardData::fromResult(['holder' => 'Alice', 'pan' => '4111111111111111', 'brand' => '4111111111111111'])->errors(); +// ['brand' => "Invalid value for sensitive field 'brand' of CardData."] +``` + +To queue a job that needs the card, pass an identifier (or a token from your payment provider) and load the data inside the job. + +## Limits + +- `var_export()` and Symfony VarDumper (`dump()`, `dd()`) read properties directly and ignore `__debugInfo()`, so they still show the value. +- A field without a cast, enum or nested type goes to the constructor as-is; a wrong type raises PHP's `TypeError`, which names the type, never the value. +- Set `zend.exception_ignore_args=On` in production so values passed to `from()` do not appear in stack traces. + +## PAN masking + +Independently of `#[Sensitive]`, every message built by the library — unknown keys, rejected enum and discriminator values, `UuidCast`, `MoneyCast`, `DateTimeCast` and `DateTimeImmutableCast` — and every entry of `HydrationResult::errors()` masks digit sequences that look like a card number (13–19 digits, optionally separated by spaces or dashes), keeping only the last four: + +``` +Unknown discriminator value '************1111' in field 'type' for PaymentMethodData. +``` diff --git a/docs/casts/encrypted.md b/docs/casts/encrypted.md index 3c6ea7b..b53aba2 100644 --- a/docs/casts/encrypted.md +++ b/docs/casts/encrypted.md @@ -1,6 +1,6 @@ # EncryptedCast -Encrypts values when serializing to storage and decrypts them on hydration. Uses **XSalsa20-Poly1305 authenticated encryption** (via libsodium) — providing both confidentiality and integrity guarantees. +Encrypts values when serializing to storage and decrypts them on hydration. Uses **XSalsa20-Poly1305 authenticated encryption** (libsodium `crypto_secretbox`), which provides confidentiality and detects tampering with a single ciphertext. ## Requirements @@ -35,7 +35,7 @@ A literal key is also accepted (`new EncryptedCast(key: '...')`) — useful in t ```php $user = UserData::from([ 'name' => 'Alice', - 'taxId' => 'v1:AAAA...base64...', // ciphertext from database + 'taxId' => 'zW1o...base64url...', // ciphertext from database ]); $user->taxId; // '1234567890' — plaintext @@ -44,7 +44,7 @@ $user->taxId; // '1234567890' — plaintext ### Serialization (encrypt) ```php -$user->toArray()['taxId']; // 'v1:AAAA...base64...' — ciphertext, unique each call +$user->toArray()['taxId']; // 'Qk3x...base64url...' — ciphertext, unique each call ``` Every `set()` call produces a **different ciphertext** because a random 24-byte nonce is generated for each encryption. This is normal and expected. @@ -54,35 +54,49 @@ Every `set()` call produces a **different ciphertext** because a random 24-byte | Property | Detail | |---|---| | Algorithm | XSalsa20-Poly1305 (`sodium_crypto_secretbox`) | -| Authentication | AEAD — tamper detection is built in | -| Key derivation | BLAKE2b (`sodium_crypto_generichash`) from the provided key string | +| Authentication | Poly1305 tag — a modified ciphertext is rejected. There is no associated data: a valid ciphertext copied to another field or record still decrypts | +| Key derivation | BLAKE2b (`sodium_crypto_generichash`) of the provided key string — a fast hash, not a password KDF | | Nonce | 24 random bytes prepended to each ciphertext | -| Encoding | Base64 URL-safe, no padding (SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING) | +| Format | `base64url(nonce ‖ ciphertext)`, no padding, no version prefix or key ID | ## Key Handling -The cast is designed so key material never leaves process memory: +Use a key of **32 random bytes** (for example `base64_encode(random_bytes(32))`). Because the key is hashed with a fast function, a short or guessable string such as a password can be brute-forced from any stored ciphertext. + +The cast keeps key material out of the places the library controls: - **Never written to the metadata file cache.** `EncryptedCast` deliberately does not implement `__set_state()`, so classes using it are excluded from [file-based metadata persistence](../features/cache.md) — only the in-memory cache applies. Exporting the cast would write the key to disk in plaintext. - **Not serializable.** `serialize()` on the cast throws a `LogicException`. - **Redacted in debug output.** `var_dump()` / `print_r()` show `[redacted]` instead of the derived key, and the constructor parameter is marked `#[\SensitiveParameter]`, so the raw key does not appear in stack traces. +It cannot protect the key everywhere: a key read with `env:` stays in the process environment, and `var_export()` of the cast shows the derived key. + ## Tamper Detection If the ciphertext has been modified, `get()` throws `RuntimeException`: ```php -$user = UserData::from(['taxId' => 'v1:tampered_ciphertext']); -// throws RuntimeException: Failed to decrypt value: authentication tag mismatch +$user = UserData::from(['taxId' => $tamperedCiphertext]); +// RuntimeException: Decryption failed: authentication tag mismatch. ``` +Input that is not base64url or is shorter than a nonce throws `InvalidArgumentException` instead. + ## Key Rotation -`EncryptedCast` uses the provided key directly. To rotate keys: +A ciphertext carries no key ID, so one cast instance can only read values written with its own key. Rotate with a one-off migration that holds both keys: + +```php +$old = new EncryptedCast(env: 'DATA_ENCRYPTION_KEY_OLD'); +$new = new EncryptedCast(env: 'DATA_ENCRYPTION_KEY'); + +foreach (Customer::query()->cursor() as $customer) { + $customer->tax_id = $new->set($old->get($customer->tax_id)); + $customer->saveQuietly(); +} +``` -1. Deploy new cast instance with the new key -2. Re-encrypt existing records: read with old key, write with new key -3. Remove the old cast +Until every row is migrated, the application can read only the rows written with the key it is configured with, so run the migration during a maintenance window or before switching the application to the new key. ## Breaking Change Notice diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index d68b553..c3b71bc 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -52,6 +52,7 @@ All behaviour is declared on the constructor parameters: - **`#[Cast(...)]`** — type conversion for dates, booleans, JSON, encryption, … - **`#[Rules([...])]`** — Laravel validation rules - **`#[Hidden]`** — exclude from `toArray()` output +- **`#[Sensitive]`** — keep a value (PAN, CVV, tokens) out of exception messages, `serialize()` and `var_dump()` - **`#[IgnoreIfNull]`** — omit null fields from output - **`#[Flatten]`** — inline nested DTO fields into the parent array - **`#[MapPropertyName]`** / **`#[MapInputName]`** / **`#[MapOutputName]`** / **`#[TransformKeys]`** — key remapping diff --git a/src/Attributes/Sensitive.php b/src/Attributes/Sensitive.php new file mode 100644 index 0000000..a22a1d9 --- /dev/null +++ b/src/Attributes/Sensitive.php @@ -0,0 +1,10 @@ +parameters as $param) { if (array_key_exists($param->phpName, $overrides)) { $value = $overrides[$param->phpName]; - $value = $param->isPlain ? $value : ValueCaster::cast($param, $value); + $value = match (true) { + $param->isPlain => $value, + $param->isSensitive => ValueCaster::castSensitive($param, $value, static::class), + default => ValueCaster::cast($param, $value), + }; unset($overrides[$param->phpName]); } else { $value = $current[$param->phpName]; diff --git a/src/Casts/DateTimeCast.php b/src/Casts/DateTimeCast.php index 5796101..2473278 100644 --- a/src/Casts/DateTimeCast.php +++ b/src/Casts/DateTimeCast.php @@ -11,6 +11,7 @@ use InvalidArgumentException; use StdOut\SimpleDataObjects\Contracts\CastsValue; use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema; +use StdOut\SimpleDataObjects\Support\Redactor; final class DateTimeCast implements CastsValue, ProvidesJsonSchema { @@ -50,7 +51,7 @@ public function get(mixed $value): ?DateTime if ($dt === false) { throw new InvalidArgumentException( - "Cannot parse \"{$value}\" using format \"{$this->inputFormat}\".", + 'Cannot parse "'.Redactor::forMessage((string) $value)."\" using format \"{$this->inputFormat}\".", ); } diff --git a/src/Casts/DateTimeImmutableCast.php b/src/Casts/DateTimeImmutableCast.php index 902ba1b..f6c5d35 100644 --- a/src/Casts/DateTimeImmutableCast.php +++ b/src/Casts/DateTimeImmutableCast.php @@ -11,6 +11,7 @@ use InvalidArgumentException; use StdOut\SimpleDataObjects\Contracts\CastsValue; use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema; +use StdOut\SimpleDataObjects\Support\Redactor; final class DateTimeImmutableCast implements CastsValue, ProvidesJsonSchema { @@ -50,7 +51,7 @@ public function get(mixed $value): ?DateTimeImmutable if ($dt === false) { throw new InvalidArgumentException( - "Cannot parse \"{$value}\" using format \"{$this->inputFormat}\".", + 'Cannot parse "'.Redactor::forMessage((string) $value)."\" using format \"{$this->inputFormat}\".", ); } diff --git a/src/Casts/EncryptedCast.php b/src/Casts/EncryptedCast.php index 94e7e7c..c1e76ea 100644 --- a/src/Casts/EncryptedCast.php +++ b/src/Casts/EncryptedCast.php @@ -49,7 +49,6 @@ public function __construct( } } - // BLAKE2b: a secure, fast KDF — no brute-force shortcut unlike raw sha256 $this->secretKey = sodium_crypto_generichash($key, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES); } diff --git a/src/Casts/MoneyCast.php b/src/Casts/MoneyCast.php index 5429f7b..90b7119 100644 --- a/src/Casts/MoneyCast.php +++ b/src/Casts/MoneyCast.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use StdOut\SimpleDataObjects\Contracts\CastsValue; use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema; +use StdOut\SimpleDataObjects\Support\Redactor; use StdOut\SimpleDataObjects\ValueObjects\Money; /** @@ -91,7 +92,7 @@ private function parseDecimalString(string $amount): int $amount = trim($amount); if (! preg_match('/^[+-]?+(?:\d++\.?+\d*+|\.\d++)$/', $amount)) { - throw new InvalidArgumentException("\"{$amount}\" is not a valid decimal amount."); + throw new InvalidArgumentException('"'.Redactor::forMessage($amount).'" is not a valid decimal amount.'); } $negative = $amount[0] === '-'; diff --git a/src/Casts/UuidCast.php b/src/Casts/UuidCast.php index 79fd346..0f8e61f 100644 --- a/src/Casts/UuidCast.php +++ b/src/Casts/UuidCast.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use StdOut\SimpleDataObjects\Contracts\CastsValue; use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema; +use StdOut\SimpleDataObjects\Support\Redactor; final class UuidCast implements CastsValue, ProvidesJsonSchema { @@ -26,7 +27,7 @@ public function get(mixed $value): ?string $uuid = (string) $value; if (! preg_match(self::PATTERN, $uuid)) { - throw new InvalidArgumentException("\"{$uuid}\" is not a valid RFC 4122 UUID."); + throw new InvalidArgumentException('"'.Redactor::forMessage($uuid).'" is not a valid RFC 4122 UUID.'); } return strtolower($uuid); diff --git a/src/Concerns/HasSensitiveFields.php b/src/Concerns/HasSensitiveFields.php new file mode 100644 index 0000000..444213b --- /dev/null +++ b/src/Concerns/HasSensitiveFields.php @@ -0,0 +1,60 @@ +initializeLazyObject($this); + + $vars = get_mangled_object_vars($this); + $exposed = self::sensitiveKeys($vars); + + if ($exposed !== []) { + throw new \LogicException(sprintf('Cannot serialize %s: field \'%s\' is #[Sensitive].', static::class, reset($exposed))); + } + + return $vars; + } + + public function __debugInfo(): array + { + if ((new \ReflectionClass($this))->isUninitializedLazyObject($this)) { + return []; + } + + $vars = get_mangled_object_vars($this); + + foreach (array_keys(self::sensitiveKeys($vars)) as $key) { + $vars[$key] = Redactor::REDACTED; + } + + return $vars; + } + + /** + * @param array $vars + * @return array + */ + private static function sensitiveKeys(array $vars): array + { + $sensitive = MetadataRegistry::get(static::class)->sensitiveFields; + $keys = []; + + foreach ($vars as $key => $value) { + $name = substr($key, (int) strrpos("\0".$key, "\0")); + + if ($value !== null && isset($sensitive[$name])) { + $keys[$key] = $name; + } + } + + return $keys; + } +} diff --git a/src/Exceptions/DataHydrationException.php b/src/Exceptions/DataHydrationException.php index 8dcd301..556c155 100644 --- a/src/Exceptions/DataHydrationException.php +++ b/src/Exceptions/DataHydrationException.php @@ -5,6 +5,7 @@ namespace StdOut\SimpleDataObjects\Exceptions; use RuntimeException; +use StdOut\SimpleDataObjects\Support\Redactor; final class DataHydrationException extends RuntimeException { @@ -41,7 +42,7 @@ public static function unreadableXml(string $uri, string $reason): self public static function invalidEnumValue(string $enumClass, string $field, mixed $value): self { - $given = is_scalar($value) ? "'".self::escape((string) $value)."'" : get_debug_type($value); + $given = is_scalar($value) ? "'".Redactor::forMessage((string) $value)."'" : get_debug_type($value); return new self("Invalid value {$given} for field '{$field}'; expected a case of {$enumClass}."); } @@ -52,11 +53,16 @@ public static function unresolvedDiscriminator(string $class, string $field, mix return new self("Missing discriminator field '{$field}' for {$class}."); } - $given = is_scalar($value) ? "'".self::escape((string) $value)."'" : get_debug_type($value); + $given = is_scalar($value) ? "'".Redactor::forMessage((string) $value)."'" : get_debug_type($value); return new self("Unknown discriminator value {$given} in field '{$field}' for {$class}."); } + public static function invalidSensitiveValue(string $class, string $field): self + { + return new self("Invalid value for sensitive field '{$field}' of {$class}."); + } + public static function notADataObject(string $dataClass): self { return new self("DataCollection target class '{$dataClass}' must implement DataObject."); @@ -65,16 +71,11 @@ public static function notADataObject(string $dataClass): self /** @param list $unknownKeys */ public static function unknownKeys(string $class, array $unknownKeys): self { - $list = implode(', ', array_map(static fn (string $key): string => "'".self::escape($key)."'", $unknownKeys)); + $list = implode(', ', array_map(static fn (string $key): string => "'".Redactor::forMessage($key)."'", $unknownKeys)); return new self( sprintf('Unknown key%s [%s] for %s.', count($unknownKeys) === 1 ? '' : 's', $list, $class), $unknownKeys, ); } - - private static function escape(string $value): string - { - return addcslashes($value, "\0..\37\177"); - } } diff --git a/src/HydrationResult.php b/src/HydrationResult.php index 0928d93..854a8e4 100644 --- a/src/HydrationResult.php +++ b/src/HydrationResult.php @@ -4,6 +4,8 @@ namespace StdOut\SimpleDataObjects; +use StdOut\SimpleDataObjects\Support\Redactor; + /** * @template T */ @@ -36,6 +38,10 @@ public static function success(mixed $value): self */ public static function failure(array $errors): self { + foreach ($errors as $key => $message) { + $errors[$key] = Redactor::maskPan($message); + } + return new self(false, null, $errors); } diff --git a/src/Support/ClassMeta.php b/src/Support/ClassMeta.php index 4288ff4..8050665 100644 --- a/src/Support/ClassMeta.php +++ b/src/Support/ClassMeta.php @@ -32,6 +32,9 @@ final class ClassMeta public readonly bool $cascadesNestedRules; + /** @var array */ + public readonly array $sensitiveFields; + /** * @param list $parameters * @param list> $pipes @@ -54,10 +57,15 @@ public function __construct( $validationRules = []; $validationAliases = []; $cascadesNestedRules = false; + $sensitiveFields = []; foreach ($parameters as $meta) { $inputName = $meta->inputNames[0]; + if ($meta->isSensitive) { + $sensitiveFields[$meta->phpName] = true; + } + if ($meta->rules !== []) { $validationRules[$inputName] = $meta->rules; } @@ -79,6 +87,7 @@ public function __construct( $this->validationRules = $validationRules; $this->validationAliases = $validationAliases; $this->cascadesNestedRules = $cascadesNestedRules; + $this->sensitiveFields = $sensitiveFields; $this->pipes = $pipes; $this->hasConstructor = $hasConstructor; $this->hasExtraProperties = $hasConstructor && array_any($parameters, static fn (ParameterMeta $p): bool => ! $p->viaConstructor); diff --git a/src/Support/ClassMetaFactory.php b/src/Support/ClassMetaFactory.php index 7c65768..b2cdf4a 100644 --- a/src/Support/ClassMetaFactory.php +++ b/src/Support/ClassMetaFactory.php @@ -22,12 +22,14 @@ use StdOut\SimpleDataObjects\Attributes\Pipe; use StdOut\SimpleDataObjects\Attributes\RejectUnknownKeys; use StdOut\SimpleDataObjects\Attributes\Rules; +use StdOut\SimpleDataObjects\Attributes\Sensitive; use StdOut\SimpleDataObjects\Attributes\TransformKeys; use StdOut\SimpleDataObjects\Attributes\WhenLoaded; use StdOut\SimpleDataObjects\Attributes\WrapIn; use StdOut\SimpleDataObjects\Attributes\XmlAttribute; use StdOut\SimpleDataObjects\Attributes\XmlElement; use StdOut\SimpleDataObjects\Attributes\XmlText; +use StdOut\SimpleDataObjects\Concerns\HasSensitiveFields; use StdOut\SimpleDataObjects\Contracts\DataObject; use StdOut\SimpleDataObjects\Exceptions\DataHydrationException; @@ -82,6 +84,7 @@ private static function doBuild(string $class): ClassMeta ); self::assertNoFlattenWithRejectUnknownKeys($class, $params, $rejectUnknownKeys); + self::assertSensitiveFieldsProtected($reflection, $params); return new ClassMeta( $params, @@ -116,6 +119,7 @@ private static function doBuild(string $class): ClassMeta $allParams = array_merge($ctorParams, $extraParams); self::assertNoFlattenWithRejectUnknownKeys($class, $allParams, $rejectUnknownKeys); + self::assertSensitiveFieldsProtected($reflection, $allParams); return new ClassMeta( $allParams, @@ -130,6 +134,24 @@ private static function doBuild(string $class): ClassMeta ); } + /** @param list $params */ + private static function assertSensitiveFieldsProtected(ReflectionClass $reflection, array $params): void + { + if (! array_any($params, static fn (ParameterMeta $p): bool => $p->isSensitive)) { + return; + } + + for ($r = $reflection; $r !== false; $r = $r->getParentClass()) { + if (in_array(HasSensitiveFields::class, $r->getTraitNames(), true)) { + return; + } + } + + throw new \InvalidArgumentException( + "{$reflection->name}: #[Sensitive] fields require `use ".HasSensitiveFields::class.';` so serialize() and var_dump() cannot expose them.', + ); + } + /** @return array method name => output key */ private static function computedMethods(ReflectionClass $reflection, ?string $strategy): array { @@ -355,6 +377,7 @@ enumClass: $enumClass, hiddenExcept: $hiddenExcept, phpType: RuleInferrer::phpTypeName($parameter), xmlSource: self::xmlSource($parameter, $phpName, (string) $inputNames[0]), + isSensitive: $parameter->getAttributes(Sensitive::class) !== [], ); } diff --git a/src/Support/HydratorCompiler.php b/src/Support/HydratorCompiler.php index 73f7b22..4d3640a 100644 --- a/src/Support/HydratorCompiler.php +++ b/src/Support/HydratorCompiler.php @@ -367,9 +367,11 @@ private static function fieldAccessExpr(int $i, ParameterMeta $param, string $cl { [$key, $presence] = self::resolveKeyExpr($i, $param->inputNames, $body); - $present = $param->isPlain - ? "\$d[{$key}]" - : "\\StdOut\\SimpleDataObjects\\Support\\ValueCaster::cast(\$p[{$i}], \$d[{$key}])"; + $present = match (true) { + $param->isPlain => "\$d[{$key}]", + $param->isSensitive => "\\StdOut\\SimpleDataObjects\\Support\\ValueCaster::castSensitive(\$p[{$i}], \$d[{$key}], {$classExport})", + default => "\\StdOut\\SimpleDataObjects\\Support\\ValueCaster::cast(\$p[{$i}], \$d[{$key}])", + }; $missingKey = var_export($param->inputNames[0], true); diff --git a/src/Support/ParameterMeta.php b/src/Support/ParameterMeta.php index 2200b63..8d6138b 100644 --- a/src/Support/ParameterMeta.php +++ b/src/Support/ParameterMeta.php @@ -48,6 +48,7 @@ public function __construct( public readonly ?string $phpType = null, /** `@name` for an attribute, `#text` for the element's text, otherwise a child element name. */ public readonly ?string $xmlSource = null, + public readonly bool $isSensitive = false, ) { $this->isPlain = $caster === null && $nestedDataClass === null @@ -85,6 +86,7 @@ enumClass: $state['enumClass'], hiddenExcept: $state['hiddenExcept'] ?? [], phpType: $state['phpType'] ?? null, xmlSource: $state['xmlSource'] ?? null, + isSensitive: $state['isSensitive'] ?? false, ); } } diff --git a/src/Support/Redactor.php b/src/Support/Redactor.php new file mode 100644 index 0000000..ab28bc5 --- /dev/null +++ b/src/Support/Redactor.php @@ -0,0 +1,41 @@ += 0; $i--) { + if ($pan[$i] !== ' ' && $pan[$i] !== '-' && $kept++ >= 4) { + $pan[$i] = '*'; + } + } + + return $pan; + } +} diff --git a/src/Support/ValueCaster.php b/src/Support/ValueCaster.php index 0ca67a9..bd7abd0 100644 --- a/src/Support/ValueCaster.php +++ b/src/Support/ValueCaster.php @@ -40,6 +40,15 @@ public static function cast(ParameterMeta $meta, mixed $value): mixed return $value; } + public static function castSensitive(ParameterMeta $meta, mixed $value, string $class): mixed + { + try { + return self::cast($meta, $value); + } catch (\Throwable) { + throw DataHydrationException::invalidSensitiveValue($class, (string) $meta->inputNames[0]); + } + } + private static function castEnum(ParameterMeta $meta, mixed $value): ?UnitEnum { $enum = null; diff --git a/tests/Fixtures/SensitiveCardData.php b/tests/Fixtures/SensitiveCardData.php new file mode 100644 index 0000000..fee9fd5 --- /dev/null +++ b/tests/Fixtures/SensitiveCardData.php @@ -0,0 +1,26 @@ +assertSame(self::MASKED, Redactor::maskPan(self::PAN)); + $this->assertSame('*********0004', Redactor::maskPan('4222222220004')); + $this->assertSame('***************1234', Redactor::maskPan('6011000990139421234')); + } + + public function test_masks_separated_and_embedded_numbers(): void + { + $this->assertSame('**** **** **** 1111', Redactor::maskPan('4111 1111 1111 1111')); + $this->assertSame('****-****-****-1111', Redactor::maskPan('4111-1111-1111-1111')); + $this->assertSame('card x************1111y declined', Redactor::maskPan('card x4111111111111111y declined')); + $this->assertSame('a ************1111, b ************4444', Redactor::maskPan('a 4111111111111111, b 5555555555554444')); + } + + public function test_leaves_other_numbers_untouched(): void + { + foreach (['', 'short', '123456789012', '12345678901234567890', '4111.1111.1111.1111', 'UA903052992990004149123456789'] as $value) { + $this->assertSame($value, Redactor::maskPan($value)); + } + } + + public function test_for_message_masks_and_escapes_control_characters(): void + { + $this->assertSame('\n'.self::MASKED, Redactor::forMessage("\n".self::PAN)); + } + + public function test_exception_messages_mask_pans(): void + { + $this->assertSame( + "Invalid value '".self::MASKED."' for field 'brand'; expected a case of ".Status::class.'.', + DataHydrationException::invalidEnumValue(Status::class, 'brand', self::PAN)->getMessage(), + ); + + $this->expectException(DataHydrationException::class); + $this->expectExceptionMessage("Unknown discriminator value '".self::MASKED."' in field 'type'"); + + PaymentMethodData::from(['type' => self::PAN]); + } + + public function test_unknown_keys_are_masked_in_the_message_but_kept_on_the_exception(): void + { + try { + StrictUserData::from(['name' => 'Alice', self::PAN => 'x']); + $this->fail('Expected DataHydrationException.'); + } catch (DataHydrationException $e) { + $this->assertStringContainsString("['".self::MASKED."']", $e->getMessage()); + $this->assertSame([(int) self::PAN], $e->unknownKeys); + } + } + + public function test_cast_messages_mask_pans(): void + { + $casts = [ + [new UuidCast, '"'.self::MASKED.'" is not a valid RFC 4122 UUID.'], + [new MoneyCast('USD'), '"'.self::MASKED.'x" is not a valid decimal amount.'], + [new DateTimeCast(inputFormat: 'Y-m-d'), 'Cannot parse "'.self::MASKED.'x" using format "Y-m-d".'], + [new DateTimeImmutableCast(inputFormat: 'Y-m-d'), 'Cannot parse "'.self::MASKED.'x" using format "Y-m-d".'], + ]; + + foreach ($casts as [$cast, $message]) { + try { + $cast->get($cast instanceof UuidCast ? self::PAN : self::PAN.'x'); + $this->fail('Expected InvalidArgumentException.'); + } catch (\InvalidArgumentException $e) { + $this->assertSame($message, $e->getMessage()); + } + } + } + + public function test_failure_masks_every_collected_message(): void + { + $result = HydrationResult::failure(['pan' => 'Rejected '.self::PAN.'.', 'name' => 'Required.']); + + $this->assertSame(['pan' => 'Rejected '.self::MASKED.'.', 'name' => 'Required.'], $result->errors()); + } + + public function test_from_result_errors_mask_pans(): void + { + $result = PaymentMethodData::fromResult(['type' => self::PAN]); + + $this->assertStringContainsString(self::MASKED, $result->errors()['type']); + $this->assertStringNotContainsString(self::PAN, $result->errors()['type']); + } +} diff --git a/tests/SensitiveTest.php b/tests/SensitiveTest.php new file mode 100644 index 0000000..8aea598 --- /dev/null +++ b/tests/SensitiveTest.php @@ -0,0 +1,160 @@ + 'Alice', 'pan' => self::PAN, 'status' => 'active']); + + $this->assertSame(self::PAN, $card->pan); + $this->assertSame(Status::Active, $card->status); + $this->assertSame(self::PAN, $card->toArray()['pan']); + } + + public function test_metadata_lists_sensitive_fields(): void + { + $this->assertSame( + ['pan' => true, 'status' => true, 'cvv' => true], + MetadataRegistry::get(SensitiveCardData::class)->sensitiveFields, + ); + $this->assertSame([], MetadataRegistry::get(UserData::class)->sensitiveFields); + } + + public function test_sensitive_flag_survives_the_file_cache_and_defaults_to_false(): void + { + $param = MetadataRegistry::get(SensitiveCardData::class)->parameters[1]; + + $restored = eval('return '.var_export($param, true).';'); + + $this->assertTrue($restored->isSensitive); + + $state = (array) $param; + unset($state['isSensitive'], $state['isPlain']); + + $this->assertFalse(ParameterMeta::__set_state($state)->isSensitive); + } + + public function test_serialize_refuses_a_set_sensitive_field(): void + { + $card = SensitiveCardData::from(['holder' => 'Alice', 'pan' => self::PAN]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage("Cannot serialize StdOut\\SimpleDataObjects\\Tests\\Fixtures\\SensitiveCardData: field 'pan' is #[Sensitive]."); + + serialize($card); + } + + public function test_serialize_allows_null_sensitive_fields(): void + { + $data = SensitiveOptionalData::from(['holder' => 'Alice']); + + $this->assertTrue(unserialize(serialize($data))->equals($data)); + } + + public function test_debug_output_redacts_sensitive_fields(): void + { + $output = print_r(SensitiveCardData::from(['holder' => 'Alice', 'pan' => self::PAN, 'status' => 'active']), true); + + $this->assertStringNotContainsString(self::PAN, $output); + $this->assertStringNotContainsString('Active', $output); + $this->assertStringContainsString('[pan] => [redacted]', $output); + $this->assertStringContainsString('[status] => [redacted]', $output); + $this->assertStringContainsString('[cvv] => ', $output); + $this->assertStringNotContainsString('[cvv] => [redacted]', $output); + $this->assertStringContainsString('[holder] => Alice', $output); + $this->assertStringContainsString('[note:'.SensitiveCardData::class.':private] => internal', $output); + } + + public function test_classes_without_the_trait_keep_native_serialize_and_debug_output(): void + { + $this->assertFalse(method_exists(UserData::class, '__serialize')); + $this->assertFalse(method_exists(UserData::class, '__debugInfo')); + } + + public function test_sensitive_fields_require_the_trait(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(SensitiveWithoutTraitData::class.': #[Sensitive] fields require `use '.HasSensitiveFields::class.';`'); + + SensitiveWithoutTraitData::from(['pan' => self::PAN]); + } + + public function test_the_trait_is_inherited(): void + { + $data = SensitiveSubclassData::from(['holder' => 'Alice', 'cvv' => '123']); + + $this->assertStringContainsString('[cvv] => [redacted]', print_r($data, true)); + } + + public function test_serialize_initializes_a_lazy_object(): void + { + $lazy = SensitiveOptionalData::fromLazy(['holder' => 'Alice']); + + $this->assertSame('Alice', unserialize(serialize($lazy))->holder); + } + + public function test_debug_output_does_not_initialize_a_lazy_object(): void + { + $lazy = SensitiveCardData::fromLazy(['holder' => 'Alice']); + + $this->assertSame(SensitiveCardData::class." Object\n(\n)\n", print_r($lazy, true)); + $this->assertTrue((new \ReflectionClass(SensitiveCardData::class))->isUninitializedLazyObject($lazy)); + } + + public function test_invalid_sensitive_value_is_not_echoed_by_from(): void + { + try { + SensitiveCardData::from(['holder' => 'Alice', 'pan' => self::PAN, 'status' => 'secret-4242']); + $this->fail('Expected DataHydrationException.'); + } catch (DataHydrationException $e) { + $this->assertSame("Invalid value for sensitive field 'status' of ".SensitiveCardData::class.'.', $e->getMessage()); + $this->assertNull($e->getPrevious()); + } + } + + public function test_invalid_sensitive_value_is_not_echoed_by_from_result(): void + { + $result = SensitiveCardData::fromResult(['holder' => 'Alice', 'pan' => self::PAN, 'status' => 'secret-4242']); + + $this->assertSame( + ['status' => "Invalid value for sensitive field 'status' of ".SensitiveCardData::class.'.'], + $result->errors(), + ); + } + + public function test_invalid_sensitive_value_is_not_echoed_by_with(): void + { + $card = SensitiveCardData::from(['holder' => 'Alice', 'pan' => self::PAN]); + + $this->expectException(DataHydrationException::class); + $this->expectExceptionMessage("Invalid value for sensitive field 'status' of ".SensitiveCardData::class.'.'); + + $card->with(status: 'secret-4242'); + } + + public function test_with_casts_sensitive_and_plain_fields(): void + { + $card = SensitiveCardData::from(['holder' => 'Alice', 'pan' => self::PAN])->with(status: 'inactive', holder: 'Bob'); + + $this->assertSame(Status::Inactive, $card->status); + $this->assertSame('Bob', $card->holder); + } +}