|
|
@@ -0,0 +1,80 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Tests\Unit\Logging;
|
|
|
+
|
|
|
+use App\Infrastructure\Logging\SafeTrace;
|
|
|
+use PHPUnit\Framework\TestCase;
|
|
|
+use RuntimeException;
|
|
|
+use Throwable;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Regression test for SEC_REVIEW F21: PHP's getTraceAsString() inlines
|
|
|
+ * scalar args into each frame, which leaks plaintext secrets when an
|
|
|
+ * exception is thrown from inside `password_verify`, an OIDC token
|
|
|
+ * validation call, etc. SafeTrace must drop args entirely.
|
|
|
+ */
|
|
|
+final class SafeTraceTest extends TestCase
|
|
|
+{
|
|
|
+ public function testFormattedTraceDoesNotContainArgs(): void
|
|
|
+ {
|
|
|
+ $secret = 'super-plaintext-password-please-do-not-leak';
|
|
|
+ $caught = $this->capture(fn () => $this->throwWithSecret($secret));
|
|
|
+
|
|
|
+ $trace = SafeTrace::format($caught);
|
|
|
+
|
|
|
+ self::assertStringNotContainsString($secret, $trace);
|
|
|
+ self::assertStringContainsString('throwWithSecret', $trace);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testFormattedTraceWalksPreviousChain(): void
|
|
|
+ {
|
|
|
+ $innerSecret = 'inner-secret-123';
|
|
|
+ $outerSecret = 'outer-secret-456';
|
|
|
+
|
|
|
+ $caught = $this->capture(function () use ($innerSecret, $outerSecret): void {
|
|
|
+ try {
|
|
|
+ $this->throwWithSecret($innerSecret);
|
|
|
+ } catch (Throwable $inner) {
|
|
|
+ $this->wrap($outerSecret, $inner);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ $trace = SafeTrace::format($caught);
|
|
|
+ self::assertStringNotContainsString($innerSecret, $trace);
|
|
|
+ self::assertStringNotContainsString($outerSecret, $trace);
|
|
|
+ self::assertStringContainsString('Caused by', $trace);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testFrameLayoutResemblesPhpNativeFormat(): void
|
|
|
+ {
|
|
|
+ $caught = $this->capture(fn () => $this->throwWithSecret('x'));
|
|
|
+
|
|
|
+ $trace = SafeTrace::format($caught);
|
|
|
+
|
|
|
+ // Numbered frames, file(line) suffix, class::method() call, then {main}.
|
|
|
+ self::assertMatchesRegularExpression('/^#0 .+\(\d+\): .+\(\)$/m', $trace);
|
|
|
+ self::assertStringEndsWith('{main}', $trace);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function capture(callable $fn): Throwable
|
|
|
+ {
|
|
|
+ try {
|
|
|
+ $fn();
|
|
|
+ } catch (Throwable $e) {
|
|
|
+ return $e;
|
|
|
+ }
|
|
|
+ self::fail('expected callable to throw');
|
|
|
+ }
|
|
|
+
|
|
|
+ private function throwWithSecret(string $secret): never
|
|
|
+ {
|
|
|
+ throw new RuntimeException('boom');
|
|
|
+ }
|
|
|
+
|
|
|
+ private function wrap(string $secret, Throwable $inner): never
|
|
|
+ {
|
|
|
+ throw new RuntimeException('outer', 0, $inner);
|
|
|
+ }
|
|
|
+}
|