Upstream Seldaek/monolog state of the touched files before 1191965241
This commit is contained in:
commit
2216d4fc24
2 files changed
+956
No files matched your search
@@ -0,0 +1,350 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Monolog package.
|
||||||
|
*
|
||||||
|
* (c) Jordi Boggiano <j.boggiano@seld.be>
|
||||||
|
*
|
||||||
|
* 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 <j.boggiano@seld.be>
|
||||||
|
*/
|
||||||
|
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<string> Lowercased sensitive keys */
|
||||||
|
private array $sensitiveKeys;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $patterns;
|
||||||
|
|
||||||
|
/** @var array<class-string, array<string, \ReflectionProperty>> */
|
||||||
|
private array $sensitiveProperties = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param FormatterInterface $formatter The formatter to wrap and whose output gets redacted
|
||||||
|
* @param list<string> $sensitiveKeys Exact context/extra keys whose values to mask (case-insensitive)
|
||||||
|
* @param list<string> $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<mixed> $data
|
||||||
|
* @return array<mixed>
|
||||||
|
*/
|
||||||
|
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<LogRecord> $records
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
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<string> $secrets
|
||||||
|
* @param array<int, true> $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<string, \ReflectionProperty>
|
||||||
|
*/
|
||||||
|
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<string> $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<string> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Monolog package.
|
||||||
|
*
|
||||||
|
* (c) Jordi Boggiano <j.boggiano@seld.be>
|
||||||
|
*
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user