diff --git a/src/Monolog/Processor/RedactSensitiveProcessor.php b/src/Monolog/Processor/RedactSensitiveProcessor.php new file mode 100644 index 000000000..6b8225746 --- /dev/null +++ b/src/Monolog/Processor/RedactSensitiveProcessor.php @@ -0,0 +1,77 @@ +sensitiveKeys = array_map('strtolower', $sensitiveKeys); + $this->patterns = $patterns; + $this->mask = $mask; + } + + public function __invoke(LogRecord $record): LogRecord + { + $context = $this->sanitize($record->context); + $extra = $this->sanitize($record->extra); + + $message = $record->message; + foreach ($this->patterns as $pattern) { + if (@preg_match($pattern, '') === false) { + continue; // ignore invalid pattern instead of throwing inside logging path + } + $message = preg_replace($pattern, $this->mask, $message) ?? $message; + } + + return $record->with( + message: $message, + context: $context, + extra: $extra + ); + } + + /** @param mixed $value */ + private function sanitize(mixed $value): mixed + { + if (is_array($value)) { + $sanitized = []; + foreach ($value as $k => $v) { + $key = \is_string($k) ? strtolower($k) : $k; + if (is_string($k) && in_array($key, $this->sensitiveKeys, true)) { + $sanitized[$k] = $this->mask; + } else { + $sanitized[$k] = $this->sanitize($v); + } + } + return $sanitized; + } + + if (is_string($value) && $this->patterns) { + foreach ($this->patterns as $pattern) { + if (@preg_match($pattern, '') === false) { + continue; + } + $value = preg_replace($pattern, $this->mask, $value) ?? $value; + } + } + + return $value; + } +} diff --git a/tests/Monolog/Processor/RedactSensitiveProcessorTest.php b/tests/Monolog/Processor/RedactSensitiveProcessorTest.php new file mode 100644 index 000000000..2e6a388da --- /dev/null +++ b/tests/Monolog/Processor/RedactSensitiveProcessorTest.php @@ -0,0 +1,58 @@ + 'rishi', 'password' => 'super-secret', 'nested' => ['api_key' => 'abc123']], + extra: ['token' => 't-456', 'irrelevant' => 'keep'] + ); + + $out = $p($rec); + + $this->assertSame('REDACTED', $out->context['password']); + $this->assertSame('REDACTED', $out->context['nested']['api_key']); + $this->assertSame('REDACTED', $out->extra['token']); + $this->assertSame('keep', $out->extra['irrelevant']); + } + + public function testRedactsWithPatterns(): void + { + $p = new RedactSensitiveProcessor( + sensitiveKeys: [], + patterns: ['/(Bearer\\s+)[A-Za-z0-9\\._-]+/i', '/([\\w.%+-]+@[\\w.-]+\\.[A-Za-z]{2,})/'] + ); + + $rec = new LogRecord( + new \DateTimeImmutable('@0'), 'test', Level::Info, + 'Auth {h}: Bearer abc.def-ghi and user john@example.com', + ['h' => 'header'], [] + ); + + $out = $p($rec); + $this->assertSame('Auth {h}: REDACTED and user REDACTED', $out->message); + } + + public function testIgnoresInvalidRegexSafely(): void + { + $p = new RedactSensitiveProcessor([], ['/[invalid/']); + $rec = new LogRecord(new \DateTimeImmutable('@0'), 'test', Level::Info, 'hello', [], []); + $out = $p($rec); + $this->assertSame('hello', $out->message); + } +} +?> \ No newline at end of file