1
0

SafeTraceTest.php 2.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Logging;
  4. use App\Infrastructure\Logging\SafeTrace;
  5. use PHPUnit\Framework\TestCase;
  6. use RuntimeException;
  7. use Throwable;
  8. /**
  9. * Regression test for SEC_REVIEW F21: PHP's getTraceAsString() inlines
  10. * scalar args into each frame, which leaks plaintext secrets when an
  11. * exception is thrown from inside `password_verify`, an OIDC token
  12. * validation call, etc. SafeTrace must drop args entirely.
  13. */
  14. final class SafeTraceTest extends TestCase
  15. {
  16. public function testFormattedTraceDoesNotContainArgs(): void
  17. {
  18. $secret = 'super-plaintext-password-please-do-not-leak';
  19. $caught = $this->capture(fn () => $this->throwWithSecret($secret));
  20. $trace = SafeTrace::format($caught);
  21. self::assertStringNotContainsString($secret, $trace);
  22. self::assertStringContainsString('throwWithSecret', $trace);
  23. }
  24. public function testFormattedTraceWalksPreviousChain(): void
  25. {
  26. $innerSecret = 'inner-secret-123';
  27. $outerSecret = 'outer-secret-456';
  28. $caught = $this->capture(function () use ($innerSecret, $outerSecret): void {
  29. try {
  30. $this->throwWithSecret($innerSecret);
  31. } catch (Throwable $inner) {
  32. $this->wrap($outerSecret, $inner);
  33. }
  34. });
  35. $trace = SafeTrace::format($caught);
  36. self::assertStringNotContainsString($innerSecret, $trace);
  37. self::assertStringNotContainsString($outerSecret, $trace);
  38. self::assertStringContainsString('Caused by', $trace);
  39. }
  40. public function testFrameLayoutResemblesPhpNativeFormat(): void
  41. {
  42. $caught = $this->capture(fn () => $this->throwWithSecret('x'));
  43. $trace = SafeTrace::format($caught);
  44. // Numbered frames, file(line) suffix, class::method() call, then {main}.
  45. self::assertMatchesRegularExpression('/^#0 .+\(\d+\): .+\(\)$/m', $trace);
  46. self::assertStringEndsWith('{main}', $trace);
  47. }
  48. private function capture(callable $fn): Throwable
  49. {
  50. try {
  51. $fn();
  52. } catch (Throwable $e) {
  53. return $e;
  54. }
  55. self::fail('expected callable to throw');
  56. }
  57. private function throwWithSecret(string $secret): never
  58. {
  59. throw new RuntimeException('boom');
  60. }
  61. private function wrap(string $secret, Throwable $inner): never
  62. {
  63. throw new RuntimeException('outer', 0, $inner);
  64. }
  65. }