diff --git a/README.md b/README.md index 707ece7..72dd3f4 100644 --- a/README.md +++ b/README.md @@ -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. + ### Masking strategies | Strategy | Result | When | @@ -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`). diff --git a/src/Masker/Masker.php b/src/Masker/Masker.php index 6282021..b5d9f07 100644 --- a/src/Masker/Masker.php +++ b/src/Masker/Masker.php @@ -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. @@ -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.'); @@ -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. + } + $masked[$key] = $this->keyMatcher->matches($key) ? $this->maskValue($value) : $this->processValue($value, $depth, $seen); @@ -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|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 $masked + */ + private function encodeJson(array $masked): string + { + return json_encode($masked, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + } } diff --git a/src/MaskerBuilder.php b/src/MaskerBuilder.php index b164854..65e4d56 100644 --- a/src/MaskerBuilder.php +++ b/src/MaskerBuilder.php @@ -32,6 +32,7 @@ final class MaskerBuilder /** * @param list $additionalKeys * @param array $additionalPatterns + * @param list $jsonKeys */ private function __construct( private readonly ?KeyMatcherInterface $keyMatcher = null, @@ -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 = [], ) { } @@ -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 $keys + */ + public function withJsonKeys(array $keys): self + { + return $this->cloneWith(jsonKeys: [...$this->jsonKeys, ...$keys]); + } + /** * Replaces the key matcher entirely (ignores the default key list). */ @@ -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, ); } @@ -167,6 +184,7 @@ private function defaultValueMatcher(): ValueMatcherInterface /** * @param list|null $additionalKeys * @param array|null $additionalPatterns + * @param list|null $jsonKeys */ private function cloneWith( ?KeyMatcherInterface $keyMatcher = null, @@ -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, @@ -191,6 +210,7 @@ private function cloneWith( $exactKeys ?? $this->exactKeys, $maskMessage ?? $this->maskMessage, $traverseObjects ?? $this->traverseObjects, + $jsonKeys ?? $this->jsonKeys, ); } } diff --git a/tests/Masker/MaskerTest.php b/tests/Masker/MaskerTest.php index 4c6ae81..882bd5b 100644 --- a/tests/Masker/MaskerTest.php +++ b/tests/Masker/MaskerTest.php @@ -16,13 +16,17 @@ final class MaskerTest extends TestCase { private const MASK = '***'; - private function masker(int $maxDepth = 16): Masker + /** + * @param list $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), ); } @@ -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. * diff --git a/tests/MaskerBuilderTest.php b/tests/MaskerBuilderTest.php index 72ffecd..7bb83e1 100644 --- a/tests/MaskerBuilderTest.php +++ b/tests/MaskerBuilderTest.php @@ -295,6 +295,66 @@ public function testObjectTraversalCanBeReEnabledWithNoArgument(): void self::assertSame(['o' => ['password' => '***']], $masker->mask(['o' => $object])); } + public function testWithJsonKeysMasksInsideJsonStringValue(): void + { + $masker = MaskerBuilder::create() + ->withJsonKeys(['body']) + ->withStrategy(new FullMaskStrategy('***')) + ->buildMasker(); + + $result = $masker->mask(['body' => '{"password":"x","ok":"y"}']); + + self::assertSame(['body' => '{"password":"***","ok":"y"}'], $result); + } + + public function testWithJsonKeysAcceptsSeveralKeysAtOnce(): void + { + $masker = MaskerBuilder::create() + ->withJsonKeys(['body', 'payload']) + ->withStrategy(new FullMaskStrategy('***')) + ->buildMasker(); + + $result = $masker->mask([ + 'body' => '{"password":"x"}', + 'payload' => '{"token":"y"}', + ]); + + self::assertSame([ + 'body' => '{"password":"***"}', + 'payload' => '{"token":"***"}', + ], $result); + } + + public function testWithJsonKeysAccumulatesAcrossCalls(): void + { + $masker = MaskerBuilder::create() + ->withJsonKeys(['body']) + ->withJsonKeys(['payload']) + ->withStrategy(new FullMaskStrategy('***')) + ->buildMasker(); + + $result = $masker->mask([ + 'body' => '{"password":"x"}', + 'payload' => '{"token":"y"}', + ]); + + self::assertSame([ + 'body' => '{"password":"***"}', + 'payload' => '{"token":"***"}', + ], $result); + } + + public function testWithoutJsonKeysLeavesJsonStringsUntouched(): void + { + $masker = MaskerBuilder::create() + ->withStrategy(new FullMaskStrategy('***')) + ->buildMasker(); + + $result = $masker->mask(['body' => '{"password":"x"}']); + + self::assertSame(['body' => '{"password":"x"}'], $result); + } + private function record(): LogRecord { return new LogRecord( diff --git a/tests/Property/JsonMaskerPropertyTest.php b/tests/Property/JsonMaskerPropertyTest.php new file mode 100644 index 0000000..4973ca0 --- /dev/null +++ b/tests/Property/JsonMaskerPropertyTest.php @@ -0,0 +1,111 @@ +withJsonKeys([self::JSON_KEY]) + ->withStrategy(new FullMaskStrategy(self::MASK)) + ->buildMasker(); + } + + public function testReEncodesToValidJson(): void + { + $this + ->forAll(Generators::nestedArray()) + ->then(function (array $payload): void { + $output = $this->masker()->mask([self::JSON_KEY => self::encode($payload)]); + + $this->assertIsString($output[self::JSON_KEY]); + json_decode($output[self::JSON_KEY], true); + $this->assertSame(\JSON_ERROR_NONE, json_last_error()); + }); + } + + public function testNoSensitiveKeySurvivesInsideTheJsonPayload(): void + { + $this + ->forAll(Generators::nestedArray()) + ->then(function (array $payload): void { + $output = $this->masker()->mask([self::JSON_KEY => self::encode($payload)]); + $decoded = json_decode($output[self::JSON_KEY], true); + + $this->assertSensitiveKeysMasked(\is_array($decoded) ? $decoded : []); + }); + } + + public function testIsIdempotent(): void + { + $this + ->forAll(Generators::nestedArray()) + ->then(function (array $payload): void { + $masker = $this->masker(); + $once = $masker->mask([self::JSON_KEY => self::encode($payload)]); + + $this->assertSame($once, $masker->mask($once)); + }); + } + + public function testDoesNotMutateInput(): void + { + $this + ->forAll(Generators::nestedArray()) + ->then(function (array $payload): void { + $input = [self::JSON_KEY => self::encode($payload)]; + $snapshot = $input; + $this->masker()->mask($input); + + $this->assertSame($snapshot, $input); + }); + } + + /** + * @param array $payload + */ + private static function encode(array $payload): string + { + return json_encode($payload, \JSON_THROW_ON_ERROR); + } + + /** + * @param array $output + */ + private function assertSensitiveKeysMasked(array $output): void + { + $keyMatcher = KeyListMatcher::withDefaults(); + + foreach ($output as $key => $value) { + if (\is_string($key) && $keyMatcher->matches($key)) { + $this->assertSame(self::MASK, $value); + + continue; + } + + if (\is_array($value)) { + $this->assertSensitiveKeysMasked($value); + } + } + } +}