SecretScrubbingProcessorTest.php 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Logging;
  4. use App\Infrastructure\Logging\SecretScrubbingProcessor;
  5. use Monolog\Formatter\JsonFormatter;
  6. use Monolog\Level;
  7. use Monolog\LogRecord;
  8. use PHPUnit\Framework\TestCase;
  9. final class SecretScrubbingProcessorTest extends TestCase
  10. {
  11. public function testBearerTokenInContextIsScrubbedAndPreservesKindPrefix(): void
  12. {
  13. $processor = new SecretScrubbingProcessor();
  14. $record = $this->record('outbound api call', [
  15. 'authorization' => 'Bearer irdb_svc_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
  16. ]);
  17. $out = $processor($record);
  18. // Key-based redaction wins; the whole value is replaced with ***.
  19. self::assertSame('***', $out->context['authorization']);
  20. }
  21. public function testBareTokenInMessageIsScrubbed(): void
  22. {
  23. $processor = new SecretScrubbingProcessor();
  24. $record = $this->record(
  25. 'attempted with token irdb_adm_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
  26. []
  27. );
  28. $out = $processor($record);
  29. self::assertStringContainsString('irdb_adm_***', $out->message);
  30. self::assertStringNotContainsString('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', $out->message);
  31. }
  32. public function testFormattedOutputDoesNotLeakBearerToken(): void
  33. {
  34. // Round-trip the record through the processor and the JsonFormatter
  35. // to confirm the *rendered* log line is clean — what actually hits
  36. // stdout in production.
  37. $processor = new SecretScrubbingProcessor();
  38. $record = $this->record('api request', [
  39. 'headers' => ['Authorization' => 'Bearer irdb_rep_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'],
  40. ]);
  41. $out = $processor($record);
  42. $formatter = new JsonFormatter();
  43. $line = $formatter->format($out);
  44. self::assertStringNotContainsString('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', $line);
  45. self::assertStringContainsString('***', $line);
  46. }
  47. public function testPasswordHashKeyIsScrubbed(): void
  48. {
  49. $processor = new SecretScrubbingProcessor();
  50. $record = $this->record('config dump', [
  51. 'LOCAL_ADMIN_PASSWORD_HASH' => '$argon2id$v=19$m=65536,t=4,p=1$abcdef$ghijkl',
  52. 'OIDC_CLIENT_SECRET' => 'super-secret-value',
  53. 'MAXMIND_LICENSE_KEY' => 'license-12345',
  54. 'IPINFO_TOKEN' => 'token-67890',
  55. 'DB_MYSQL_PASSWORD' => 'rootpass',
  56. ]);
  57. $out = $processor($record);
  58. foreach (['LOCAL_ADMIN_PASSWORD_HASH', 'OIDC_CLIENT_SECRET', 'MAXMIND_LICENSE_KEY', 'IPINFO_TOKEN', 'DB_MYSQL_PASSWORD'] as $key) {
  59. self::assertSame('***', $out->context[$key], "key {$key} not scrubbed");
  60. }
  61. }
  62. public function testArgon2HashEmbeddedInMessageIsScrubbed(): void
  63. {
  64. $processor = new SecretScrubbingProcessor();
  65. $record = $this->record(
  66. 'verifying $argon2id$v=19$m=65536,t=4,p=1$abc$def for user',
  67. []
  68. );
  69. $out = $processor($record);
  70. self::assertStringNotContainsString('$argon2id$v=19', $out->message);
  71. self::assertStringContainsString('$argon2***', $out->message);
  72. }
  73. public function testNonSensitiveContentIsLeftAlone(): void
  74. {
  75. $processor = new SecretScrubbingProcessor();
  76. $record = $this->record('user search hit', [
  77. 'count' => 42,
  78. 'email' => 'someone@example.com',
  79. 'ip' => '203.0.113.42',
  80. ]);
  81. $out = $processor($record);
  82. self::assertSame(42, $out->context['count']);
  83. self::assertSame('someone@example.com', $out->context['email']);
  84. self::assertSame('203.0.113.42', $out->context['ip']);
  85. self::assertSame('user search hit', $out->message);
  86. }
  87. public function testNestedContextIsScrubbedRecursively(): void
  88. {
  89. $processor = new SecretScrubbingProcessor();
  90. $record = $this->record('nested', [
  91. 'request' => [
  92. 'method' => 'POST',
  93. 'authorization' => 'Bearer irdb_adm_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
  94. 'body' => ['ok' => true],
  95. ],
  96. ]);
  97. $out = $processor($record);
  98. self::assertSame('***', $out->context['request']['authorization']);
  99. self::assertSame('POST', $out->context['request']['method']);
  100. self::assertTrue($out->context['request']['body']['ok']);
  101. }
  102. /**
  103. * @param array<string, mixed> $context
  104. */
  105. private function record(string $message, array $context): LogRecord
  106. {
  107. return new LogRecord(
  108. datetime: new \DateTimeImmutable(),
  109. channel: 'test',
  110. level: Level::Info,
  111. message: $message,
  112. context: $context,
  113. );
  114. }
  115. }