From 2216d4fc24b75687335b057d3570df2200395b9b Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 4 Sep 2026 08:38:50 +0200 Subject: [PATCH] Upstream Seldaek/monolog state of the touched files before 1191965241 --- src/Monolog/Formatter/RedactingFormatter.php | 350 ++++++++++ .../Formatter/RedactingFormatterTest.php | 606 ++++++++++++++++++ 2 files changed, 956 insertions(+) create mode 100644 src/Monolog/Formatter/RedactingFormatter.php create mode 100644 tests/Monolog/Formatter/RedactingFormatterTest.php diff --git a/src/Monolog/Formatter/RedactingFormatter.php b/src/Monolog/Formatter/RedactingFormatter.php new file mode 100644 index 0000000..ed766d2 --- /dev/null +++ b/src/Monolog/Formatter/RedactingFormatter.php @@ -0,0 +1,350 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Formatter; + +use Monolog\LogRecord; +use Monolog\Utils; + +/** + * Wraps another formatter to redact sensitive data from log records. + * + * It works in three passes so that it can mask secrets wherever they end up: + * + * - Before delegating, it masks the value of any context/extra key whose name + * matches one of the configured sensitive keys (case-insensitive, recursive). + * - After delegating, it removes those same secret values from the wrapped + * formatter's output, which catches the copies masking the record cannot + * reach, e.g. a secret interpolated into the message by the + * PsrLogMessageProcessor or embedded in an exception message. + * - Last, it runs the configured regex patterns over the output, which catches + * secrets that only became strings once the inner formatter normalized + * objects (e.g. tokens buried in an exception stack trace). If a pattern + * cannot be applied, the record is dropped instead of emitted unredacted. + * + * On top of the sensitive keys, secrets are also read from the properties of the + * objects in the record matching a constructor parameter marked + * #[SensitiveParameter]. Those can only be handled by the output pass, as the + * objects carrying them cannot be modified. This is the one part that reflects on + * every logged object, so it can be turned off with $redactSensitiveParameters. + * + * Only string and array output can be swept, so wrapping a formatter which returns + * objects (e.g. the ElasticaFormatter) leaves you with the key masking alone. + * + * Because it is a formatter rather than a processor, it is guaranteed to run + * after every processor (both Logger- and Handler-level), so it always sees the + * final record with all data at hand. + * + * @author Jordi Boggiano + */ +final class RedactingFormatter implements WrappingFormatterInterface +{ + /** + * Matches long token-like words (30+ alphanumerics, optionally prefixed like api_key_), + * which are very likely to be secrets/tokens. Provided as an opt-in pattern as it may + * cause collateral damage on legitimate long strings (hashes, UUIDs, base64, ...). + */ + public const TOKEN_PATTERN = '{\b(?:[a-z]+_)*[a-zA-Z0-9]{30,}\b}'; + + /** + * How deep to walk the record and the output, mirrors NormalizerFormatter's default + */ + private const MAX_DEPTH = 9; + + /** @var list Lowercased sensitive keys */ + private array $sensitiveKeys; + + /** @var list */ + private array $patterns; + + /** @var array> */ + private array $sensitiveProperties = []; + + /** + * @param FormatterInterface $formatter The formatter to wrap and whose output gets redacted + * @param list $sensitiveKeys Exact context/extra keys whose values to mask (case-insensitive) + * @param list $patterns PCRE patterns to mask in the formatted output (e.g. self::TOKEN_PATTERN) + * @param string $mask Replacement token + * @param bool $redactSensitiveParameters Whether to also read secrets off the properties matching a + * #[SensitiveParameter] constructor parameter, which requires + * walking and reflecting on every object in the record + * @param int $minSecretLength Minimum length a secret must have to be removed from the + * output, to avoid short values like "pass" redacting + * unrelated data + * + * @throws \InvalidArgumentException If a pattern is not a valid PCRE regex + */ + public function __construct( + private readonly FormatterInterface $formatter, + array $sensitiveKeys = ['password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey', 'authorization', 'auth', 'cookie'], + array $patterns = [], + private readonly string $mask = '[REDACTED]', + private readonly bool $redactSensitiveParameters = true, + private readonly int $minSecretLength = 5, + ) { + $this->sensitiveKeys = array_values(array_map('strtolower', $sensitiveKeys)); + + foreach ($patterns as $pattern) { + if (@preg_match($pattern, '') === false) { + throw new \InvalidArgumentException(sprintf('Invalid redaction pattern provided to RedactingFormatter: %s', var_export($pattern, true))); + } + } + $this->patterns = array_values($patterns); + } + + public function getWrappedFormatter(): FormatterInterface + { + return $this->formatter; + } + + public function format(LogRecord $record) + { + // secrets must be collected before the keys get masked, as masking loses the values + $secrets = $this->collectSecrets([$record]); + + return $this->sweep($this->formatter->format($this->redactRecord($record)), $secrets); + } + + public function formatBatch(array $records) + { + $secrets = $this->collectSecrets($records); + + foreach ($records as $key => $record) { + $records[$key] = $this->redactRecord($record); + } + + return $this->sweep($this->formatter->formatBatch($records), $secrets); + } + + private function redactRecord(LogRecord $record): LogRecord + { + return $record->with( + context: $this->redactKeys($record->context), + extra: $this->redactKeys($record->extra), + ); + } + + /** + * @param array $data + * @return array + */ + private function redactKeys(array $data, int $depth = 0): array + { + // context comes from userland and can hold a self-referencing array + if ($depth > self::MAX_DEPTH) { + return $data; + } + + foreach ($data as $key => $value) { + if (\is_string($key) && \in_array(strtolower($key), $this->sensitiveKeys, true)) { + $data[$key] = $this->mask; + } elseif (\is_array($value)) { + $data[$key] = $this->redactKeys($value, $depth + 1); + } + } + + return $data; + } + + /** + * Gathers the secret values present in the given records, longest first + * + * @param iterable $records + * @return list + */ + private function collectSecrets(iterable $records): array + { + $secrets = []; + $seen = []; + foreach ($records as $record) { + $this->collect($record->context, $secrets, $seen, 0); + $this->collect($record->extra, $secrets, $seen, 0); + } + + if ([] === $secrets) { + return []; + } + + // a secret containing quotes/backslashes/newlines shows up escaped in json output, + // so both forms have to be looked for + $escapedSecrets = []; + foreach ($secrets as $secret) { + $escaped = substr(Utils::jsonEncode($secret, null, true), 1, -1); + if ($escaped !== $secret && $escaped !== '') { + $escapedSecrets[] = $escaped; + } + } + + $secrets = array_values(array_unique(array_merge($secrets, $escapedSecrets))); + // longest first, so that overlapping secrets do not leave fragments behind + usort($secrets, static fn (string $a, string $b) => \strlen($b) <=> \strlen($a)); + + return $secrets; + } + + /** + * @param list $secrets + * @param array $seen Ids of the objects visited so far, to survive cyclic graphs + */ + private function collect(mixed $data, array &$secrets, array &$seen, int $depth): void + { + if ($depth > self::MAX_DEPTH) { + return; + } + + 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); + + continue; + } + + $this->collect($value, $secrets, $seen, $depth + 1); + } + + return; + } + + // the array walk above is essentially free, reflecting on objects is not + if (!\is_object($data) || !$this->redactSensitiveParameters) { + return; + } + + $id = spl_object_id($data); + if (isset($seen[$id])) { + return; + } + $seen[$id] = true; + + foreach ($this->getSensitiveProperties($data) as $property) { + if ($property->isInitialized($data)) { + $this->addSecret($property->getValue($data), $secrets); + } + } + + // public properties only, matching what formatters get to see, but enough to reach + // objects nested inside this one + foreach (get_object_vars($data) as $value) { + $this->collect($value, $secrets, $seen, $depth + 1); + } + } + + /** + * Reflects the properties of an object matching a #[SensitiveParameter] constructor parameter + * + * Non-public properties are included as they can still leak through __toString(), and the + * result is cached per class as reflecting on every logged object would be far too costly. + * + * @return array + */ + private function getSensitiveProperties(object $data): array + { + $class = $data::class; + if (isset($this->sensitiveProperties[$class])) { + return $this->sensitiveProperties[$class]; + } + + $names = Utils::getSensitiveParameterNames($class); + + // PHP 8.2+ wraps the arguments it hides from stack traces in a SensitiveParameterValue, + // whose own constructor parameter is not marked. Matched by name and read as a property + // so that it does not matter whether symfony/polyfill-php82 is installed on PHP 8.1: + // naming the class makes PHPStan report either the @internal getValue() the polyfill + // inherits, or a missing class when it is absent. + if ('SensitiveParameterValue' === $class) { + $names['value'] = true; + } + + $properties = []; + foreach ($names as $name => $_) { + // walking up is required as a private property of a parent class is not + // reachable through the child's ReflectionClass + for ($reflection = new \ReflectionClass($class); false !== $reflection; $reflection = $reflection->getParentClass()) { + if ($reflection->hasProperty($name)) { + $properties[$name] = $reflection->getProperty($name); + + break; + } + } + } + + return $this->sensitiveProperties[$class] = $properties; + } + + /** + * @param list $secrets + */ + private function addSecret(mixed $value, array &$secrets): void + { + // a Stringable secret ends up in the output as its string form, so that is what + // has to be looked for + if ($value instanceof \Stringable) { + try { + $value = $value->__toString(); + } catch (\Throwable) { + return; + } + } + + // ints/floats are left out on purpose, sweeping a digit run out of the output + // would silently corrupt unrelated data + if (\is_string($value) && $value !== '' && \strlen($value) >= $this->minSecretLength) { + $secrets[] = $value; + } + } + + /** + * Removes the collected secrets and applies the configured patterns to the formatter output, + * recursing into arrays to support formatters that do not return a string (e.g. MongoDBFormatter). + * + * @param list $secrets + */ + private function sweep(mixed $formatted, array $secrets, int $depth = 0): mixed + { + if ([] === $this->patterns && [] === $secrets) { + return $formatted; + } + + // deeper than any formatter will render anyway, and a custom one may well + // hand back a self-referencing array + if ($depth > self::MAX_DEPTH) { + return $formatted; + } + + if (\is_string($formatted)) { + if ([] !== $secrets) { + $formatted = str_replace($secrets, $this->mask, $formatted); + } + if ([] !== $this->patterns) { + $replaced = preg_replace($this->patterns, $this->mask, $formatted); + if (null === $replaced) { + // the patterns could not be applied so the output cannot be trusted, drop it + // entirely. Neither the patterns (which may embed a secret themselves) nor the + // mask are reported, preg_last_error_msg() is one of a fixed set of PCRE strings + return '[RedactingFormatter dropped this record, pattern redaction failed with "'.preg_last_error_msg().'"]'; + } + $formatted = $replaced; + } + + return $formatted; + } + + if (\is_array($formatted)) { + foreach ($formatted as $key => $value) { + $formatted[$key] = $this->sweep($value, $secrets, $depth + 1); + } + } + + return $formatted; + } +} diff --git a/tests/Monolog/Formatter/RedactingFormatterTest.php b/tests/Monolog/Formatter/RedactingFormatterTest.php new file mode 100644 index 0000000..ef4349c --- /dev/null +++ b/tests/Monolog/Formatter/RedactingFormatterTest.php @@ -0,0 +1,606 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Formatter; + +use Monolog\Level; +use Monolog\LogRecord; +use Monolog\Processor\PsrLogMessageProcessor; +use Monolog\Test\MonologTestCase; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * @covers Monolog\Formatter\RedactingFormatter + */ +class RedactingFormatterTest extends MonologTestCase +{ + public function testRedactsSensitiveKeysInContextAndExtra() + { + $captured = null; + $formatter = new RedactingFormatter($this->capturingFormatter($captured)); + + $formatter->format($this->getRecord( + context: ['user' => 'rishi', 'password' => 'super-secret', 'nested' => ['api_key' => 'abc123']], + extra: ['token' => 't-456', 'irrelevant' => 'keep'], + )); + + $this->assertSame('rishi', $captured->context['user']); + $this->assertSame('[REDACTED]', $captured->context['password']); + $this->assertSame('[REDACTED]', $captured->context['nested']['api_key']); + $this->assertSame('[REDACTED]', $captured->extra['token']); + $this->assertSame('keep', $captured->extra['irrelevant']); + } + + public function testKeyMatchingIsCaseInsensitive() + { + $captured = null; + $formatter = new RedactingFormatter($this->capturingFormatter($captured)); + + $formatter->format($this->getRecord(context: ['Password' => 'x', 'AUTHORIZATION' => 'Bearer y'])); + + $this->assertSame('[REDACTED]', $captured->context['Password']); + $this->assertSame('[REDACTED]', $captured->context['AUTHORIZATION']); + } + + public function testRedactsPatternsInFinalOutput() + { + $formatter = new RedactingFormatter( + new LineFormatter('%message% %context%', 'Y-m-d'), + sensitiveKeys: [], + patterns: ['{Bearer\s+[A-Za-z0-9._-]+}'], + ); + + $output = $formatter->format($this->getRecord( + message: 'Auth header Bearer abc.def-ghi', + context: ['header' => 'Bearer zzz999'], + )); + + $this->assertStringContainsString('Auth header [REDACTED]', $output); + $this->assertStringNotContainsString('Bearer', $output); + } + + public function testTokenPatternConstantRedactsLongTokens() + { + $formatter = new RedactingFormatter( + new LineFormatter('%message%', 'Y-m-d'), + sensitiveKeys: [], + patterns: [RedactingFormatter::TOKEN_PATTERN], + ); + + $output = $formatter->format($this->getRecord(message: 'token is test_0123456789abcdefghijklmnopqrstuvwxyz here')); + + $this->assertStringContainsString('token is [REDACTED] here', $output); + } + + public function testSweepsSecretsHiddenInsideNormalizedObjects() + { + // The secret lives inside an exception that only becomes a string once + // the wrapped JsonFormatter normalizes the record. Redacting the record + // alone would miss it; running the patterns over the output catches it. + $formatter = new RedactingFormatter( + new JsonFormatter(), + sensitiveKeys: [], + patterns: ['{Bearer\s+[A-Za-z0-9._-]+}'], + ); + + $output = $formatter->format($this->getRecord( + context: ['exception' => new \RuntimeException('failed using Bearer abc.def-ghi')], + )); + + $this->assertIsString($output); + $this->assertStringNotContainsString('Bearer abc.def-ghi', $output); + $this->assertStringContainsString('[REDACTED]', $output); + } + + public function testSweepsArrayReturningFormatters() + { + $formatter = new RedactingFormatter( + new NormalizerFormatter('Y-m-d'), + sensitiveKeys: [], + patterns: ['{Bearer\s+[A-Za-z0-9._-]+}'], + ); + + $output = $formatter->format($this->getRecord(message: 'Bearer abc.def-ghi')); + + $this->assertIsArray($output); + $this->assertSame('[REDACTED]', $output['message']); + } + + public function testFormatBatchRedactsEachRecord() + { + $formatter = new RedactingFormatter( + new LineFormatter('%message% %context%', 'Y-m-d'), + patterns: ['{Bearer\s+[A-Za-z0-9._-]+}'], + ); + + // LineFormatter::formatBatch joins records into a single string + $output = $formatter->formatBatch([ + $this->getRecord(message: 'one Bearer aaa.bbb-ccc', context: ['password' => 'p1']), + $this->getRecord(message: 'two', context: ['token' => 't2']), + ]); + + $this->assertIsString($output); + $this->assertStringNotContainsString('Bearer', $output); + $this->assertStringNotContainsString('p1', $output); + $this->assertStringNotContainsString('t2', $output); + $this->assertSame(3, substr_count($output, '[REDACTED]')); // password key, token key, and Bearer pattern + } + + public function testFormatBatchWithArrayReturningFormatter() + { + $formatter = new RedactingFormatter(new NormalizerFormatter('Y-m-d')); + + $output = $formatter->formatBatch([ + $this->getRecord(context: ['password' => 'p1']), + $this->getRecord(context: ['token' => 't2']), + ]); + + $this->assertIsArray($output); + $this->assertSame('[REDACTED]', $output[0]['context']['password']); + $this->assertSame('[REDACTED]', $output[1]['context']['token']); + } + + public function testThrowsOnInvalidPattern() + { + $this->expectException(\InvalidArgumentException::class); + + new RedactingFormatter(new LineFormatter(), patterns: ['{invalid']); + } + + public function testNoPatternsLeavesOutputUntouched() + { + $inner = new LineFormatter('%message%', 'Y-m-d'); + $record = $this->getRecord(message: 'a long token test_0123456789abcdefghijklmnopqrstuvwxyz'); + + $formatter = new RedactingFormatter($inner, sensitiveKeys: []); + + $this->assertSame($inner->format($record), $formatter->format($record)); + } + + public function testCustomMask() + { + $captured = null; + $formatter = new RedactingFormatter($this->capturingFormatter($captured), mask: '***'); + + $formatter->format($this->getRecord(context: ['password' => 'x'])); + + $this->assertSame('***', $captured->context['password']); + } + + public function testRedactsSensitiveParameterValues() + { + $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord(context: ['user' => new SensitiveParamHolder()])); + + $this->assertStringNotContainsString('s3cret-webhook', $output); + $this->assertStringContainsString('[REDACTED]', $output); + // the non-sensitive parameter is left alone + $this->assertStringContainsString('user-1', $output); + } + + public function testRedactsSensitiveParameterValuesWithJsonFormatter() + { + // JsonFormatter hands objects straight to json_encode, so nothing normalizes them + $formatter = new RedactingFormatter(new JsonFormatter()); + + $output = $formatter->format($this->getRecord(context: ['user' => new SensitiveParamHolder()])); + + $this->assertIsString($output); + $this->assertStringNotContainsString('s3cret-webhook', $output); + $this->assertStringContainsString('"mySecretUrl":"[REDACTED]"', $output); + } + + public function testRedactsNonPublicSensitiveParameterLeakedByToString() + { + $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord(context: ['conn' => new SensitiveDsnHolder()])); + + $this->assertStringNotContainsString('hunter2', $output); + $this->assertStringContainsString('Connection([REDACTED])', $output); + } + + public function testRedactsNonPromotedSensitiveParameterMatchedByPropertyName() + { + $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord(context: ['sub' => new SensitiveNonPromotedHolder()])); + + $this->assertStringNotContainsString('tok-abcdefghij', $output); + $this->assertStringContainsString('[REDACTED]', $output); + } + + public function testRedactsSensitiveParameterOnNestedObjectAndInsideArrays() + { + $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord(context: ['list' => [['nested' => new SensitiveNestingHolder()]]])); + + $this->assertStringNotContainsString('s3cret-webhook', $output); + $this->assertStringContainsString('[REDACTED]', $output); + } + + public function testRedactsSecretAlreadyInterpolatedIntoTheMessage() + { + $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d')); + $processor = new PsrLogMessageProcessor(); + + $output = $formatter->format($processor($this->getRecord( + message: 'calling api with {token}', + context: ['token' => 'tok-supersecret-value'], + ))); + + $this->assertStringNotContainsString('tok-supersecret-value', $output); + $this->assertStringContainsString('calling api with [REDACTED]', $output); + } + + public function testRedactsSecretEmbeddedInAnExceptionMessage() + { + $formatter = new RedactingFormatter(new JsonFormatter()); + + $output = $formatter->format($this->getRecord(context: [ + 'user' => new SensitiveParamHolder(), + 'exception' => new \RuntimeException('could not reach https://example.org/s3cret-webhook'), + ])); + + $this->assertIsString($output); + $this->assertStringNotContainsString('s3cret-webhook', $output); + $this->assertStringContainsString('could not reach [REDACTED]', $output); + } + + public function testRedactsSecretsNeedingJsonEscaping() + { + $formatter = new RedactingFormatter(new JsonFormatter(), sensitiveKeys: ['password']); + + $output = $formatter->format($this->getRecord( + message: 'login failed for pa"ss\\word-123', + context: ['password' => 'pa"ss\\word-123'], + )); + + $this->assertIsString($output); + $this->assertStringNotContainsString('word-123', $output); + $this->assertSame(2, substr_count($output, '[REDACTED]')); // masked key + swept message + } + + public function testRedactsSensitiveParameterValueObjects() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('SensitiveParameterValue requires PHP 8.2'); + } + + $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord( + message: 'token was tok-supersecret-value', + context: ['arg' => new \SensitiveParameterValue('tok-supersecret-value')], + )); + + $this->assertStringNotContainsString('tok-supersecret-value', $output); + } + + public function testShortSecretsAreNotSwept() + { + $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord( + message: 'user pass logged in', + context: ['password' => 'pass'], + )); + + // the key is still masked, but "pass" is too short to be removed from the message + $this->assertStringContainsString('user pass logged in', $output); + $this->assertStringContainsString('[REDACTED]', $output); + } + + public function testMinSecretLengthIsConfigurable() + { + $formatter = new RedactingFormatter(new LineFormatter('%message%', 'Y-m-d'), minSecretLength: 4); + + $output = $formatter->format($this->getRecord(message: 'user pass logged in', context: ['password' => 'pass'])); + + $this->assertStringContainsString('user [REDACTED] logged in', $output); + } + + public function testSensitiveParameterSupportCanBeDisabled() + { + $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d'), redactSensitiveParameters: false); + + $output = $formatter->format($this->getRecord( + message: 'calling https://example.org/s3cret-webhook', + context: ['user' => new SensitiveParamHolder(), 'token' => 'tok-supersecret-value'], + )); + + $this->assertStringContainsString('calling https://example.org/s3cret-webhook', $output); + // sensitive keys are still swept from the output, only the objects are left alone + $this->assertStringNotContainsString('tok-supersecret-value', $output); + } + + #[DataProvider('provideEdgeCaseObjects')] + public function testHandlesObjectsWithoutUsableConstructors(object $object) + { + $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord(context: ['edge' => $object])); + + $this->assertIsString($output); + } + + public static function provideEdgeCaseObjects(): array + { + $cyclic = new SensitiveCyclicHolder(); + $cyclic->next = $cyclic; + + return [ + 'no constructor' => [new \stdClass()], + 'enum' => [SensitiveTestEnum::One], + 'anonymous class' => [new class ('anon-secret-value') { + public function __construct(#[\SensitiveParameter] public string $secret) + { + } + }], + 'uninitialized property' => [(new \ReflectionClass(SensitiveNonPromotedHolder::class))->newInstanceWithoutConstructor()], + 'cyclic graph' => [$cyclic], + ]; + } + + public function testCyclicGraphSecretsAreStillFound() + { + $cyclic = new SensitiveCyclicHolder(); + $cyclic->next = $cyclic; + + $formatter = new RedactingFormatter(new LineFormatter('%message%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord(message: 'leaked cyclic-secret-value', context: ['obj' => $cyclic])); + + $this->assertStringContainsString('leaked [REDACTED]', $output); + } + + public function testRedactsSensitiveParameterInheritedFromAParentClass() + { + $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord(context: ['creds' => new ChildCredentials()])); + + $this->assertStringNotContainsString('tok-inherited-secret', $output); + $this->assertStringContainsString('token=[REDACTED]', $output); + } + + public function testRedactsStringableSecretsAtSensitiveKeys() + { + $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord( + message: 'authenticating with stringable-secret-value', + context: ['token' => new StringableSecret()], + )); + + $this->assertStringNotContainsString('stringable-secret-value', $output); + } + + public function testRedactsStringableSensitiveParameters() + { + $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord(context: ['holder' => new StringableSecretHolder()])); + + $this->assertStringNotContainsString('stringable-secret-value', $output); + $this->assertStringContainsString('auth=[REDACTED]', $output); + } + + public function testAThrowingToStringDoesNotBreakFormatting() + { + $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord( + message: 'still logged', + context: ['token' => new ThrowingStringable()], + )); + + $this->assertStringContainsString('still logged', $output); + } + + public function testARecordIsDroppedRatherThanLeakedWhenAPatternCannotBeApplied() + { + // a /u pattern over invalid UTF-8 makes preg_replace() return null + $formatter = new RedactingFormatter( + new LineFormatter('%message%', 'Y-m-d'), + sensitiveKeys: [], + patterns: ['{tok-[a-z0-9]+}u'], + ); + + $output = $formatter->format($this->getRecord(message: 'secret tok-abc123 in '.\chr(0xFF).' here')); + + $this->assertStringNotContainsString('tok-abc123', $output); + $this->assertStringContainsString('RedactingFormatter dropped this record', $output); + // neither the pattern nor the mask is echoed back, both may embed secret material + $this->assertStringNotContainsString('tok-[a-z0-9]+', $output); + } + + public function testSelfReferencingContextArraysDoNotRecurseForever() + { + $context = ['password' => 'super-secret-value']; + $context['self'] = &$context; + + $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d')); + + $output = $formatter->format($this->getRecord(context: $context)); + + $this->assertIsString($output); + $this->assertStringNotContainsString('super-secret-value', $output); + } + + public function testSelfReferencingFormatterOutputDoesNotRecurseForever() + { + $inner = new class implements FormatterInterface { + public function format(LogRecord $record) + { + $formatted = ['leaked' => 'tok-supersecret-value']; + $formatted['self'] = &$formatted; + + return $formatted; + } + + public function formatBatch(array $records) + { + return array_map(fn (LogRecord $record) => $this->format($record), $records); + } + }; + + $output = (new RedactingFormatter($inner))->format($this->getRecord(context: ['token' => 'tok-supersecret-value'])); + + $this->assertIsArray($output); + $this->assertSame('[REDACTED]', $output['leaked']); + } + + public function testGetWrappedFormatter() + { + $inner = new LineFormatter(); + + $this->assertSame($inner, (new RedactingFormatter($inner))->getWrappedFormatter()); + } + + /** + * Returns a formatter that records the (already redacted) LogRecord it receives + * into $captured, so tests can assert on the structured record passed downstream. + */ + private function capturingFormatter(?LogRecord &$captured): FormatterInterface + { + return new class($captured) implements FormatterInterface { + public function __construct(private ?LogRecord &$captured) + { + } + + public function format(LogRecord $record) + { + $this->captured = $record; + + return ''; + } + + public function formatBatch(array $records) + { + foreach ($records as $record) { + $this->captured = $record; + } + + return ''; + } + }; + } +} + +class SensitiveParamHolder +{ + public function __construct( + public string $id = 'user-1', + #[\SensitiveParameter] + public string $mySecretUrl = 'https://example.org/s3cret-webhook', + ) { + } +} + +class SensitiveDsnHolder +{ + public function __construct( + #[\SensitiveParameter] + private string $dsn = 'mysql://user:hunter2@localhost/db', + ) { + } + + public function __toString(): string + { + return 'Connection('.$this->dsn.')'; + } +} + +class SensitiveNonPromotedHolder +{ + public string $token; + + public function __construct(#[\SensitiveParameter] string $token = 'tok-abcdefghij') + { + $this->token = $token; + } +} + +class SensitiveNestingHolder +{ + public function __construct(public SensitiveParamHolder $inner = new SensitiveParamHolder()) + { + } +} + +class SensitiveCyclicHolder +{ + public ?self $next = null; + + public function __construct(#[\SensitiveParameter] public string $secret = 'cyclic-secret-value') + { + } +} + +enum SensitiveTestEnum: string +{ + case One = 'one'; +} + +class ParentCredentials +{ + public function __construct( + #[\SensitiveParameter] + private string $token = 'tok-inherited-secret', + ) { + } + + public function __toString(): string + { + return "token={$this->token}"; + } +} + +class ChildCredentials extends ParentCredentials +{ +} + +class StringableSecret +{ + public function __construct(private string $value = 'stringable-secret-value') + { + } + + public function __toString(): string + { + return $this->value; + } +} + +class StringableSecretHolder +{ + public function __construct( + #[\SensitiveParameter] + private StringableSecret $token = new StringableSecret(), + ) { + } + + public function __toString(): string + { + return 'auth='.$this->token; + } +} + +class ThrowingStringable +{ + public function __toString(): string + { + throw new \RuntimeException('nope'); + } +}