Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.

## [Unreleased]

### Security
- An exception thrown by a data class constructor during `from()`, `fromLazy()`
or `with()` (a `TypeError` for a wrong input type, or the constructor's own
validation) no longer carries the raw arguments in the constructor's stack
frame: they are wrapped in `SensitiveParameterValue`, as PHP does for
`#[\SensitiveParameter]`. The exception's type, message, line and previous
chain are unchanged. Run `sdo:clear` (or `sdo:warm`) after upgrading so
file-cached hydrators are regenerated.

## [2.8.0] — 2026-10-16

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/attributes/sensitive.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ To queue a job that needs the card, pass an identifier (or a token from your pay
It requires `symfony/var-dumper` (installed with Laravel) and changes nothing until you call it.
- `var_export()` has no hook and always shows 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.
- 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.
- 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. The data class constructor is your code, so the library cannot mark it: when the constructor rejects its arguments — a `TypeError` for a wrong type, or an exception your own checks throw — the library wraps that frame's arguments in `SensitiveParameterValue` before the exception leaves `from()`, `fromLazy()` or `with()`. The exception's type, message, line and `getPrevious()` chain are unchanged. Helpers the constructor calls are still your frames: mark their parameters `#[\SensitiveParameter]`. 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

Expand Down
14 changes: 12 additions & 2 deletions src/BaseData.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use StdOut\SimpleDataObjects\Support\HydratorCompiler;
use StdOut\SimpleDataObjects\Support\InputNormalizer;
use StdOut\SimpleDataObjects\Support\MetadataRegistry;
use StdOut\SimpleDataObjects\Support\Redactor;
use StdOut\SimpleDataObjects\Support\SchemaGenerator;
use StdOut\SimpleDataObjects\Support\SerializerCompiler;
use StdOut\SimpleDataObjects\Support\ValueCaster;
Expand All @@ -41,7 +42,7 @@
*/
public static function from(#[\SensitiveParameter] mixed $data): static
{
$hydrate = HydratorCompiler::$hydrators[static::class] ?? HydratorCompiler::compile(static::class);

Check warning on line 45 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ */ public static function from(#[\SensitiveParameter] mixed $data): static { - $hydrate = HydratorCompiler::$hydrators[static::class] ?? HydratorCompiler::compile(static::class); + $hydrate = HydratorCompiler::compile(static::class) ?? HydratorCompiler::$hydrators[static::class]; if (is_array($data)) { /** @var static */

if (is_array($data)) {
/** @var static */
Expand All @@ -65,7 +66,7 @@
public static function fromLazy(#[\SensitiveParameter] mixed $data): static
{
$class = static::class;
$reflector = self::$reflectors[$class] ??= new \ReflectionClass($class);

Check warning on line 69 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "AssignCoalesce": @@ @@ public static function fromLazy(#[\SensitiveParameter] mixed $data): static { $class = static::class; - $reflector = self::$reflectors[$class] ??= new \ReflectionClass($class); + $reflector = (self::$reflectors[$class] = new \ReflectionClass($class)); // A ghost's class is fixed at creation, so a #[Discriminator] class // must resolve the concrete class eagerly; hydration stays deferred.

// A ghost's class is fixed at creation, so a #[Discriminator] class
// must resolve the concrete class eagerly; hydration stays deferred.
Expand All @@ -88,19 +89,24 @@
$meta = MetadataRegistry::get($class);

if (! $meta->hasConstructor) {
(HydratorCompiler::$populators[$class] ?? HydratorCompiler::compilePopulate($class))($normalized, $ghost);

Check warning on line 92 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ $meta = MetadataRegistry::get($class); if (! $meta->hasConstructor) { - (HydratorCompiler::$populators[$class] ?? HydratorCompiler::compilePopulate($class))($normalized, $ghost); + (HydratorCompiler::compilePopulate($class) ?? HydratorCompiler::$populators[$class])($normalized, $ghost); return; }

return;
}

$args = (HydratorCompiler::$argResolvers[$class] ?? HydratorCompiler::compileArgs($class))($normalized);

Check warning on line 97 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ return; } - $args = (HydratorCompiler::$argResolvers[$class] ?? HydratorCompiler::compileArgs($class))($normalized); + $args = (HydratorCompiler::compileArgs($class) ?? HydratorCompiler::$argResolvers[$class])($normalized); try { $ghost->__construct(...$args);
$ghost->__construct(...$args);

try {
$ghost->__construct(...$args);
} catch (\Throwable $e) {
throw Redactor::scrubConstructorArguments($e, $class);
}

// Hybrid: the constructor only covers its own parameters — the
// extra properties still need populating, same mechanism as the
// constructor-less path above.
if ($meta->hasExtraProperties) {
(HydratorCompiler::$populators[$class] ?? HydratorCompiler::compilePopulate($class))($normalized, $ghost);

Check warning on line 109 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ // extra properties still need populating, same mechanism as the // constructor-less path above. if ($meta->hasExtraProperties) { - (HydratorCompiler::$populators[$class] ?? HydratorCompiler::compilePopulate($class))($normalized, $ghost); + (HydratorCompiler::compilePopulate($class) ?? HydratorCompiler::$populators[$class])($normalized, $ghost); } }); }
}
});
}
Expand All @@ -122,7 +128,7 @@
*/
public static function fromResult(#[\SensitiveParameter] mixed $data): HydrationResult
{
$collect = HydratorCompiler::$collectingHydrators[static::class] ?? HydratorCompiler::compileCollecting(static::class);

Check warning on line 131 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ */ public static function fromResult(#[\SensitiveParameter] mixed $data): HydrationResult { - $collect = HydratorCompiler::$collectingHydrators[static::class] ?? HydratorCompiler::compileCollecting(static::class); + $collect = HydratorCompiler::compileCollecting(static::class) ?? HydratorCompiler::$collectingHydrators[static::class]; if (is_array($data)) { /** @var HydrationResult<static> */

if (is_array($data)) {
/** @var HydrationResult<static> */
Expand All @@ -133,7 +139,7 @@
return HydrationResult::success($data);
}

try {

Check warning on line 142 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "CatchBlockRemoval": @@ @@ try { $data = InputNormalizer::normalize(static::class, $data); - } catch (DataHydrationException $e) { - return HydrationResult::failure(['$input' => $e->getMessage()]); } catch (\Throwable) { return HydrationResult::failure(['$input' => 'Input could not be normalized.']); }
$data = InputNormalizer::normalize(static::class, $data);
} catch (DataHydrationException $e) {
return HydrationResult::failure(['$input' => $e->getMessage()]);
Expand Down Expand Up @@ -174,7 +180,7 @@
$class = static::class;

return LazyCollection::make(static function () use ($items, $class): \Generator {
$hydrate = HydratorCompiler::$hydrators[$class] ?? HydratorCompiler::compile($class);

Check warning on line 183 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ $class = static::class; return LazyCollection::make(static function () use ($items, $class): \Generator { - $hydrate = HydratorCompiler::$hydrators[$class] ?? HydratorCompiler::compile($class); + $hydrate = HydratorCompiler::compile($class) ?? HydratorCompiler::$hydrators[$class]; foreach ($items as $item) { yield $item instanceof $class

foreach ($items as $item) {
yield $item instanceof $class
Expand All @@ -195,7 +201,7 @@
$class = static::class;

return LazyCollection::make(static function () use ($class, $uri, $path): \Generator {
$hydrate = HydratorCompiler::$hydrators[$class] ?? HydratorCompiler::compile($class);

Check warning on line 204 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ $class = static::class; return LazyCollection::make(static function () use ($class, $uri, $path): \Generator { - $hydrate = HydratorCompiler::$hydrators[$class] ?? HydratorCompiler::compile($class); + $hydrate = HydratorCompiler::compile($class) ?? HydratorCompiler::$hydrators[$class]; foreach (XmlStream::read($class, $uri, $path) as $row) { yield $hydrate($row);

foreach (XmlStream::read($class, $uri, $path) as $row) {
yield $hydrate($row);
Expand Down Expand Up @@ -242,7 +248,11 @@
);
}

$instance = $meta->hasConstructor ? new static(...$ctorArgs) : new static;
try {
$instance = $meta->hasConstructor ? new static(...$ctorArgs) : new static;
} catch (\Throwable $e) { // @phpstan-ignore catch.neverThrown (PHPStan sees only BaseData, which has no constructor; subclass constructors can throw)
throw Redactor::scrubConstructorArguments($e, static::class);
}

// Extra (non-constructor) properties — constructor-less classes and
// hybrid classes' extra fields. Legal even for readonly properties:
Expand Down
9 changes: 7 additions & 2 deletions src/Support/HydratorCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -190,21 +190,26 @@ static function (#[\\SensitiveParameter] array \$d) use (\$p, \$pipes): \\{$clas

$unknownCheck = self::buildUnknownKeysCheck($class, $meta);
[$body, $argList] = self::buildParts($class, $meta);
$construct = "try {\n"
." \$o = new \\{$class}({$argList});\n"
." } catch (\\Throwable \$e) {\n"
." throw \\StdOut\\SimpleDataObjects\\Support\\Redactor::scrubConstructorArguments(\$e, \\{$class}::class);\n"
." }\n";

if ($meta->hasExtraProperties) {
$extraBody = self::buildPropertyAssignments($class, $meta, includePipesPrelude: false);

return <<<PHP
static function (#[\\SensitiveParameter] array \$d) use (\$p, \$pipes): \\{$class} {
{$unknownCheck}{$body} \$o = new \\{$class}({$argList});
{$unknownCheck}{$body} {$construct}
{$extraBody} return \$o;
}
PHP;
}

return <<<PHP
static function (#[\\SensitiveParameter] array \$d) use (\$p, \$pipes): \\{$class} {
{$unknownCheck}{$body} return new \\{$class}({$argList});
{$unknownCheck}{$body} {$construct} return \$o;
}
PHP;
}
Expand Down
33 changes: 33 additions & 0 deletions src/Support/Redactor.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,39 @@ public static function forMessage(#[\SensitiveParameter] string $value): string
return addcslashes(self::maskPan($value), "\0..\37\177");
}

/**
* The library passes input to a data class constructor it does not own,
* so that frame cannot carry #[\SensitiveParameter]: a TypeError at the
* call or any exception thrown in the constructor body would keep the raw
* arguments in its trace. Wraps them the way PHP wraps a sensitive
* parameter, in place, so the type, message and line stay the same.
*
* @param class-string $class
*/
public static function scrubConstructorArguments(\Throwable $e, string $class): \Throwable
{
for ($current = $e; $current !== null; $current = $current->getPrevious()) {
$property = new \ReflectionProperty($current instanceof \Exception ? \Exception::class : \Error::class, 'trace');
/** @var list<array<string, mixed>> $trace */
$trace = $property->getValue($current);

foreach ($trace as $i => $frame) {
if (($frame['function'] ?? null) === '__construct'
&& isset($frame['class'], $frame['args'])
&& is_a($class, (string) $frame['class'], true)) {
$trace[$i]['args'] = array_map(
static fn (mixed $arg): \SensitiveParameterValue => $arg instanceof \SensitiveParameterValue ? $arg : new \SensitiveParameterValue($arg),
(array) $frame['args'],
);
}
}

$property->setValue($current, $trace);
}

return $e;
}

/** @param array{0: string} $match */
private static function maskDigits(array $match): string
{
Expand Down
22 changes: 22 additions & 0 deletions tests/Fixtures/ChargeData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace StdOut\SimpleDataObjects\Tests\Fixtures;

use StdOut\SimpleDataObjects\BaseData;

/** Validates in its own constructor, so a rejected card is thrown from that frame. */
class ChargeData extends BaseData
{
public function __construct(
public readonly string $pan,
public readonly int $amount,
#[\SensitiveParameter]
public readonly string $cvv = '',
) {
if ($amount < 0) {
throw new \DomainException('Charge rejected.', 0, new \InvalidArgumentException('Amount must not be negative.'));
}
}
}
10 changes: 10 additions & 0 deletions tests/Fixtures/HybridChargeData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace StdOut\SimpleDataObjects\Tests\Fixtures;

class HybridChargeData extends ChargeData
{
public ?string $reference = null;
}
32 changes: 32 additions & 0 deletions tests/StackTraceRedactionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use StdOut\SimpleDataObjects\Laravel\DataObjectCast;
use StdOut\SimpleDataObjects\Tests\Fixtures\CastableAddressData;
use StdOut\SimpleDataObjects\Tests\Fixtures\CastableItemData;
use StdOut\SimpleDataObjects\Tests\Fixtures\ChargeData;
use StdOut\SimpleDataObjects\Tests\Fixtures\HybridChargeData;
use StdOut\SimpleDataObjects\Tests\Fixtures\OrderWithMoneyData;
use StdOut\SimpleDataObjects\Tests\Fixtures\OrderWithUuidData;
use StdOut\SimpleDataObjects\Tests\Fixtures\SensitiveCardData;
Expand Down Expand Up @@ -63,6 +65,11 @@ public static function failingCalls(): iterable
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])];
yield 'constructor type error' => [static fn () => ChargeData::from(['pan' => $pan, 'amount' => 'ten'])];
yield 'constructor throws' => [static fn () => ChargeData::from(['pan' => $pan, 'amount' => -1, 'cvv' => '123'])];
yield 'inherited constructor, extra properties' => [static fn () => HybridChargeData::from(['pan' => $pan, 'amount' => -1])];
yield 'constructor throws in fromLazy' => [static fn () => ChargeData::fromLazy(['pan' => $pan, 'amount' => -1])->amount];
yield 'constructor throws in with' => [static fn () => ChargeData::from(['pan' => $pan, 'amount' => 1])->with(amount: -1)];
}

#[DataProvider('failingCalls')]
Expand All @@ -80,6 +87,31 @@ public function test_eloquent_casts_keep_input_out_of_stack_trace_arguments(): v
$this->assertNoPanInTrace(fn () => (new DataCollectionCast(CastableItemData::class))->get($model, 'items', '{"pan":"'.self::PAN.'"', $attributes));
}

public function test_constructor_exception_keeps_type_message_line_and_previous(): void
{
try {
ChargeData::from(['pan' => self::PAN, 'amount' => -1, 'cvv' => '123']);
$this->fail('Expected the constructor to throw.');
} catch (\DomainException $e) {
$this->assertSame('Charge rejected.', $e->getMessage());
$this->assertStringEndsWith('ChargeData.php', $e->getFile());
$this->assertInstanceOf(\InvalidArgumentException::class, $e->getPrevious());

$frame = $e->getTrace()[0];
$this->assertSame([ChargeData::class, '__construct'], [$frame['class'] ?? null, $frame['function']]);
$this->assertContainsOnlyInstancesOf(\SensitiveParameterValue::class, $frame['args'] ?? []);
$this->assertSame([self::PAN, -1, '123'], array_map(static fn (\SensitiveParameterValue $arg): mixed => $arg->getValue(), $frame['args'] ?? []));
}
}

public function test_constructor_type_error_stays_a_type_error(): void
{
$this->expectException(\TypeError::class);
$this->expectExceptionMessage('Argument #2 ($amount) must be of type int');

ChargeData::from(['pan' => self::PAN, 'amount' => 'ten']);
}

private function assertNoPanInTrace(Closure $call): void
{
try {
Expand Down
Loading