Skip to content

feat(masker): mask JSON held in string values via JSON keys - #4

Merged
rflavien merged 1 commit into
mainfrom
feat/json-key-masking
Jun 10, 2026
Merged

feat(masker): mask JSON held in string values via JSON keys#4
rflavien merged 1 commit into
mainfrom
feat/json-key-masking

Conversation

@rflavien

Copy link
Copy Markdown
Contributor

Contexte

Certains logs poussent dans context/extra une clé dont la valeur est une string contenant du JSON — typiquement le body brut d'une requête POST. Jusqu'ici cette valeur était une feuille opaque : seul le ValueMatcher (regex) s'appliquait sur la string entière, donc un password enfoui dans le JSON fuyait dans les logs.

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

Solution

Nouvelle notion de « clés JSON », opt-in, configurée via MaskerBuilder::withJsonKeys([...]). Pour ces clés, si la valeur est une string décodable, le moteur la décode, applique le masquage récursif habituel (clés sensibles + valeurs sensibles), puis ré-encode :

$processor = MaskerBuilder::create()
    ->withJsonKeys(['body', 'request_body'])
    ->buildProcessor();
// ['body' => '{"username":"alice","password":"████████"}']

Comportement

  • Opt-in : sans withJsonKeys, comportement inchangé (la string reste une feuille opaque).
  • Précédence : un JSON décodable l'emporte sur une clé sensible (on regarde dedans au lieu de collapser). Un JSON non décodable / un scalaire JSON retombe sur les règles normales (donc une clé aussi sensible collapse).
  • Le ré-encodage normalise le formatage (espaces, échappement ; un {} vide ressort en []) — payload sémantiquement équivalent, pas byte-identique.

Détails d'implémentation

  • Masker : paramètre constructeur optionnel ?KeyMatcherInterface $jsonKeyMatcher (réutilise KeyListMatcher), branche en tête de process(), helpers decodeJsonArray() / encodeJson(). La structure décodée est traitée à depth+1 → la borne maxDepth continue de s'appliquer à travers la frontière JSON.
  • MaskerBuilder : méthode fluide accumulative withJsonKeys().

Tests

  • Unitaires (MaskerTest, MaskerBuilderTest) : objet/list JSON, valeur détectée par pattern, imbrication + truncation maxDepth, fallback (JSON invalide / scalaire / valeur non-string), JSON gagne sur sensible, ré-encodage slashes/unicode non échappés, idempotence, non-régression sans clés JSON.
  • Property-based : nouveau tests/Property/JsonMaskerPropertyTest.php (sortie re-décodable, aucun secret ne survit dans le payload, idempotence, non-mutation).

Validation

make validate ✅ — cs-check, phpstan (max), coverage 100% (110/110 lignes), infection MSI 100%, proofs (18 tests, 2738 assertions).

🤖 Generated with Claude Code

Some logs carry a serialized payload as a string (typically the raw body
of a POST request). Until now such a value was an opaque leaf: secrets
nested in the JSON leaked through.

Declare those keys with MaskerBuilder::withJsonKeys(); the engine decodes
the value, masks it recursively (same key + value rules), then re-encodes
it. The feature is opt-in — without JSON keys nothing changes. A decodable
JSON value takes precedence over a sensitive-key match; a non-decodable
one falls back to the normal rules.

Held to the project gates: 100% coverage, MSI 100%, property tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rflavien
rflavien force-pushed the feat/json-key-masking branch from 6f966da to 99d0410 Compare June 10, 2026 15:23
@rflavien
rflavien requested a review from Copilot June 10, 2026 15:27
@rflavien
rflavien merged commit de01604 into main Jun 10, 2026
4 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an opt-in feature to mask secrets inside JSON payloads that are logged as string values (e.g. raw HTTP request bodies), by declaring “JSON keys” via MaskerBuilder::withJsonKeys([...]). This extends the existing recursive masking engine so it can decode, mask, and re-encode JSON stored in strings while preserving the current default behavior when the feature is not enabled.

Changes:

  • Add MaskerBuilder::withJsonKeys() and wire a JSON-key matcher through to Masker.
  • Extend Masker to decode/mask/re-encode JSON for configured keys.
  • Add unit + property-based tests and README documentation for the new behavior.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Masker/Masker.php Adds JSON-string decoding/masking/re-encoding path gated by an optional JSON key matcher.
src/MaskerBuilder.php Adds withJsonKeys() builder API and passes a KeyListMatcher for JSON keys into Masker.
tests/Masker/MaskerTest.php Adds unit tests covering JSON-in-string masking behavior, precedence, maxDepth behavior, and idempotence.
tests/MaskerBuilderTest.php Adds builder-level tests ensuring withJsonKeys() works, accumulates, and remains opt-in.
tests/Property/JsonMaskerPropertyTest.php Adds property-based tests for JSON re-decodability, idempotence, and non-mutation.
README.md Documents the new withJsonKeys() feature and its behavior/constraints.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Masker/Masker.php
Comment on lines 76 to +85
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 thread README.md
Comment on lines +122 to +123
- Re-encoding normalises formatting (whitespace, escaping; an empty `{}` comes
back as `[]`) — the masked payload is semantically equal, not byte-identical.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants