diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a2b2cb..ad5d88d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +## [2.4.0] — 2026-10-16 + +### Added +- `EncryptedCast(provider: ...)` with the `EncryptionKeyProvider` contract + loads the key from a KMS, Vault or HSM instead of the environment. +- `IsEloquentCastable` accepts an output context (`AccountData::class.':storage'`), + so `#[Hidden(except: ['storage'])]` fields are stored and survive the + next load. Without a context nothing changes. + +### Security +- Input is marked `#[\SensitiveParameter]` in every entry point, compiled + hydrator, pipe, cast and Eloquent cast, so stack traces no longer carry + card numbers when `zend.exception_ignore_args` is off. +- The `EncryptedCast` derived key no longer shows up in `var_export()`, + `(array)` casts or `json_encode()` of the cast or its metadata. +- `DateTimeCast` and `DateTimeImmutableCast` without `inputFormat` mask card + numbers in PHP's own `DateMalformedStringException`; the exception type + is unchanged. + ## [2.3.0] — 2026-10-16 ### Added @@ -568,7 +587,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.3.0...HEAD +[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.4.0...HEAD +[2.4.0]: https://github.com/std-out/simple-data-objects/compare/v2.3.0...v2.4.0 [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 diff --git a/docs/attributes/sensitive.md b/docs/attributes/sensitive.md index 61c981b..4474819 100644 --- a/docs/attributes/sensitive.md +++ b/docs/attributes/sensitive.md @@ -55,7 +55,7 @@ To queue a job that needs the card, pass an identifier (or a token from your pay - `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. +- Every entry point (`from*()`, `tryFrom()`, `validate()`, `with()`, `collection()`), the compiled hydrators, pipes, casts and the Eloquent casts mark their input `#[\SensitiveParameter]`, so with `zend.exception_ignore_args=Off` stack traces show `Object(SensitiveParameterValue)` instead of the input. Your own casts and pipes need the same attribute on their `$value` / `$data` parameter. Keep `zend.exception_ignore_args=On` in production anyway: it also covers application frames. ## PAN masking diff --git a/docs/casts/custom.md b/docs/casts/custom.md index aad190a..6170993 100644 --- a/docs/casts/custom.md +++ b/docs/casts/custom.md @@ -9,14 +9,16 @@ namespace StdOut\SimpleDataObjects\Contracts; interface CastsValue { - public function get(mixed $value): mixed; - public function set(mixed $value): mixed; + public function get(#[\SensitiveParameter] mixed $value): mixed; + public function set(#[\SensitiveParameter] mixed $value): mixed; } ``` - `get()` — called during **hydration** (raw input → PHP value) - `set()` — called during **serialization** (PHP value → serializable form) +PHP does not inherit `#[\SensitiveParameter]` from the interface. Add it to your own `get()` / `set()` so the value stays out of stack traces. + ## Example: Money Cast ```php diff --git a/docs/casts/encrypted.md b/docs/casts/encrypted.md index b53aba2..795e5ff 100644 --- a/docs/casts/encrypted.md +++ b/docs/casts/encrypted.md @@ -28,7 +28,28 @@ class UserData extends BaseData } ``` -A literal key is also accepted (`new EncryptedCast(key: '...')`) — useful in tests, but **avoid it in application code**: the key would live in your source tree. Exactly one of `key` / `env` must be provided; a missing or empty environment variable throws a `RuntimeException` immediately rather than silently encrypting with an empty key. +A literal key is also accepted (`new EncryptedCast(key: '...')`) — useful in tests, but **avoid it in application code**: the key would live in your source tree. Exactly one of `key` / `env` / `provider` must be provided; a missing or empty environment variable throws a `RuntimeException` immediately rather than silently encrypting with an empty key. + +### Key provider (KMS, Vault, HSM) + +To keep the key out of the process environment, pass a class implementing `EncryptionKeyProvider`. It is instantiated without arguments when the cast is built, and its `key()` is called once: + +```php +use StdOut\SimpleDataObjects\Contracts\EncryptionKeyProvider; + +final class KmsKeyProvider implements EncryptionKeyProvider +{ + public function key(): string + { + return KmsClient::default()->decrypt(file_get_contents('/run/secrets/data-key.enc')); + } +} + +#[Cast(new EncryptedCast(provider: KmsKeyProvider::class))] +public readonly string $taxId, +``` + +A class that does not implement the interface throws `InvalidArgumentException`; an empty key throws `RuntimeException`. ### Hydration (decrypt) @@ -67,9 +88,9 @@ 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. +- **Hidden from every dump.** The derived key is held in a `\SensitiveParameterValue`, so `var_dump()`, `print_r()`, `var_export()`, `(array)` casts and `json_encode()` of the cast, or of metadata that contains it, never show it. The constructor parameter and the values passed to `get()` / `set()` are marked `#[\SensitiveParameter]`, so neither the key nor the plaintext appears 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. +It cannot protect the key everywhere: a key read with `env:` stays in the process environment — use a [key provider](#key-provider-kms-vault-hsm) to avoid that. ## Tamper Detection diff --git a/docs/features/serialization.md b/docs/features/serialization.md index 39a678c..06912c7 100644 --- a/docs/features/serialization.md +++ b/docs/features/serialization.md @@ -84,6 +84,6 @@ Casts transform values in both directions. On `toArray()`, the `set()` method of | `DateTimeCast('Y-m-d')` | `DateTime` object | `'2025-06-15'` | | `BooleanCast` | `true` | `true` | | `JsonCast` | `['key' => 'val']` | `'{"key":"val"}'` | -| `EncryptedCast('key')` | plaintext string | AES ciphertext | +| `EncryptedCast('key')` | plaintext string | XSalsa20-Poly1305 ciphertext | See [Casts →](../casts/index.md) for the full reference. diff --git a/docs/laravel/eloquent-casting.md b/docs/laravel/eloquent-casting.md index 5b9cf1b..1f31f55 100644 --- a/docs/laravel/eloquent-casting.md +++ b/docs/laravel/eloquent-casting.md @@ -41,6 +41,32 @@ If you prefer the older `$casts` property style, the mapping is identical either protected $casts = ['address' => AddressData::class]; ``` +### Keeping `#[Hidden]` fields in storage + +The column is written with `toJson()`, so `#[Hidden]` fields are dropped and a required hidden field makes the next load fail. Pass an output [context](../attributes/hidden.md#context-groups) after the class name and list it in `except` to store such a field while keeping it out of API responses: + +```php +class AccountData extends BaseData implements Castable +{ + use IsEloquentCastable; + + public function __construct( + public readonly string $login, + #[Hidden(except: ['storage'])] + public readonly string $apiToken, + ) {} +} + +protected function casts(): array +{ + return ['account' => AccountData::class.':storage']; +} + +$user->account->toArray(); // ['login' => '...'] — apiToken stays hidden everywhere else +``` + +Dirty-tracking compares the same context, so a change to a stored hidden field marks the attribute dirty. + ## Collections `AsDataCollection::of()` casts a JSON array column to a `TypedDataCollection`, mirroring Laravel's own `AsCollection::using()` idiom. The item class needs no trait or interface — only `AsDataCollection` itself is `Castable`: diff --git a/src/BaseData.php b/src/BaseData.php index 71cefe4..4fe21b9 100644 --- a/src/BaseData.php +++ b/src/BaseData.php @@ -37,7 +37,7 @@ abstract class BaseData implements Arrayable, DataObject, JsonSerializable, Stri * a plain object (public properties), or an existing instance of the * same class (returned as-is — instances are immutable). */ - public static function from(mixed $data): static + public static function from(#[\SensitiveParameter] mixed $data): static { $hydrate = HydratorCompiler::$hydrators[static::class] ?? HydratorCompiler::compile(static::class); @@ -60,7 +60,7 @@ public static function from(mixed $data): static * created but only some are ever read. Note that invalid input therefore * throws on first access, not here. */ - public static function fromLazy(mixed $data): static + public static function fromLazy(#[\SensitiveParameter] mixed $data): static { $class = static::class; $reflector = self::$reflectors[$class] ??= new \ReflectionClass($class); @@ -103,7 +103,7 @@ public static function fromLazy(mixed $data): static }); } - public static function tryFrom(mixed $data): ?static + public static function tryFrom(#[\SensitiveParameter] mixed $data): ?static { try { return static::from($data); @@ -118,7 +118,7 @@ public static function tryFrom(mixed $data): ?static * * @return HydrationResult */ - public static function fromResult(mixed $data): HydrationResult + public static function fromResult(#[\SensitiveParameter] mixed $data): HydrationResult { $collect = HydratorCompiler::$collectingHydrators[static::class] ?? HydratorCompiler::compileCollecting(static::class); @@ -146,7 +146,7 @@ public static function fromResult(mixed $data): HydrationResult /** * @return TypedDataCollection */ - public static function collection(iterable $items): TypedDataCollection + public static function collection(#[\SensitiveParameter] iterable $items): TypedDataCollection { return TypedDataCollection::of(static::class, $items); } @@ -164,7 +164,7 @@ public static function jsonSchema(): array * * @return LazyCollection */ - public static function lazyCollection(iterable $items): LazyCollection + public static function lazyCollection(#[\SensitiveParameter] iterable $items): LazyCollection { $class = static::class; @@ -199,12 +199,12 @@ public static function lazyXml(string $uri, string $path): LazyCollection } /** Explicit alias of from() for JSON input — same decoding and errors. */ - public static function fromJson(string $json): static + public static function fromJson(#[\SensitiveParameter] string $json): static { return static::from($json); } - public function with(mixed ...$overrides): static + public function with(#[\SensitiveParameter] mixed ...$overrides): static { $meta = MetadataRegistry::get(static::class); $current = get_object_vars($this); @@ -250,7 +250,7 @@ public function with(mixed ...$overrides): static return $instance; } - public static function fromValidated(mixed $data): static + public static function fromValidated(#[\SensitiveParameter] mixed $data): static { $array = is_array($data) ? $data : InputNormalizer::normalize(static::class, $data); $meta = MetadataRegistry::get(static::class); @@ -276,7 +276,7 @@ public static function fromValidated(mixed $data): static * * @return HydrationResult */ - public static function fromValidatedResult(mixed $data): HydrationResult + public static function fromValidatedResult(#[\SensitiveParameter] mixed $data): HydrationResult { if (! is_array($data)) { try { @@ -329,7 +329,7 @@ public static function fromValidatedResult(mixed $data): HydrationResult } /** @throws ValidationException */ - public static function validate(mixed $data): void + public static function validate(#[\SensitiveParameter] mixed $data): void { $meta = MetadataRegistry::get(static::class); @@ -358,7 +358,7 @@ public static function validate(mixed $data): void * * @throws DataHydrationException */ - private static function resolveDiscriminated(ClassMeta $meta, array $data): string + private static function resolveDiscriminated(ClassMeta $meta, #[\SensitiveParameter] array $data): string { /** @var class-string|null $target */ $target = $meta->resolveDiscriminatedClass($data); diff --git a/src/Casts/BooleanCast.php b/src/Casts/BooleanCast.php index 829148e..752cdb1 100644 --- a/src/Casts/BooleanCast.php +++ b/src/Casts/BooleanCast.php @@ -16,7 +16,7 @@ public static function __set_state(array $state): self return new self; } - public function get(mixed $value): ?bool + public function get(#[\SensitiveParameter] mixed $value): ?bool { if ($value === null) { return null; @@ -29,7 +29,7 @@ public function get(mixed $value): ?bool return in_array(strtolower((string) $value), self::TRUTHY, true); } - public function set(mixed $value): ?bool + public function set(#[\SensitiveParameter] mixed $value): ?bool { return $value === null ? null : (bool) $value; } diff --git a/src/Casts/CommaSeparatedCast.php b/src/Casts/CommaSeparatedCast.php index 111cc91..2390175 100644 --- a/src/Casts/CommaSeparatedCast.php +++ b/src/Casts/CommaSeparatedCast.php @@ -19,7 +19,7 @@ public static function __set_state(array $state): self return new self($state['separator'], $state['trim']); } - public function get(mixed $value): ?array + public function get(#[\SensitiveParameter] mixed $value): ?array { if ($value === null) { return null; @@ -40,7 +40,7 @@ public function get(mixed $value): ?array return $this->trim ? array_map('trim', $parts) : $parts; } - public function set(mixed $value): ?string + public function set(#[\SensitiveParameter] mixed $value): ?string { if ($value === null) { return null; diff --git a/src/Casts/DateTimeCast.php b/src/Casts/DateTimeCast.php index 2473278..0be55ad 100644 --- a/src/Casts/DateTimeCast.php +++ b/src/Casts/DateTimeCast.php @@ -4,6 +4,7 @@ namespace StdOut\SimpleDataObjects\Casts; +use DateMalformedStringException; use DateTime; use DateTimeImmutable; use DateTimeInterface; @@ -30,7 +31,7 @@ public static function __set_state(array $state): self return new self($state['outputFormat'], $state['inputFormat'], $state['timezone']); } - public function get(mixed $value): ?DateTime + public function get(#[\SensitiveParameter] mixed $value): ?DateTime { if ($value === null) { return null; @@ -58,10 +59,14 @@ public function get(mixed $value): ?DateTime return $dt; } - return new DateTime((string) $value, $tz); + try { + return new DateTime((string) $value, $tz); + } catch (DateMalformedStringException $e) { + throw new DateMalformedStringException(Redactor::forMessage($e->getMessage())); + } } - public function set(mixed $value): ?string + public function set(#[\SensitiveParameter] mixed $value): ?string { if ($value === null) { return null; diff --git a/src/Casts/DateTimeImmutableCast.php b/src/Casts/DateTimeImmutableCast.php index f6c5d35..cc23717 100644 --- a/src/Casts/DateTimeImmutableCast.php +++ b/src/Casts/DateTimeImmutableCast.php @@ -4,6 +4,7 @@ namespace StdOut\SimpleDataObjects\Casts; +use DateMalformedStringException; use DateTime; use DateTimeImmutable; use DateTimeInterface; @@ -30,7 +31,7 @@ public static function __set_state(array $state): self return new self($state['outputFormat'], $state['inputFormat'], $state['timezone']); } - public function get(mixed $value): ?DateTimeImmutable + public function get(#[\SensitiveParameter] mixed $value): ?DateTimeImmutable { if ($value === null) { return null; @@ -58,10 +59,14 @@ public function get(mixed $value): ?DateTimeImmutable return $dt; } - return new DateTimeImmutable((string) $value, $tz); + try { + return new DateTimeImmutable((string) $value, $tz); + } catch (DateMalformedStringException $e) { + throw new DateMalformedStringException(Redactor::forMessage($e->getMessage())); + } } - public function set(mixed $value): ?string + public function set(#[\SensitiveParameter] mixed $value): ?string { if ($value === null) { return null; diff --git a/src/Casts/EncryptedCast.php b/src/Casts/EncryptedCast.php index c1e76ea..86778eb 100644 --- a/src/Casts/EncryptedCast.php +++ b/src/Casts/EncryptedCast.php @@ -9,6 +9,7 @@ use RuntimeException; use SodiumException; use StdOut\SimpleDataObjects\Contracts\CastsValue; +use StdOut\SimpleDataObjects\Contracts\EncryptionKeyProvider; use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema; /** @@ -24,35 +25,35 @@ */ final class EncryptedCast implements CastsValue, ProvidesJsonSchema { - private readonly string $secretKey; + private readonly \SensitiveParameterValue $secretKey; /** * Attribute arguments only allow constant expressions — env() or config() * calls are a compile error there. Pass the NAME of an environment - * variable via $env to keep the key itself out of source code: + * variable via $env, or an EncryptionKeyProvider class via $provider, to + * keep the key itself out of source code: * * #[Cast(new EncryptedCast(env: 'DATA_ENCRYPTION_KEY'))] + * #[Cast(new EncryptedCast(provider: KmsKeyProvider::class))] + * + * @param class-string|null $provider */ public function __construct( #[\SensitiveParameter] ?string $key = null, ?string $env = null, + ?string $provider = null, ) { - if (($key === null) === ($env === null)) { - throw new InvalidArgumentException('Provide exactly one of $key or $env.'); - } - - if ($env !== null) { - $key = $_ENV[$env] ?? getenv($env); - - if (! is_string($key) || $key === '') { - throw new RuntimeException("Environment variable '{$env}' is not set or empty."); - } - } - - $this->secretKey = sodium_crypto_generichash($key, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + $key = match (true) { + ($key !== null) + ($env !== null) + ($provider !== null) !== 1 => throw new InvalidArgumentException('Provide exactly one of $key, $env or $provider.'), + $env !== null => self::keyFromEnvironment($env), + $provider !== null => self::keyFromProvider($provider), + default => $key, + }; + + $this->secretKey = new \SensitiveParameterValue(sodium_crypto_generichash($key, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES)); } - public function get(mixed $value): ?string + public function get(#[\SensitiveParameter] mixed $value): ?string { if ($value === null) { return null; @@ -71,7 +72,7 @@ public function get(mixed $value): ?string $nonce = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $ciphertext = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->secretKey); + $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->secretKey->getValue()); if ($plaintext === false) { throw new RuntimeException('Decryption failed: authentication tag mismatch.'); @@ -80,7 +81,7 @@ public function get(mixed $value): ?string return $plaintext; } - public function set(mixed $value): ?string + public function set(#[\SensitiveParameter] mixed $value): ?string { if ($value === null) { return null; @@ -91,7 +92,7 @@ public function set(mixed $value): ?string } $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - $ciphertext = sodium_crypto_secretbox((string) $value, $nonce, $this->secretKey); + $ciphertext = sodium_crypto_secretbox((string) $value, $nonce, $this->secretKey->getValue()); return sodium_bin2base64($nonce.$ciphertext, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); } @@ -112,4 +113,30 @@ public function jsonSchema(): array { return ['type' => 'string']; } + + private static function keyFromEnvironment(string $env): string + { + $key = $_ENV[$env] ?? getenv($env); + + if (! is_string($key) || $key === '') { + throw new RuntimeException("Environment variable '{$env}' is not set or empty."); + } + + return $key; + } + + private static function keyFromProvider(string $provider): string + { + if (! is_subclass_of($provider, EncryptionKeyProvider::class)) { + throw new InvalidArgumentException("Key provider '{$provider}' must implement ".EncryptionKeyProvider::class.'.'); + } + + $key = (new $provider)->key(); + + if ($key === '') { + throw new RuntimeException("Key provider '{$provider}' returned an empty key."); + } + + return $key; + } } diff --git a/src/Casts/EnumCast.php b/src/Casts/EnumCast.php index bf36ad4..559420d 100644 --- a/src/Casts/EnumCast.php +++ b/src/Casts/EnumCast.php @@ -20,7 +20,7 @@ public static function __set_state(array $state): self return new self($state['enumClass'], $state['default']); } - public function get(mixed $value): ?UnitEnum + public function get(#[\SensitiveParameter] mixed $value): ?UnitEnum { if ($value === null) { return $this->default; @@ -37,7 +37,7 @@ public function get(mixed $value): ?UnitEnum return $this->default; } - public function set(mixed $value): mixed + public function set(#[\SensitiveParameter] mixed $value): mixed { if ($value === null) { return null; diff --git a/src/Casts/FloatCast.php b/src/Casts/FloatCast.php index 60b3c95..1b5a699 100644 --- a/src/Casts/FloatCast.php +++ b/src/Casts/FloatCast.php @@ -18,7 +18,7 @@ public static function __set_state(array $state): self return new self($state['decimals']); } - public function get(mixed $value): ?float + public function get(#[\SensitiveParameter] mixed $value): ?float { if ($value === null) { return null; @@ -29,7 +29,7 @@ public function get(mixed $value): ?float return $this->decimals >= 0 ? round($float, $this->decimals) : $float; } - public function set(mixed $value): ?float + public function set(#[\SensitiveParameter] mixed $value): ?float { return $this->get($value); } diff --git a/src/Casts/IntegerCast.php b/src/Casts/IntegerCast.php index 67150e6..c2d08cf 100644 --- a/src/Casts/IntegerCast.php +++ b/src/Casts/IntegerCast.php @@ -14,12 +14,12 @@ public static function __set_state(array $state): self return new self; } - public function get(mixed $value): ?int + public function get(#[\SensitiveParameter] mixed $value): ?int { return $value === null ? null : (int) $value; } - public function set(mixed $value): ?int + public function set(#[\SensitiveParameter] mixed $value): ?int { return $value === null ? null : (int) $value; } diff --git a/src/Casts/JsonCast.php b/src/Casts/JsonCast.php index f0c5a36..207a35f 100644 --- a/src/Casts/JsonCast.php +++ b/src/Casts/JsonCast.php @@ -19,7 +19,7 @@ public static function __set_state(array $state): self return new self($state['assoc'], $state['encodeFlags']); } - public function get(mixed $value): mixed + public function get(#[\SensitiveParameter] mixed $value): mixed { if ($value === null) { return null; @@ -32,7 +32,7 @@ public function get(mixed $value): mixed return json_decode((string) $value, $this->assoc, 64, JSON_THROW_ON_ERROR); } - public function set(mixed $value): ?string + public function set(#[\SensitiveParameter] mixed $value): ?string { if ($value === null) { return null; diff --git a/src/Casts/MoneyCast.php b/src/Casts/MoneyCast.php index 90b7119..b91fa51 100644 --- a/src/Casts/MoneyCast.php +++ b/src/Casts/MoneyCast.php @@ -33,7 +33,7 @@ public static function __set_state(array $state): self return new self($state['currency'], $state['decimals']); } - public function get(mixed $value): ?Money + public function get(#[\SensitiveParameter] mixed $value): ?Money { if ($value === null) { return null; @@ -55,7 +55,7 @@ public function get(mixed $value): ?Money return new Money($this->resolveAmount($value), $this->currency); } - public function set(mixed $value): ?int + public function set(#[\SensitiveParameter] mixed $value): ?int { if ($value === null) { return null; @@ -66,7 +66,7 @@ public function set(mixed $value): ?int return $value->amount; } - private function resolveAmount(mixed $amount): int + private function resolveAmount(#[\SensitiveParameter] mixed $amount): int { if (is_int($amount)) { return $amount; @@ -87,7 +87,7 @@ private function resolveAmount(mixed $amount): int * representation (float gives 100 for "1.005" but 201 for "2.005", * despite both being an equally "exact" half-cent). */ - private function parseDecimalString(string $amount): int + private function parseDecimalString(#[\SensitiveParameter] string $amount): int { $amount = trim($amount); diff --git a/src/Casts/TrimCast.php b/src/Casts/TrimCast.php index f2c7c66..1d4b403 100644 --- a/src/Casts/TrimCast.php +++ b/src/Casts/TrimCast.php @@ -22,7 +22,7 @@ public static function __set_state(array $state): self return new self($state['transform']); } - public function get(mixed $value): ?string + public function get(#[\SensitiveParameter] mixed $value): ?string { if ($value === null) { return null; @@ -37,7 +37,7 @@ public function get(mixed $value): ?string }; } - public function set(mixed $value): ?string + public function set(#[\SensitiveParameter] mixed $value): ?string { return $this->get($value); } diff --git a/src/Casts/UuidCast.php b/src/Casts/UuidCast.php index 0f8e61f..05b8810 100644 --- a/src/Casts/UuidCast.php +++ b/src/Casts/UuidCast.php @@ -18,7 +18,7 @@ public static function __set_state(array $state): self return new self; } - public function get(mixed $value): ?string + public function get(#[\SensitiveParameter] mixed $value): ?string { if ($value === null) { return null; @@ -33,7 +33,7 @@ public function get(mixed $value): ?string return strtolower($uuid); } - public function set(mixed $value): ?string + public function set(#[\SensitiveParameter] mixed $value): ?string { return $this->get($value); } diff --git a/src/Concerns/HasLaravelIntegration.php b/src/Concerns/HasLaravelIntegration.php index 6188f6d..0b4c4e3 100644 --- a/src/Concerns/HasLaravelIntegration.php +++ b/src/Concerns/HasLaravelIntegration.php @@ -13,7 +13,7 @@ trait HasLaravelIntegration { - abstract public static function from(mixed $data): static; + abstract public static function from(#[\SensitiveParameter] mixed $data): static; abstract public function toArray(?string $context = null): array; diff --git a/src/Concerns/IsEloquentCastable.php b/src/Concerns/IsEloquentCastable.php index 24c74d8..ea1c08f 100644 --- a/src/Concerns/IsEloquentCastable.php +++ b/src/Concerns/IsEloquentCastable.php @@ -20,6 +20,6 @@ trait IsEloquentCastable { public static function castUsing(array $arguments): DataObjectCast { - return new DataObjectCast(static::class); + return new DataObjectCast(static::class, $arguments[0] ?? null); } } diff --git a/src/Concerns/WireableData.php b/src/Concerns/WireableData.php index 503cba9..7c438fb 100644 --- a/src/Concerns/WireableData.php +++ b/src/Concerns/WireableData.php @@ -16,7 +16,7 @@ */ trait WireableData { - abstract public static function from(mixed $data): static; + abstract public static function from(#[\SensitiveParameter] mixed $data): static; abstract public function toArray(): array; @@ -25,7 +25,7 @@ public function toLivewire(): array return $this->toArray(); } - public static function fromLivewire(mixed $value): static + public static function fromLivewire(#[\SensitiveParameter] mixed $value): static { return static::from($value); } diff --git a/src/Contracts/CastsValue.php b/src/Contracts/CastsValue.php index 1ae67c4..ad7cbe0 100644 --- a/src/Contracts/CastsValue.php +++ b/src/Contracts/CastsValue.php @@ -6,7 +6,7 @@ interface CastsValue { - public function get(mixed $value): mixed; + public function get(#[\SensitiveParameter] mixed $value): mixed; - public function set(mixed $value): mixed; + public function set(#[\SensitiveParameter] mixed $value): mixed; } diff --git a/src/Contracts/DataPipe.php b/src/Contracts/DataPipe.php index 518111b..2a536db 100644 --- a/src/Contracts/DataPipe.php +++ b/src/Contracts/DataPipe.php @@ -7,5 +7,5 @@ interface DataPipe { /** @param class-string $dataClass */ - public function handle(array $data, string $dataClass, callable $next): array; + public function handle(#[\SensitiveParameter] array $data, string $dataClass, callable $next): array; } diff --git a/src/Contracts/EncryptionKeyProvider.php b/src/Contracts/EncryptionKeyProvider.php new file mode 100644 index 0000000..67b7d3d --- /dev/null +++ b/src/Contracts/EncryptionKeyProvider.php @@ -0,0 +1,10 @@ + $dataClass */ public function __construct(private readonly string $dataClass) {} - public function get(Model $model, string $key, mixed $value, array $attributes): ?TypedDataCollection + public function get(Model $model, string $key, #[\SensitiveParameter] mixed $value, #[\SensitiveParameter] array $attributes): ?TypedDataCollection { if ($value === null) { return null; @@ -39,7 +39,7 @@ public function get(Model $model, string $key, mixed $value, array $attributes): return $this->dataClass::collection($items); } - public function set(Model $model, string $key, mixed $value, array $attributes): ?string + public function set(Model $model, string $key, #[\SensitiveParameter] mixed $value, #[\SensitiveParameter] array $attributes): ?string { if ($value === null) { return null; @@ -51,7 +51,7 @@ public function set(Model $model, string $key, mixed $value, array $attributes): } /** See DataObjectCast::compare() for why this exists. */ - public function compare(Model $model, string $key, mixed $firstValue, mixed $secondValue): bool + public function compare(Model $model, string $key, #[\SensitiveParameter] mixed $firstValue, #[\SensitiveParameter] mixed $secondValue): bool { if ($firstValue === $secondValue) { return true; diff --git a/src/Laravel/DataObjectCast.php b/src/Laravel/DataObjectCast.php index ee1e4c1..c11da85 100644 --- a/src/Laravel/DataObjectCast.php +++ b/src/Laravel/DataObjectCast.php @@ -14,16 +14,19 @@ final class DataObjectCast implements CastsAttributes { /** @param class-string $dataClass */ - public function __construct(private readonly string $dataClass) {} + public function __construct( + private readonly string $dataClass, + private readonly ?string $context = null, + ) {} - public function get(Model $model, string $key, mixed $value, array $attributes): ?BaseData + public function get(Model $model, string $key, #[\SensitiveParameter] mixed $value, #[\SensitiveParameter] array $attributes): ?BaseData { return $value === null ? null : $this->dataClass::from($value); } - public function set(Model $model, string $key, mixed $value, array $attributes): ?string + public function set(Model $model, string $key, #[\SensitiveParameter] mixed $value, #[\SensitiveParameter] array $attributes): ?string { - return $value === null ? null : $this->dataClass::from($value)->toJson(); + return $value === null ? null : $this->dataClass::from($value)->toJson(context: $this->context); } /** @@ -31,7 +34,7 @@ public function set(Model $model, string $key, mixed $value, array $attributes): * 12+ only. `ComparesCastableAttributes` isn't declared here since it * doesn't exist before Laravel 12. */ - public function compare(Model $model, string $key, mixed $firstValue, mixed $secondValue): bool + public function compare(Model $model, string $key, #[\SensitiveParameter] mixed $firstValue, #[\SensitiveParameter] mixed $secondValue): bool { if ($firstValue === $secondValue) { return true; @@ -41,6 +44,9 @@ public function compare(Model $model, string $key, mixed $firstValue, mixed $sec return false; } - return $this->dataClass::from($firstValue)->equals($this->dataClass::from($secondValue)); + $first = $this->dataClass::from($firstValue); + $second = $this->dataClass::from($secondValue); + + return $first::class === $second::class && $first->toArray($this->context) === $second->toArray($this->context); } } diff --git a/src/Pipes/LowercaseValuePipe.php b/src/Pipes/LowercaseValuePipe.php index 8525b42..d7e3a32 100644 --- a/src/Pipes/LowercaseValuePipe.php +++ b/src/Pipes/LowercaseValuePipe.php @@ -8,7 +8,7 @@ final class LowercaseValuePipe implements ValuePipe { - public function handle(mixed $value, string $paramName, callable $next): mixed + public function handle(#[\SensitiveParameter] mixed $value, string $paramName, callable $next): mixed { return $next(is_string($value) ? mb_strtolower($value) : $value); } diff --git a/src/Pipes/NullifyEmptyStringValuePipe.php b/src/Pipes/NullifyEmptyStringValuePipe.php index d0a11a3..a4d0a94 100644 --- a/src/Pipes/NullifyEmptyStringValuePipe.php +++ b/src/Pipes/NullifyEmptyStringValuePipe.php @@ -8,7 +8,7 @@ final class NullifyEmptyStringValuePipe implements ValuePipe { - public function handle(mixed $value, string $paramName, callable $next): mixed + public function handle(#[\SensitiveParameter] mixed $value, string $paramName, callable $next): mixed { return $next($value === '' ? null : $value); } diff --git a/src/Pipes/NullifyEmptyStringsPipe.php b/src/Pipes/NullifyEmptyStringsPipe.php index 6a13955..29e6155 100644 --- a/src/Pipes/NullifyEmptyStringsPipe.php +++ b/src/Pipes/NullifyEmptyStringsPipe.php @@ -8,7 +8,7 @@ final class NullifyEmptyStringsPipe implements DataPipe { - public function handle(array $data, string $dataClass, callable $next): array + public function handle(#[\SensitiveParameter] array $data, string $dataClass, callable $next): array { return $next(array_map( static fn (mixed $value): mixed => $value === '' ? null : $value, diff --git a/src/Pipes/TrimStringsPipe.php b/src/Pipes/TrimStringsPipe.php index 20298e0..3e52836 100644 --- a/src/Pipes/TrimStringsPipe.php +++ b/src/Pipes/TrimStringsPipe.php @@ -8,7 +8,7 @@ final class TrimStringsPipe implements DataPipe { - public function handle(array $data, string $dataClass, callable $next): array + public function handle(#[\SensitiveParameter] array $data, string $dataClass, callable $next): array { return $next(array_map( static fn (mixed $value): mixed => is_string($value) ? trim($value) : $value, diff --git a/src/Pipes/TrimValuePipe.php b/src/Pipes/TrimValuePipe.php index bae6a81..05c39d6 100644 --- a/src/Pipes/TrimValuePipe.php +++ b/src/Pipes/TrimValuePipe.php @@ -8,7 +8,7 @@ final class TrimValuePipe implements ValuePipe { - public function handle(mixed $value, string $paramName, callable $next): mixed + public function handle(#[\SensitiveParameter] mixed $value, string $paramName, callable $next): mixed { return $next(is_string($value) ? trim($value) : $value); } diff --git a/src/Pipes/UppercaseValuePipe.php b/src/Pipes/UppercaseValuePipe.php index 9e96146..c77143b 100644 --- a/src/Pipes/UppercaseValuePipe.php +++ b/src/Pipes/UppercaseValuePipe.php @@ -8,7 +8,7 @@ final class UppercaseValuePipe implements ValuePipe { - public function handle(mixed $value, string $paramName, callable $next): mixed + public function handle(#[\SensitiveParameter] mixed $value, string $paramName, callable $next): mixed { return $next(is_string($value) ? mb_strtoupper($value) : $value); } diff --git a/src/Support/ClassMeta.php b/src/Support/ClassMeta.php index 8050665..1bf0475 100644 --- a/src/Support/ClassMeta.php +++ b/src/Support/ClassMeta.php @@ -99,7 +99,7 @@ public function __construct( * * @return class-string|null */ - public function resolveDiscriminatedClass(array $data): ?string + public function resolveDiscriminatedClass(#[\SensitiveParameter] array $data): ?string { $value = $data[$this->discriminatorField] ?? null; @@ -118,7 +118,7 @@ public function resolveDiscriminatedClass(array $data): ?string * @param array $data * @return array{0: array, 1: array>} */ - public function validationInput(array $data): array + public function validationInput(#[\SensitiveParameter] array $data): array { if ($this->validationAliases === [] && ! $this->cascadesNestedRules) { return [$data, $this->validationRules]; @@ -134,7 +134,7 @@ public function validationInput(array $data): array * @param array> $rules * @return array */ - private function prepareValidation(array $data, string $prefix, array &$rules): array + private function prepareValidation(#[\SensitiveParameter] array $data, string $prefix, array &$rules): array { foreach ($this->validationAliases as $primary => $aliases) { if (array_key_exists($primary, $data)) { @@ -180,7 +180,7 @@ private function prepareValidation(array $data, string $prefix, array &$rules): * @param array> $rules * @return array */ - private static function prepareNestedValidation(string $class, array $data, string $prefix, array &$rules): array + private static function prepareNestedValidation(string $class, #[\SensitiveParameter] array $data, string $prefix, array &$rules): array { $meta = MetadataRegistry::get($class); diff --git a/src/Support/HydratorCompiler.php b/src/Support/HydratorCompiler.php index 4d3640a..0e05a8d 100644 --- a/src/Support/HydratorCompiler.php +++ b/src/Support/HydratorCompiler.php @@ -169,7 +169,7 @@ public static function generate(string $class, ClassMeta $meta): string $field = var_export($meta->discriminatorField, true); return <<resolveDiscriminatedClass(\$d) ?? throw \\StdOut\\SimpleDataObjects\\Exceptions\\DataHydrationException::unresolvedDiscriminator({$classExport}, {$field}, \$d[{$field}] ?? null); return \$c::from(\$d); } @@ -181,7 +181,7 @@ static function (array \$d) use (\$meta): \\{$class} { $body = self::buildPropertyAssignments($class, $meta); return <<discriminatorField, true); return <<resolveDiscriminatedClass(\$d); if (\$c === null) { return {$resultClass}::failure([{$field} => \\StdOut\\SimpleDataObjects\\Exceptions\\DataHydrationException::unresolvedDiscriminator({$classExport}, {$field}, \$d[{$field}] ?? null)->getMessage()]); @@ -470,7 +470,7 @@ static function (array \$d) use (\$meta): {$resultClass} { $fields = self::buildCollectingPropertyAssignments($class, $meta); return <<toArray(); diff --git a/src/Support/PipelineRunner.php b/src/Support/PipelineRunner.php index 48039ed..f8fe9c7 100644 --- a/src/Support/PipelineRunner.php +++ b/src/Support/PipelineRunner.php @@ -23,7 +23,7 @@ final class PipelineRunner * @param list> $pipes * @param class-string $dataClass */ - public static function run(array $data, string $dataClass, array $pipes): array + public static function run(#[\SensitiveParameter] array $data, string $dataClass, array $pipes): array { if ($pipes === []) { return $data; @@ -31,8 +31,8 @@ public static function run(array $data, string $dataClass, array $pipes): array $pipeline = array_reduce( array_reverse($pipes), - static fn (callable $next, string $pipeClass): \Closure => static fn (array $d): array => self::instance($pipeClass)->handle($d, $dataClass, $next), - static fn (array $d): array => $d, + static fn (callable $next, string $pipeClass): \Closure => static fn (#[\SensitiveParameter] array $d): array => self::instance($pipeClass)->handle($d, $dataClass, $next), + static fn (#[\SensitiveParameter] array $d): array => $d, ); return $pipeline($data); @@ -43,7 +43,7 @@ public static function run(array $data, string $dataClass, array $pipes): array * * @param list> $pipes */ - public static function runOnValue(mixed $value, string $paramName, array $pipes): mixed + public static function runOnValue(#[\SensitiveParameter] mixed $value, string $paramName, array $pipes): mixed { if ($pipes === []) { return $value; @@ -51,8 +51,8 @@ public static function runOnValue(mixed $value, string $paramName, array $pipes) $pipeline = array_reduce( array_reverse($pipes), - static fn (callable $next, string $pipeClass): \Closure => static fn (mixed $v): mixed => self::instance($pipeClass)->handle($v, $paramName, $next), - static fn (mixed $v): mixed => $v, + static fn (callable $next, string $pipeClass): \Closure => static fn (#[\SensitiveParameter] mixed $v): mixed => self::instance($pipeClass)->handle($v, $paramName, $next), + static fn (#[\SensitiveParameter] mixed $v): mixed => $v, ); return $pipeline($value); diff --git a/src/Support/Redactor.php b/src/Support/Redactor.php index ab28bc5..31d9335 100644 --- a/src/Support/Redactor.php +++ b/src/Support/Redactor.php @@ -10,7 +10,7 @@ final class Redactor private const string PAN_PATTERN = '/(?pipes !== []) { $value = PipelineRunner::runOnValue($value, $meta->phpName, $meta->pipes); @@ -40,7 +40,7 @@ public static function cast(ParameterMeta $meta, mixed $value): mixed return $value; } - public static function castSensitive(ParameterMeta $meta, mixed $value, string $class): mixed + public static function castSensitive(ParameterMeta $meta, #[\SensitiveParameter] mixed $value, string $class): mixed { try { return self::cast($meta, $value); @@ -49,7 +49,7 @@ public static function castSensitive(ParameterMeta $meta, mixed $value, string $ } } - private static function castEnum(ParameterMeta $meta, mixed $value): ?UnitEnum + private static function castEnum(ParameterMeta $meta, #[\SensitiveParameter] mixed $value): ?UnitEnum { $enum = null; diff --git a/src/Support/ValueNormalizer.php b/src/Support/ValueNormalizer.php index 8239cc3..a9bf2fe 100644 --- a/src/Support/ValueNormalizer.php +++ b/src/Support/ValueNormalizer.php @@ -16,7 +16,7 @@ */ final class ValueNormalizer { - public static function normalize(mixed $value, ?string $context = null): mixed + public static function normalize(#[\SensitiveParameter] mixed $value, ?string $context = null): mixed { if ($value instanceof BaseData) { return $value->toArray($context); diff --git a/src/Support/XmlStream.php b/src/Support/XmlStream.php index a74483d..4475cc0 100644 --- a/src/Support/XmlStream.php +++ b/src/Support/XmlStream.php @@ -119,7 +119,7 @@ private static function fatalError(): ?\LibXMLError * Expects the reader on an element; leaves it either on that same element * or on its end tag, so the caller always advances with next(). */ - private static function fill(XMLReader $xml, array $shape, array &$row): void + private static function fill(XMLReader $xml, array $shape, #[\SensitiveParameter] array &$row): void { foreach ($shape[0] as $attribute => [$key, $type]) { $value = $xml->getAttribute($attribute); @@ -174,7 +174,7 @@ private static function fill(XMLReader $xml, array $shape, array &$row): void } } - private static function coerce(string $value, int $type): mixed + private static function coerce(#[\SensitiveParameter] string $value, int $type): mixed { if ($type >= self::NULLABLE) { if ($value === '') { diff --git a/src/TypedDataCollection.php b/src/TypedDataCollection.php index 8a8f808..30fbf2e 100644 --- a/src/TypedDataCollection.php +++ b/src/TypedDataCollection.php @@ -19,7 +19,7 @@ final class TypedDataCollection extends Collection * @param class-string $dataClass * @return static */ - public static function of(string $dataClass, iterable $items = []): static + public static function of(string $dataClass, #[\SensitiveParameter] iterable $items = []): static { // Resolved once — the per-item loop pays only instanceof + one call $hydrate = HydratorCompiler::$hydrators[$dataClass] ?? HydratorCompiler::compile($dataClass); diff --git a/tests/DataObjectCastTest.php b/tests/DataObjectCastTest.php index b7441fb..ebcbb20 100644 --- a/tests/DataObjectCastTest.php +++ b/tests/DataObjectCastTest.php @@ -7,7 +7,9 @@ use Illuminate\Database\Eloquent\Model; use PHPUnit\Framework\TestCase; use StdOut\SimpleDataObjects\Laravel\DataObjectCast; +use StdOut\SimpleDataObjects\Tests\Fixtures\CastableAccountData; use StdOut\SimpleDataObjects\Tests\Fixtures\CastableAddressData; +use StdOut\SimpleDataObjects\Tests\Fixtures\CastablePaymentData; /** * Direct, isolated tests of the cast class itself — $model is unused by @@ -95,4 +97,35 @@ public function test_compare_detects_a_genuine_difference(): void $this->assertFalse($this->cast()->compare($this->model(), 'address', $a, $b)); } + + public function test_set_drops_hidden_fields_without_a_context(): void + { + $json = (new DataObjectCast(CastableAccountData::class))->set($this->model(), 'account', new CastableAccountData('alice', 'secret'), []); + + $this->assertJsonStringEqualsJsonString('{"login":"alice"}', $json); + } + + public function test_set_keeps_hidden_fields_visible_in_the_context(): void + { + $cast = CastableAccountData::castUsing(['storage']); + + $json = $cast->set($this->model(), 'account', new CastableAccountData('alice', 'secret'), []); + + $this->assertJsonStringEqualsJsonString('{"login":"alice","token":"secret"}', $json); + } + + public function test_compare_with_a_context_detects_a_hidden_field_change(): void + { + $cast = CastableAccountData::castUsing(['storage']); + + $this->assertFalse($cast->compare($this->model(), 'account', '{"login":"alice","token":"a"}', '{"login":"alice","token":"b"}')); + $this->assertTrue($cast->compare($this->model(), 'account', '{"login":"alice","token":"a"}', '{"token":"a","login":"alice"}')); + } + + public function test_compare_rejects_instances_of_different_classes(): void + { + $cast = new DataObjectCast(CastablePaymentData::class); + + $this->assertFalse($cast->compare($this->model(), 'payment', '{"type":"card","last4":"4242"}', '{"type":"bank","iban":"UA00"}')); + } } diff --git a/tests/EloquentCastTest.php b/tests/EloquentCastTest.php index af7949b..78bc5d5 100644 --- a/tests/EloquentCastTest.php +++ b/tests/EloquentCastTest.php @@ -9,6 +9,7 @@ use Illuminate\Database\Schema\Blueprint; use PHPUnit\Framework\TestCase; use StdOut\SimpleDataObjects\Exceptions\DataHydrationException; +use StdOut\SimpleDataObjects\Tests\Fixtures\CastableAccountData; use StdOut\SimpleDataObjects\Tests\Fixtures\CastableAddressData; use StdOut\SimpleDataObjects\Tests\Fixtures\CastableBankPaymentData; use StdOut\SimpleDataObjects\Tests\Fixtures\CastableCardPaymentData; @@ -37,6 +38,7 @@ public static function setUpBeforeClass(): void $table->string('address')->nullable(); $table->string('items')->nullable(); $table->string('payment')->nullable(); + $table->string('account')->nullable(); }); self::$capsule = $capsule; @@ -68,6 +70,18 @@ public function test_single_data_object_round_trips_through_save_and_fresh_load( $this->assertSame('Kyiv', $reloaded->address->city); } + public function test_storage_context_keeps_hidden_fields_through_save_and_fresh_load(): void + { + $model = CastableOrderModel::create([ + 'account' => new CastableAccountData('alice', 'secret-token'), + ]); + + $reloaded = CastableOrderModel::find($model->id)->fresh(); + + $this->assertSame('secret-token', $reloaded->account->token); + $this->assertSame(['login' => 'alice'], $reloaded->account->toArray()); + } + public function test_single_data_object_accepts_a_plain_array_on_set(): void { $model = CastableOrderModel::create([ diff --git a/tests/Fixtures/CastableAccountData.php b/tests/Fixtures/CastableAccountData.php new file mode 100644 index 0000000..c9c9544 --- /dev/null +++ b/tests/Fixtures/CastableAccountData.php @@ -0,0 +1,21 @@ + CastableAddressData::class, 'items' => AsDataCollection::class.':'.CastableItemData::class, 'payment' => CastablePaymentData::class, + 'account' => CastableAccountData::class.':storage', ]; } diff --git a/tests/Fixtures/EmptyKeyProvider.php b/tests/Fixtures/EmptyKeyProvider.php new file mode 100644 index 0000000..a0d1574 --- /dev/null +++ b/tests/Fixtures/EmptyKeyProvider.php @@ -0,0 +1,15 @@ +get(self::PAN); + $this->fail('Expected DateMalformedStringException.'); + } catch (\DateMalformedStringException $e) { + $this->assertStringContainsString(self::MASKED, $e->getMessage()); + $this->assertStringNotContainsString(self::PAN, $e->getMessage()); + $this->assertNull($e->getPrevious()); + } + } + } + public function test_failure_masks_every_collected_message(): void { $result = HydrationResult::failure(['pan' => 'Rejected '.self::PAN.'.', 'name' => 'Required.']); diff --git a/tests/PrimitiveCastsTest.php b/tests/PrimitiveCastsTest.php index 394a538..64be9a3 100644 --- a/tests/PrimitiveCastsTest.php +++ b/tests/PrimitiveCastsTest.php @@ -16,11 +16,13 @@ use StdOut\SimpleDataObjects\Casts\JsonCast; use StdOut\SimpleDataObjects\Casts\TrimCast; use StdOut\SimpleDataObjects\Casts\UuidCast; +use StdOut\SimpleDataObjects\Tests\Fixtures\EmptyKeyProvider; use StdOut\SimpleDataObjects\Tests\Fixtures\FilterData; use StdOut\SimpleDataObjects\Tests\Fixtures\OrderWithUuidData; use StdOut\SimpleDataObjects\Tests\Fixtures\Priority; use StdOut\SimpleDataObjects\Tests\Fixtures\ProductData; use StdOut\SimpleDataObjects\Tests\Fixtures\SecretData; +use StdOut\SimpleDataObjects\Tests\Fixtures\StaticKeyProvider; use StdOut\SimpleDataObjects\Tests\Fixtures\Status; class PrimitiveCastsTest extends TestCase @@ -312,6 +314,49 @@ public function test_encrypted_cast_requires_exactly_one_key_source(): void new EncryptedCast; } + public function test_encrypted_cast_rejects_more_than_one_key_source(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provide exactly one of $key, $env or $provider.'); + + new EncryptedCast('my-key', provider: StaticKeyProvider::class); + } + + public function test_encrypted_cast_resolves_key_from_provider(): void + { + $encrypted = (new EncryptedCast(provider: StaticKeyProvider::class))->set('secret'); + + $this->assertSame('secret', (new EncryptedCast('provider-key'))->get($encrypted)); + } + + public function test_encrypted_cast_rejects_a_class_that_is_not_a_key_provider(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Key provider 'stdClass' must implement"); + + new EncryptedCast(provider: \stdClass::class); + } + + public function test_encrypted_cast_rejects_an_empty_key_from_provider(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('returned an empty key'); + + new EncryptedCast(provider: EmptyKeyProvider::class); + } + + public function test_encrypted_cast_hides_the_derived_key_from_every_dump(): void + { + $cast = new EncryptedCast('dump-test-key'); + $key = sodium_crypto_generichash('dump-test-key', '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + $dumps = [var_export($cast, true), print_r((array) $cast, true), (string) json_encode((array) $cast), print_r($cast, true)]; + + foreach ($dumps as $dump) { + $this->assertStringNotContainsString($key, $dump); + $this->assertStringNotContainsString(bin2hex($key), $dump); + } + } + public function test_encrypted_cast_rejects_both_key_and_env(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/StackTraceRedactionTest.php b/tests/StackTraceRedactionTest.php new file mode 100644 index 0000000..53f5439 --- /dev/null +++ b/tests/StackTraceRedactionTest.php @@ -0,0 +1,117 @@ +ignoreArgs = ini_set('zend.exception_ignore_args', '0'); + } + + protected function tearDown(): void + { + ini_set('zend.exception_ignore_args', (string) $this->ignoreArgs); + } + + /** @return iterable */ + public static function failingCalls(): iterable + { + $pan = self::PAN; + + yield 'from array' => [static fn () => SensitiveCardData::from(['pan' => $pan])]; + yield 'from JSON string' => [static fn () => SensitiveCardData::from('{"pan":"'.$pan.'"')]; + yield 'fromJson' => [static fn () => SensitiveCardData::fromJson('{"pan":"'.$pan.'"}')]; + yield 'from object' => [static fn () => SensitiveCardData::from((object) ['pan' => $pan])]; + yield 'fromLazy' => [static fn () => SensitiveCardData::fromLazy(['pan' => $pan])->holder]; + yield 'fromValidated' => [static fn () => ValidatedUserData::fromValidated(['name' => 'a', 'email' => $pan])]; + yield 'validate' => [static fn () => ValidatedUserData::validate(['name' => 'a', 'email' => $pan])]; + yield 'with' => [static fn () => SensitiveCardData::from(['holder' => 'a', 'pan' => $pan])->with(status: $pan)]; + yield 'collection' => [static fn () => SensitiveCardData::collection([['pan' => $pan]])]; + yield 'lazyCollection' => [static fn () => SensitiveCardData::lazyCollection([['pan' => $pan]])->all()]; + yield 'cast via DTO' => [static fn () => OrderWithUuidData::from(['id' => $pan])]; + yield 'money cast' => [static fn () => OrderWithMoneyData::from(['price' => $pan.'x'])]; + yield 'cast directly' => [static fn () => (new UuidCast)->get($pan)]; + yield 'date cast' => [static fn () => (new DateTimeCast)->get($pan)]; + yield 'immutable date cast' => [static fn () => (new DateTimeImmutableCast)->get($pan)]; + yield 'encrypted cast' => [static fn () => (new EncryptedCast('k'))->get($pan.'!')]; + yield 'data pipe' => [static fn () => ThrowingPipeData::from(['name' => $pan])]; + yield 'fromLivewire' => [static fn () => WireableEventData::fromLivewire(['name' => $pan])]; + } + + #[DataProvider('failingCalls')] + public function test_input_never_appears_in_stack_trace_arguments(Closure $call): void + { + $this->assertNoPanInTrace($call); + } + + public function test_eloquent_casts_keep_input_out_of_stack_trace_arguments(): void + { + $model = $this->createMock(Model::class); + $attributes = ['card' => self::PAN]; + + $this->assertNoPanInTrace(fn () => (new DataObjectCast(CastableAddressData::class))->get($model, 'address', ['street' => self::PAN], $attributes)); + $this->assertNoPanInTrace(fn () => (new DataCollectionCast(CastableItemData::class))->get($model, 'items', '{"pan":"'.self::PAN.'"', $attributes)); + } + + private function assertNoPanInTrace(Closure $call): void + { + try { + $call(); + } catch (Throwable $e) { + for ($current = $e; $current !== null; $current = $current->getPrevious()) { + foreach ($current->getTrace() as $frame) { + $this->assertFalse( + self::containsPan($frame['args'] ?? []), + 'PAN leaked through arguments of '.($frame['class'] ?? '').($frame['type'] ?? '').$frame['function'], + ); + } + } + + return; + } + + $this->fail('Expected the call to throw.'); + } + + private static function containsPan(mixed $value): bool + { + if (is_array($value)) { + foreach ($value as $key => $item) { + if (self::containsPan($key) || self::containsPan($item)) { + return true; + } + } + + return false; + } + + return is_scalar($value) && str_contains((string) $value, self::PAN); + } +}