From 78b679dd92cc988be48ce647aa46ecd5369832b1 Mon Sep 17 00:00:00 2001 From: dualfroz Date: Fri, 4 Sep 2026 08:38:50 +0200 Subject: [PATCH] Fix RedactingFormatter missing secrets nested in array-valued sensitive keys RedactingFormatter::collect() only added a sensitive key's value to the secret list when it was a string or Stringable. When the value was an array, addSecret() silently dropped it, so none of its leaf strings were collected. PsrLogMessageProcessor json-encodes array context values when interpolating them into the message, so a secret nested that way could already be sitting in the message in plain text by the time this formatter ran, and the output sweep had nothing to remove it with. Add collectSensitiveValue() to walk a sensitive key's value and collect every string leaf, however deeply nested, and use it instead of calling addSecret() directly. --- src/Monolog/Formatter/RedactingFormatter.php | 27 +++++++++++++++++-- .../Formatter/RedactingFormatterTest.php | 16 +++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/Monolog/Formatter/RedactingFormatter.php b/src/Monolog/Formatter/RedactingFormatter.php index ed766d2..d97a66a 100644 --- a/src/Monolog/Formatter/RedactingFormatter.php +++ b/src/Monolog/Formatter/RedactingFormatter.php @@ -203,8 +203,9 @@ final class RedactingFormatter implements WrappingFormatterInterface if (\is_array($data)) { foreach ($data as $key => $value) { if (\is_string($key) && \in_array(strtolower($key), $this->sensitiveKeys, true)) { - // no need to recurse, the whole value is masked in the record anyway - $this->addSecret($value, $secrets); + // the value is masked in the record, but a nested array's leaves can still + // leak elsewhere (e.g. json-encoded by PsrLogMessageProcessor) + $this->collectSensitiveValue($value, $secrets, $depth + 1); continue; } @@ -239,6 +240,28 @@ final class RedactingFormatter implements WrappingFormatterInterface } } + /** + * Collects every string leaf nested inside a value under a sensitive key. + * + * @param list $secrets + */ + private function collectSensitiveValue(mixed $value, array &$secrets, int $depth): void + { + if ($depth > self::MAX_DEPTH) { + return; + } + + if (\is_array($value)) { + foreach ($value as $item) { + $this->collectSensitiveValue($item, $secrets, $depth + 1); + } + + return; + } + + $this->addSecret($value, $secrets); + } + /** * Reflects the properties of an object matching a #[SensitiveParameter] constructor parameter * diff --git a/tests/Monolog/Formatter/RedactingFormatterTest.php b/tests/Monolog/Formatter/RedactingFormatterTest.php index ef4349c..f8c365a 100644 --- a/tests/Monolog/Formatter/RedactingFormatterTest.php +++ b/tests/Monolog/Formatter/RedactingFormatterTest.php @@ -243,6 +243,22 @@ class RedactingFormatterTest extends MonologTestCase $this->assertStringContainsString('calling api with [REDACTED]', $output); } + public function testRedactsSecretNestedInAnArrayValueAlreadyInterpolatedIntoTheMessage() + { + // PsrLogMessageProcessor json-encodes array context into the message, so a + // secret nested under a sensitive key leaks before this formatter runs. + $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d')); + $processor = new PsrLogMessageProcessor(); + + $output = $formatter->format($processor($this->getRecord( + message: 'password reset payload: {password}', + context: ['password' => ['new' => 'tok-supersecret-value']], + ))); + + $this->assertStringNotContainsString('tok-supersecret-value', $output); + $this->assertStringContainsString('[REDACTED]', $output); + } + public function testRedactsSecretEmbeddedInAnExceptionMessage() { $formatter = new RedactingFormatter(new JsonFormatter());