1
0

SecretScrubbingProcessorTest.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  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 testRawJwtInValueIsScrubbed(): void
  88. {
  89. // SEC_REVIEW F65: a JWT logged under any key (not in the
  90. // sensitive-key list) must still be scrubbed by the value
  91. // pattern. JWTs always start `eyJ` (base64url of `{"`), so the
  92. // pattern is anchored on that.
  93. $processor = new SecretScrubbingProcessor();
  94. $jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTYifQ.qj8u_Mt1ZyT5PfksI91X4-3aBwQ-_Pwm';
  95. $record = $this->record('upstream returned id_token', [
  96. 'jwt' => $jwt,
  97. ]);
  98. $out = $processor($record);
  99. self::assertSame('eyJ***', $out->context['jwt']);
  100. }
  101. public function testRawJwtInMessageIsScrubbed(): void
  102. {
  103. $processor = new SecretScrubbingProcessor();
  104. $jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3In0.q-MT_OmW-fakesigvaluehere';
  105. $record = $this->record("decoded payload from {$jwt} ok", []);
  106. $out = $processor($record);
  107. self::assertStringContainsString('eyJ***', $out->message);
  108. self::assertStringNotContainsString($jwt, $out->message);
  109. }
  110. public function testShortBearerTokenIsScrubbed(): void
  111. {
  112. // SEC_REVIEW F65: the previous floor of {20,} let a short
  113. // Bearer slip through. The new floor is {8,}.
  114. $processor = new SecretScrubbingProcessor();
  115. $record = $this->record('outbound', [
  116. 'auth' => 'Bearer abc12345',
  117. ]);
  118. $out = $processor($record);
  119. // 'auth' isn't in the key-name list, so we exercise the value
  120. // pattern. The Bearer prefix stays as a triage breadcrumb.
  121. self::assertSame('Bearer ***', $out->context['auth']);
  122. }
  123. public function testIpAddressDoesNotMatchJwtRegex(): void
  124. {
  125. // False-positive guard: dotted-quad IP looks like
  126. // `int.int.int` but doesn't start with `eyJ`, so it's
  127. // outside the JWT pattern.
  128. $processor = new SecretScrubbingProcessor();
  129. $record = $this->record('handling 192.168.1.1 request', [
  130. 'ip' => '203.0.113.42',
  131. ]);
  132. $out = $processor($record);
  133. self::assertSame('203.0.113.42', $out->context['ip']);
  134. self::assertStringContainsString('192.168.1.1', $out->message);
  135. }
  136. public function testNestedContextIsScrubbedRecursively(): void
  137. {
  138. $processor = new SecretScrubbingProcessor();
  139. $record = $this->record('nested', [
  140. 'request' => [
  141. 'method' => 'POST',
  142. 'authorization' => 'Bearer irdb_adm_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
  143. 'body' => ['ok' => true],
  144. ],
  145. ]);
  146. $out = $processor($record);
  147. self::assertSame('***', $out->context['request']['authorization']);
  148. self::assertSame('POST', $out->context['request']['method']);
  149. self::assertTrue($out->context['request']['body']['ok']);
  150. }
  151. /**
  152. * @param array<string, mixed> $context
  153. */
  154. private function record(string $message, array $context): LogRecord
  155. {
  156. return new LogRecord(
  157. datetime: new \DateTimeImmutable(),
  158. channel: 'test',
  159. level: Level::Info,
  160. message: $message,
  161. context: $context,
  162. );
  163. }
  164. }