Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,30 @@ Monolog runs processors in **reverse** of their push order. To also mask the
Recursion is bounded by `maxDepth` (default 16); anything deeper — and any object
cycle — is replaced with `[TRUNCATED]` rather than traversed.

### JSON inside a string value

Some logs carry a serialized payload as a *string* — typically the raw body of a
POST request. Declare those keys with `withJsonKeys()` and the processor decodes
the JSON, masks it recursively (same key + value rules), then re-encodes it:

```php
$processor = MaskerBuilder::create()
->withJsonKeys(['body', 'request_body'])
->buildProcessor();

$logger->info('request', ['body' => '{"username":"alice","password":"hunter2"}']);
// context becomes:
// ['body' => '{"username":"alice","password":"████████"}']
```

- **Opt-in**: with no JSON keys declared the behaviour is unchanged — such a
string stays an opaque leaf.
- A *decodable* JSON value takes precedence over a sensitive-key match, so a key
that is both sensitive and declared JSON is looked *into* rather than collapsed.
A non-decodable value (or a JSON scalar) falls back to the normal rules.
- Re-encoding normalises formatting (whitespace, escaping; an empty `{}` comes
back as `[]`) — the masked payload is semantically equal, not byte-identical.
Comment on lines +122 to +123

### Masking strategies

| Strategy | Result | When |
Expand Down Expand Up @@ -172,7 +196,8 @@ The masking engine is decoupled from Monolog so it can be tested and reused on
its own:

- `Masker` — recursive, immutable engine (never mutates its input; traverses
arrays and objects; bounded by a max depth that also guards against cycles).
arrays and objects; bounded by a max depth that also guards against cycles;
optionally decodes/masks/re-encodes JSON held in string values via JSON keys).
- `MaskingProcessor` — thin Monolog adapter (`ProcessorInterface`).
- `Matcher\*` — pluggable detection: keys (`KeyListMatcher`, `SegmentKeyMatcher`)
and values (`RegexValueMatcher`, `CreditCardMatcher`, `ChainValueMatcher`).
Expand Down
48 changes: 48 additions & 0 deletions src/Masker/Masker.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
* their public properties — matching how Monolog would otherwise serialise them,
* so secrets they carry cannot slip through unmasked.
*
* Keys flagged by the optional JSON-key matcher are treated as structured
* containers: when their value is a string that decodes to JSON, it is decoded,
* masked recursively, then re-encoded — so secrets buried inside a serialized
* payload (e.g. a logged request body) are masked too. A decodable JSON value
* takes precedence over a sensitive-key match; a non-decodable one falls back to
* the normal rules.
*
* The input is never mutated — {@see mask()} returns a fresh copy. Recursion is
* bounded by a configurable maximum depth (branches beyond it become a
* truncation marker), and object cycles are tracked to avoid infinite loops.
Expand All @@ -35,6 +42,7 @@ public function __construct(
private readonly MaskStrategyInterface $strategy,
private readonly int $maxDepth = 16,
private readonly bool $traverseObjects = true,
private readonly ?KeyMatcherInterface $jsonKeyMatcher = null,
) {
if ($maxDepth < 1) {
throw new \InvalidArgumentException('The maximum depth must be at least 1.');
Expand Down Expand Up @@ -66,6 +74,16 @@ private function processArray(array $data, int $depth, \SplObjectStorage $seen):
$masked = [];

foreach ($data as $key => $value) {
if (null !== $this->jsonKeyMatcher && \is_string($value) && $this->jsonKeyMatcher->matches($key)) {
$decoded = $this->decodeJsonArray($value);
if (null !== $decoded) {
$masked[$key] = $this->encodeJson($this->processArray($decoded, $depth + 1, $seen));

continue;
}
// Not decodable JSON: fall through to the normal rules below.
}
Comment on lines 76 to +85

$masked[$key] = $this->keyMatcher->matches($key)
? $this->maskValue($value)
: $this->processValue($value, $depth, $seen);
Expand Down Expand Up @@ -183,4 +201,34 @@ private function maskValue(mixed $value): string

return $this->strategy->mask('');
}

/**
* Decodes a string to an associative array, or returns null when the string
* is not valid JSON or decodes to a scalar — in which case the caller falls
* back to the normal key/value masking rules.
*
* @return array<array-key, mixed>|null
*/
private function decodeJsonArray(string $value): ?array
{
$decoded = json_decode($value, true);

if (\JSON_ERROR_NONE !== json_last_error() || !\is_array($decoded)) {
return null;
}

return $decoded;
}

/**
* Re-encodes a masked structure to a JSON string. {@see \JSON_THROW_ON_ERROR}
* narrows the return type to string and is unreachable in practice: the input
* always originates from a successful {@see decodeJsonArray()}.
*
* @param array<array-key, mixed> $masked
*/
private function encodeJson(array $masked): string
{
return json_encode($masked, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
}
}
20 changes: 20 additions & 0 deletions src/MaskerBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ final class MaskerBuilder
/**
* @param list<string> $additionalKeys
* @param array<string, string> $additionalPatterns
* @param list<string> $jsonKeys
*/
private function __construct(
private readonly ?KeyMatcherInterface $keyMatcher = null,
Expand All @@ -44,6 +45,7 @@ private function __construct(
private readonly bool $exactKeys = false,
private readonly bool $maskMessage = true,
private readonly bool $traverseObjects = true,
private readonly array $jsonKeys = [],
) {
}

Expand All @@ -62,6 +64,18 @@ public function withSensitiveKeys(array $keys): self
return $this->cloneWith(additionalKeys: [...$this->additionalKeys, ...$keys]);
}

/**
* Declares keys whose string value holds JSON to be masked in depth: the
* value is decoded, masked recursively, then re-encoded. Off by default
* (opt-in). Accumulates across calls.
*
* @param list<string> $keys
*/
public function withJsonKeys(array $keys): self
{
return $this->cloneWith(jsonKeys: [...$this->jsonKeys, ...$keys]);
}

/**
* Replaces the key matcher entirely (ignores the default key list).
*/
Expand Down Expand Up @@ -135,12 +149,15 @@ public function traverseObjects(bool $enabled = true): self

public function buildMasker(): Masker
{
$jsonKeyMatcher = [] === $this->jsonKeys ? null : new KeyListMatcher($this->jsonKeys);

return new Masker(
$this->keyMatcher ?? $this->defaultKeyMatcher(),
$this->valueMatchingEnabled ? ($this->valueMatcher ?? $this->defaultValueMatcher()) : null,
$this->strategy ?? new FullMaskStrategy(),
$this->maxDepth,
$this->traverseObjects,
$jsonKeyMatcher,
);
}

Expand All @@ -167,6 +184,7 @@ private function defaultValueMatcher(): ValueMatcherInterface
/**
* @param list<string>|null $additionalKeys
* @param array<string, string>|null $additionalPatterns
* @param list<string>|null $jsonKeys
*/
private function cloneWith(
?KeyMatcherInterface $keyMatcher = null,
Expand All @@ -179,6 +197,7 @@ private function cloneWith(
?bool $exactKeys = null,
?bool $maskMessage = null,
?bool $traverseObjects = null,
?array $jsonKeys = null,
): self {
return new self(
$keyMatcher ?? $this->keyMatcher,
Expand All @@ -191,6 +210,7 @@ private function cloneWith(
$exactKeys ?? $this->exactKeys,
$maskMessage ?? $this->maskMessage,
$traverseObjects ?? $this->traverseObjects,
$jsonKeys ?? $this->jsonKeys,
);
}
}
148 changes: 147 additions & 1 deletion tests/Masker/MaskerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ final class MaskerTest extends TestCase
{
private const MASK = '***';

private function masker(int $maxDepth = 16): Masker
/**
* @param list<string> $jsonKeys
*/
private function masker(int $maxDepth = 16, array $jsonKeys = []): Masker
{
return new Masker(
new KeyListMatcher(['password', 'token']),
RegexValueMatcher::withDefaults(),
new FullMaskStrategy(self::MASK),
$maxDepth,
jsonKeyMatcher: [] === $jsonKeys ? null : new KeyListMatcher($jsonKeys),
);
}

Expand Down Expand Up @@ -435,6 +439,148 @@ public function testDefaultMaxDepthIsSixteen(): void
self::assertSame(16, self::preservedDepth($result));
}

public function testMasksSensitiveKeyInsideJsonStringValue(): void
{
// A declared JSON key is decoded, masked in depth, then re-encoded.
// A sibling string key that is NOT a JSON key follows the normal rules.
$result = $this->masker(jsonKeys: ['body'])->mask([
'body' => '{"username":"alice","password":"hunter2"}',
'channel' => 'web',
]);

self::assertSame([
'body' => '{"username":"alice","password":"***"}',
'channel' => 'web',
], $result);
}

public function testMasksValueMatchedByPatternInsideJsonStringValue(): void
{
$result = $this->masker(jsonKeys: ['body'])->mask([
'body' => '{"contact":"john.doe@example.com"}',
]);

self::assertSame(['body' => '{"contact":"***"}'], $result);
}

public function testMasksNestedJsonStructureInsideStringValue(): void
{
$result = $this->masker(jsonKeys: ['body'])->mask([
'body' => '{"user":{"name":"alice","password":"x"}}',
]);

self::assertSame(['body' => '{"user":{"name":"alice","password":"***"}}'], $result);
}

public function testJsonInsideStringValueRespectsMaxDepth(): void
{
// The decoded structure is processed at depth+1, so the depth budget
// keeps applying across the JSON boundary.
$result = $this->masker(2, ['body'])->mask([
'body' => '{"a":{"b":"c"}}',
]);

self::assertSame(['body' => '{"a":"[TRUNCATED]"}'], $result);
}

public function testJsonInsideStringValuePreservedWhenWithinMaxDepth(): void
{
// Same payload, one more depth level available: the decoded structure is
// fully preserved — pins the exact depth+1 budget across the boundary.
$result = $this->masker(3, ['body'])->mask([
'body' => '{"a":{"b":"c"}}',
]);

self::assertSame(['body' => '{"a":{"b":"c"}}'], $result);
}

public function testJsonReEncodingLeavesSlashesAndUnicodeUnescaped(): void
{
// Re-encoding keeps payloads readable: slashes and non-ASCII characters
// are not escaped to \/ or \uXXXX.
$result = $this->masker(jsonKeys: ['body'])->mask([
'body' => '{"url":"a/b","note":"é"}',
]);

self::assertSame(['body' => '{"url":"a/b","note":"é"}'], $result);
}

public function testJsonWinsOverSensitiveKeyWhenValueIsValidJson(): void
{
// 'token' is BOTH sensitive and declared JSON: a valid JSON value is
// looked into rather than collapsed.
$result = $this->masker(jsonKeys: ['token'])->mask([
'token' => '{"id":1,"password":"x"}',
]);

self::assertSame(['token' => '{"id":1,"password":"***"}'], $result);
}

public function testJsonKeyWithInvalidJsonFallsBackToSensitiveCollapse(): void
{
// 'token' is sensitive AND declared JSON, but the value is not decodable:
// it falls back to the normal rules, so the sensitive key collapses it.
$result = $this->masker(jsonKeys: ['token'])->mask(['token' => 'not-json']);

self::assertSame(['token' => self::MASK], $result);
}

public function testJsonKeyWithInvalidJsonFallsBackToLeafValueMatching(): void
{
// Not sensitive, not decodable JSON: the leaf value matcher still runs.
$result = $this->masker(jsonKeys: ['body'])->mask([
'body' => 'john.doe@example.com',
'note' => 'plain text',
]);

self::assertSame(['body' => self::MASK, 'note' => 'plain text'], $result);
}

public function testJsonKeyWithNonStringValueRecursesAsArray(): void
{
// A JSON key holding an actual array (already decoded) is recursed
// normally — the JSON branch only applies to string values.
$result = $this->masker(jsonKeys: ['body'])->mask([
'body' => ['password' => 'x', 'safe' => 'y'],
]);

self::assertSame(['body' => ['password' => self::MASK, 'safe' => 'y']], $result);
}

public function testJsonKeyWithScalarJsonFallsBack(): void
{
// A string decoding to a scalar (not an array) is not treated as a
// structured payload; it falls back to the normal leaf rules.
$result = $this->masker(jsonKeys: ['body'])->mask(['body' => '42']);

self::assertSame(['body' => '42'], $result);
}

public function testMasksJsonListInsideStringValue(): void
{
$result = $this->masker(jsonKeys: ['body'])->mask([
'body' => '["john.doe@example.com","safe"]',
]);

self::assertSame(['body' => '["***","safe"]'], $result);
}

public function testWithoutJsonKeysLeavesJsonStringsUntouched(): void
{
// No JSON keys configured: a JSON string is just an opaque leaf.
$result = $this->masker()->mask(['body' => '{"password":"x"}']);

self::assertSame(['body' => '{"password":"x"}'], $result);
}

public function testIsIdempotentOnJsonStringValue(): void
{
$masker = $this->masker(jsonKeys: ['body']);
$once = $masker->mask(['body' => '{"password":"hunter2","email":"john.doe@example.com"}']);

self::assertSame($once, $masker->mask($once));
}

/**
* Builds ['next' => ['next' => ... ['leaf' => 'x']]] nested $levels deep.
*
Expand Down
Loading
Loading