diff --git a/composer.json b/composer.json index e52f252..59104f7 100644 --- a/composer.json +++ b/composer.json @@ -30,10 +30,10 @@ "tiny-blocks/collection": "^2.6" }, "require-dev": { - "ergebnis/composer-normalize": "^2.52", - "infection/infection": "^0.34", - "phpstan/phpstan": "^2.2", - "phpunit/phpunit": "^13.2", + "ergebnis/composer-normalize": "^2.54", + "infection/infection": "^0.35", + "phpstan/phpstan": "^2.3", + "phpunit/phpunit": "^13.4", "slevomat/coding-standard": "^8.31", "squizlabs/php_codesniffer": "^4.0" }, @@ -74,7 +74,7 @@ "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", "tests": [ "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", - "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" + "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html" ] } } diff --git a/src/Internal/Redactor/VisibleShape.php b/src/Internal/Redactor/VisibleShape.php index fb3bf0f..98156e6 100644 --- a/src/Internal/Redactor/VisibleShape.php +++ b/src/Internal/Redactor/VisibleShape.php @@ -12,30 +12,6 @@ enum VisibleShape: string case WORDS = 'WORDS'; case LOCAL_PART = 'LOCAL_PART'; - public function applyTo(Mask $mask, string $value, int $prefixLength, int $suffixLength): string - { - return match ($this) { - VisibleShape::EDGES => VisibleShape::edges( - mask: $mask, - value: $value, - prefixLength: $prefixLength, - suffixLength: $suffixLength - ), - VisibleShape::WORDS => VisibleShape::words( - mask: $mask, - value: $value, - prefixLength: $prefixLength, - suffixLength: $suffixLength - ), - VisibleShape::LOCAL_PART => VisibleShape::localPart( - mask: $mask, - value: $value, - prefixLength: $prefixLength, - suffixLength: $suffixLength - ) - }; - } - private static function edges(Mask $mask, string $value, int $prefixLength, int $suffixLength): string { $totalLength = mb_strlen($value, 'UTF-8'); @@ -89,4 +65,28 @@ private static function localPart(Mask $mask, string $value, int $prefixLength, mb_substr($value, $atPosition, null, 'UTF-8') ); } + + public function applyTo(Mask $mask, string $value, int $prefixLength, int $suffixLength): string + { + return match ($this) { + VisibleShape::EDGES => VisibleShape::edges( + mask: $mask, + value: $value, + prefixLength: $prefixLength, + suffixLength: $suffixLength + ), + VisibleShape::WORDS => VisibleShape::words( + mask: $mask, + value: $value, + prefixLength: $prefixLength, + suffixLength: $suffixLength + ), + VisibleShape::LOCAL_PART => VisibleShape::localPart( + mask: $mask, + value: $value, + prefixLength: $prefixLength, + suffixLength: $suffixLength + ) + }; + } } diff --git a/src/Metrics/Metric.php b/src/Metrics/Metric.php index 056d0d9..8ccbe35 100644 --- a/src/Metrics/Metric.php +++ b/src/Metrics/Metric.php @@ -59,27 +59,6 @@ public static function of(string $name, string $namespace): Metric ); } - /** - * Returns a copy of the metric carrying no field at all. - * - *
What survives a redaction is what the record should carry, so a caller that filters the - * fields rebuilds them from an empty one instead of writing over the originals: a field the - * filter dropped has no value to write, and would otherwise stay.
- * - * @return Metric A copy with every field removed. - */ - public function withoutFields(): Metric - { - return new Metric( - name: $this->name, - unit: $this->unit, - value: $this->value, - fields: [], - namespace: $this->namespace, - dimensions: $this->dimensions - ); - } - /** * Returns a copy of the metric expressed in another unit. * @@ -162,4 +141,25 @@ public function withDimension(string $name, string $value): Metric dimensions: [...$this->dimensions, $name => MetricDimension::of(name: $name, value: $value)] ); } + + /** + * Returns a copy of the metric carrying no field at all. + * + *What survives a redaction is what the record should carry, so a caller that filters the + * fields rebuilds them from an empty one instead of writing over the originals: a field the + * filter dropped has no value to write, and would otherwise stay.
+ * + * @return Metric A copy with every field removed. + */ + public function withoutFields(): Metric + { + return new Metric( + name: $this->name, + unit: $this->unit, + value: $this->value, + fields: [], + namespace: $this->namespace, + dimensions: $this->dimensions + ); + } } diff --git a/src/Redactions/BirthDateRedaction.php b/src/Redactions/BirthDateRedaction.php index 4f9a7e4..c40eb14 100644 --- a/src/Redactions/BirthDateRedaction.php +++ b/src/Redactions/BirthDateRedaction.php @@ -20,10 +20,10 @@ */ final readonly class BirthDateRedaction implements Redaction { - private const int DEFAULT_VISIBLE_PREFIX_LENGTH = 4; - private const array DEFAULT_FIELDS = ['birth*date', 'date_of_birth']; + private const int DEFAULT_VISIBLE_PREFIX_LENGTH = 4; + private Redaction $redactor; private function __construct(array $fields, int $visiblePrefixLength) diff --git a/src/Redactions/PostalCodeRedaction.php b/src/Redactions/PostalCodeRedaction.php index 3072795..24d697c 100644 --- a/src/Redactions/PostalCodeRedaction.php +++ b/src/Redactions/PostalCodeRedaction.php @@ -19,10 +19,10 @@ */ final readonly class PostalCodeRedaction implements Redaction { - private const int DEFAULT_VISIBLE_PREFIX_LENGTH = 3; - private const array DEFAULT_FIELDS = ['post*code', 'zip*code']; + private const int DEFAULT_VISIBLE_PREFIX_LENGTH = 3; + private Redaction $redactor; private function __construct(array $fields, int $visiblePrefixLength) diff --git a/src/Redactions/QueryParametersRedaction.php b/src/Redactions/QueryParametersRedaction.php index 640301f..625e8a0 100644 --- a/src/Redactions/QueryParametersRedaction.php +++ b/src/Redactions/QueryParametersRedaction.php @@ -30,24 +30,24 @@ private function __construct(array $fields) } /** - * Creates a QueryParametersRedaction keeping only the named parameters readable. + * Builds a QueryParametersRedaction masking every query parameter. * - * @param string[] $fields The parameter names left untouched, wildcards accepted. * @return QueryParametersRedaction The created instance. */ - public static function keeping(array $fields): QueryParametersRedaction + public static function default(): QueryParametersRedaction { - return new QueryParametersRedaction(fields: $fields); + return QueryParametersRedaction::keeping(fields: []); } /** - * Builds a QueryParametersRedaction masking every query parameter. + * Creates a QueryParametersRedaction keeping only the named parameters readable. * + * @param string[] $fields The parameter names left untouched, wildcards accepted. * @return QueryParametersRedaction The created instance. */ - public static function default(): QueryParametersRedaction + public static function keeping(array $fields): QueryParametersRedaction { - return QueryParametersRedaction::keeping(fields: []); + return new QueryParametersRedaction(fields: $fields); } public function redact(array $payload): array diff --git a/src/Redactions/SecretRedaction.php b/src/Redactions/SecretRedaction.php index 333084e..e823e5b 100644 --- a/src/Redactions/SecretRedaction.php +++ b/src/Redactions/SecretRedaction.php @@ -13,8 +13,6 @@ */ final readonly class SecretRedaction implements Redaction { - private const int DEFAULT_FIXED_MASK_LENGTH = 8; - private const array DEFAULT_FIELDS = [ '*token*', '*secret*', @@ -25,6 +23,8 @@ '*private_key*' ]; + private const int DEFAULT_FIXED_MASK_LENGTH = 8; + private Redaction $redactor; private function __construct(array $fields, int $fixedMaskLength) diff --git a/tests/Models/EmbeddedMetricPayload.php b/tests/Models/EmbeddedMetricPayload.php index 6e20919..d3ff28c 100644 --- a/tests/Models/EmbeddedMetricPayload.php +++ b/tests/Models/EmbeddedMetricPayload.php @@ -25,14 +25,14 @@ public function metrics(): mixed return $this->declaration()['Metrics']; } - public function timestamp(): mixed + private function envelope(): array { - return $this->envelope()['Timestamp']; + return (array)$this->payload['_aws']; } - public function namespaced(): mixed + public function timestamp(): mixed { - return $this->declaration()['Namespace']; + return $this->envelope()['Timestamp']; } public function dimensions(): mixed @@ -40,9 +40,9 @@ public function dimensions(): mixed return $this->declaration()['Dimensions']; } - private function envelope(): array + public function namespaced(): mixed { - return (array)$this->payload['_aws']; + return $this->declaration()['Namespace']; } private function declaration(): array diff --git a/tests/Unit/RedactionsTest.php b/tests/Unit/RedactionsTest.php index e586829..f651f8a 100644 --- a/tests/Unit/RedactionsTest.php +++ b/tests/Unit/RedactionsTest.php @@ -58,28 +58,6 @@ public function testRedactWhenVisibleEdgesThenKeepsBothEnds(): void self::assertStringContainsString('data={"phone":"+5511*****7766"}', $this->logStream->contents()); } - public function testRedactWhenVisibleWordsThenMasksEachWordApart(): void - { - /** @Given a structured logger masking each word of a name with a fixed-width mask */ - $logger = StreamLogger::builder() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'user-service') - ->withRedactions( - GenericRedaction::masking( - mask: Mask::fixed(length: 3), - fields: ['name'], - visibility: Visibility::words(prefixLength: 2) - ) - ) - ->build(); - - /** @When logging with a name made of several words */ - $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']); - - /** @Then every word keeps its own visible prefix */ - self::assertStringContainsString('data={"name":"Gu*** Fr***"}', $this->logStream->contents()); - } - public function testRedactWhenFullMaskThenHidesTheValueLength(): void { /** @Given a structured logger masking two fields of different lengths with a fixed mask */ @@ -216,6 +194,28 @@ public function testRedactWhenScopedThenMasksOnlyInsideTheScope(): void self::assertStringContainsString('"charge":{"document":{"value":"*********00"}}', $output); } + public function testRedactWhenVisibleWordsThenMasksEachWordApart(): void + { + /** @Given a structured logger masking each word of a name with a fixed-width mask */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions( + GenericRedaction::masking( + mask: Mask::fixed(length: 3), + fields: ['name'], + visibility: Visibility::words(prefixLength: 2) + ) + ) + ->build(); + + /** @When logging with a name made of several words */ + $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']); + + /** @Then every word keeps its own visible prefix */ + self::assertStringContainsString('data={"name":"Gu*** Fr***"}', $this->logStream->contents()); + } + public function testRedactWhenPatternMatchesTextThenMasksTheMatch(): void { /** @Given a structured logger masking eleven-digit runs found in any text */ @@ -449,26 +449,27 @@ public function testRedactWhenSuffixIsNegativeThenThrowsNegativeLength(): void ); } - public function testRedactWhenVisibleWordsHaveNoPrefixThenMasksEveryWordHead(): void + public function testRedactWhenAllowListAndNullValueThenLeavesItUntouched(): void { - /** @Given a structured logger masking each word down to its last character */ + /** @Given a structured logger allowing only the city field */ $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'user-service') - ->withRedactions( - GenericRedaction::masking( - mask: Mask::proportional(), - fields: ['name'], - visibility: Visibility::words(suffixLength: 1) - ) - ) + ->withComponent(component: 'payment-service') + ->withRedactions(GenericRedaction::keeping(fields: ['city'], mask: Mask::fixed(length: 3))) ->build(); - /** @When logging with a name made of several words */ - $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']); + /** @When logging with an absent value and a nested allowed field */ + $logger->info(message: 'address.saved', context: [ + 'city' => 'São Paulo', + 'street' => null, + 'nested' => ['city' => 'Rio', 'label' => 'x'] + ]); - /** @Then every word keeps only its trailing character */ - self::assertStringContainsString('data={"name":"******o ****e"}', $this->logStream->contents()); + /** @Then the absent value is written as is and the nested allow list still applies */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"city":"São Paulo","street":null', $output); + self::assertStringContainsString('"nested":{"city":"Rio","label":"***"}', $output); } public function testRedactWhenUrlCarriesAQueryStringThenOnlyThePathSurvives(): void @@ -495,7 +496,7 @@ public function testRedactWhenUrlCarriesAQueryStringThenOnlyThePathSurvives(): v ); } - public function testRedactWhenFilterComparesThenOperandIsMaskedAndOperatorSurvives(): void + public function testRedactWhenValueCarriesNoComparisonThenLeavesItUntouched(): void { /** @Given a structured logger masking filter operands */ $logger = StreamLogger::builder() @@ -504,161 +505,131 @@ public function testRedactWhenFilterComparesThenOperandIsMaskedAndOperatorSurviv ->withRedactions(FilterExpressionRedaction::default()) ->build(); - /** @When logging filters that compare, negate, and range over values */ - $logger->info(message: 'query.received', context: [ - 'equality' => 'status==active;document==12345678900', - 'negation' => 'status!=canceled', - 'range' => 'created_at=ge=2026-01-01,created_at=le=2026-02-01' - ]); + /** @When logging a value that carries no comparison at all */ + $logger->info(message: 'query.received', context: ['sort' => 'created_at desc']); - /** @Then every operand is masked and every field and operator stays readable */ - self::assertStringContainsString( - 'data={"equality":"status==********;document==********","negation":"status!=********",' - . '"range":"created_at=ge=********,created_at=le=********"}', - $this->logStream->contents() - ); + /** @Then it travels unchanged, because there is no operand to mask */ + self::assertStringContainsString('data={"sort":"created_at desc"}', $this->logStream->contents()); } - public function testRedactWhenFilterComparesAgainstAListThenTheWholeListIsMasked(): void + public function testRedactWhenVisibleWordsHaveNoPrefixThenMasksEveryWordHead(): void { - /** @Given a structured logger masking filter operands */ + /** @Given a structured logger masking each word down to its last character */ $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'ledger') - ->withRedactions(FilterExpressionRedaction::default()) + ->withComponent(component: 'user-service') + ->withRedactions( + GenericRedaction::masking( + mask: Mask::proportional(), + fields: ['name'], + visibility: Visibility::words(suffixLength: 1) + ) + ) ->build(); - /** @When logging a filter whose operand is a parenthesized list */ - $logger->info(message: 'query.received', context: [ - 'filter' => 'document=in=(12345678900,98765432100);status=out=(canceled,failed)' - ]); + /** @When logging with a name made of several words */ + $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']); - /** @Then no value inside the parentheses survives, which is where a list leaks */ - self::assertStringContainsString( - 'data={"filter":"document=in=********;status=out=********"}', - $this->logStream->contents() - ); + /** @Then every word keeps only its trailing character */ + self::assertStringContainsString('data={"name":"******o ****e"}', $this->logStream->contents()); } - public function testRedactWhenValueCarriesNoComparisonThenLeavesItUntouched(): void + public function testRedactWhenBirthDateWindowIsGivenThenItOverridesTheDefault(): void { - /** @Given a structured logger masking filter operands */ + /** @Given a structured logger hiding the year as well */ $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'ledger') - ->withRedactions(FilterExpressionRedaction::default()) + ->withComponent(component: 'payment-service') + ->withRedactions(BirthDateRedaction::from(fields: ['birth_date'], visiblePrefixLength: 0)) ->build(); - /** @When logging a value that carries no comparison at all */ - $logger->info(message: 'query.received', context: ['sort' => 'created_at desc']); + /** @When logging a payer carrying a date of birth */ + $logger->info(message: 'payer.registered', context: ['birth_date' => '1990-07-21']); - /** @Then it travels unchanged, because there is no operand to mask */ - self::assertStringContainsString('data={"sort":"created_at desc"}', $this->logStream->contents()); + /** @Then nothing of the date survives but its shape, and not the year the default would keep */ + self::assertStringContainsString('data={"birth_date":"****-**-**"}', $this->logStream->contents()); } - public function testRedactWhenEmailPrefixIsGivenThenItOverridesTheDefaultWindow(): void + public function testRedactWhenPostalCodeWindowIsGivenThenItOverridesTheDefault(): void { - /** @Given a structured logger showing more of the local part than the default two characters */ + /** @Given a structured logger keeping more of the code than the default three characters */ $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'user-service') - ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 4)) + ->withComponent(component: 'payment-service') + ->withRedactions(PostalCodeRedaction::from(fields: ['postal_code'], visiblePrefixLength: 5)) ->build(); /** @When logging an address */ - $logger->info(message: 'user.created', context: ['email' => 'maria.silva@example.com']); + $logger->info(message: 'address.given', context: ['postal_code' => '01310-100']); - /** @Then four characters of the local part survive, and not the two the default would keep */ - self::assertStringContainsString('data={"email":"mari*******@example.com"}', $this->logStream->contents()); + /** @Then five characters survive, and not the three the default would keep */ + self::assertStringContainsString('data={"postal_code":"01310-***"}', $this->logStream->contents()); } - public function testRedactWhenQueryParametersAreAllowedThenOnlyTheOthersAreMasked(): void + public function testRedactWhenEmailPrefixIsGivenThenItOverridesTheDefaultWindow(): void { - /** @Given a structured logger keeping the query parameters this API answers by */ + /** @Given a structured logger showing more of the local part than the default two characters */ $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'ledger') - ->withRedactions(QueryParametersRedaction::keeping(fields: ['sort', 'page*'])) + ->withComponent(component: 'user-service') + ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 4)) ->build(); - /** @When logging a request carrying an allowed parameter, a wildcard match, and one nobody declared */ - $logger->info(message: 'request.received', context: [ - 'uri' => '/v1/clients', - 'query_parameters' => ['sort' => 'created_at', 'page_size' => '20', 'document' => '12345678900'], - 'body' => ['document' => '12345678900'] - ]); + /** @When logging an address */ + $logger->info(message: 'user.created', context: ['email' => 'maria.silva@example.com']); - /** @Then the undeclared parameter is masked, the allowed ones survive, and nothing outside the branch moves */ - self::assertStringContainsString( - 'data={"uri":"/v1/clients","query_parameters":{"sort":"created_at","page_size":"20",' - . '"document":"********"},"body":{"document":"12345678900"}}', - $this->logStream->contents() - ); + /** @Then four characters of the local part survive, and not the two the default would keep */ + self::assertStringContainsString('data={"email":"mari*******@example.com"}', $this->logStream->contents()); } - public function testRedactWhenNoQueryParameterIsAllowedThenEveryOneOfThemIsMasked(): void + public function testRedactWhenFieldIsSpelledInAnotherCaseThenTheSameRuleCoversIt(): void { - /** @Given a structured logger allowing no query parameter at all */ + /** @Given a structured logger with the strategies that name their fields in snake case */ $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'identity') - ->withRedactions(QueryParametersRedaction::default()) + ->withComponent(component: 'payment-service') + ->withRedactions(SecretRedaction::default(), PostalCodeRedaction::default(), BirthDateRedaction::default()) ->build(); - /** @When logging a request carrying two of them */ - $logger->info(message: 'request.received', context: [ - 'query_parameters' => ['sort' => 'created_at', 'document' => '12345678900'] + /** @When logging a payload that spells the same names in camel case and with an acronym */ + $logger->info(message: 'payer.registered', context: [ + 'postalCode' => '01310-100', + 'birthDate' => '1990-07-21', + 'accessToken' => 'at-1', + 'debitCardToken' => 'tok-1', + 'APIKey' => 'ak-1', + 'plan' => 'saas' ]); - /** @Then both are masked, because a parameter is readable only when it is named */ + /** @Then every spelling of a covered name is masked, and the field nobody named survives */ self::assertStringContainsString( - 'data={"query_parameters":{"sort":"********","document":"********"}}', + 'data={"postalCode":"013**-***","birthDate":"1990-**-**","accessToken":"********",' + . '"debitCardToken":"********","APIKey":"********","plan":"saas"}', $this->logStream->contents() ); } - public function testRedactWhenBirthDateTravelsUnderAnotherNameThenTheDefaultCoversIt(): void + public function testRedactWhenFilterComparesAgainstAListThenTheWholeListIsMasked(): void { - /** @Given a structured logger with the default birth date redaction */ + /** @Given a structured logger masking filter operands */ $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'payment-service') - ->withRedactions(BirthDateRedaction::default()) + ->withComponent(component: 'ledger') + ->withRedactions(FilterExpressionRedaction::default()) ->build(); - /** @When logging the same date under each name it travels by, next to a field that only looks alike */ - $logger->info(message: 'payer.registered', context: [ - 'birth_date' => '1990-07-21', - 'birthdate' => '1990-07-21', - 'date_of_birth' => '1990-07-21', - 'birthplace' => 'São Paulo', - 'plan' => 'saas' + /** @When logging a filter whose operand is a parenthesized list */ + $logger->info(message: 'query.received', context: [ + 'filter' => 'document=in=(12345678900,98765432100);status=out=(canceled,failed)' ]); - /** @Then the year and the separators survive in each of them, and the lookalike is untouched */ + /** @Then no value inside the parentheses survives, which is where a list leaks */ self::assertStringContainsString( - 'data={"birth_date":"1990-**-**","birthdate":"1990-**-**","date_of_birth":"1990-**-**",' - . '"birthplace":"São Paulo","plan":"saas"}', + 'data={"filter":"document=in=********;status=out=********"}', $this->logStream->contents() ); } - public function testRedactWhenBirthDateWindowIsGivenThenItOverridesTheDefault(): void - { - /** @Given a structured logger hiding the year as well */ - $logger = StreamLogger::builder() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'payment-service') - ->withRedactions(BirthDateRedaction::from(fields: ['birth_date'], visiblePrefixLength: 0)) - ->build(); - - /** @When logging a payer carrying a date of birth */ - $logger->info(message: 'payer.registered', context: ['birth_date' => '1990-07-21']); - - /** @Then nothing of the date survives but its shape, and not the year the default would keep */ - self::assertStringContainsString('data={"birth_date":"****-**-**"}', $this->logStream->contents()); - } - public function testRedactWhenPostalCodeThenKeepsTheLeadingCharactersOfTheRegion(): void { /** @Given a structured logger with postal code redaction over three shapes of code */ @@ -682,72 +653,71 @@ public function testRedactWhenPostalCodeThenKeepsTheLeadingCharactersOfTheRegion ); } - public function testRedactWhenPostalCodeTravelsUnderAnotherNameThenTheDefaultStillCoversIt(): void + public function testRedactWhenNoQueryParameterIsAllowedThenEveryOneOfThemIsMasked(): void { - /** @Given a structured logger with the default postal code redaction */ + /** @Given a structured logger allowing no query parameter at all */ $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'payment-service') - ->withRedactions(PostalCodeRedaction::default()) + ->withComponent(component: 'identity') + ->withRedactions(QueryParametersRedaction::default()) ->build(); - /** @When logging the same value under each name it travels by, next to fields that only look alike */ - $logger->info(message: 'address.given', context: [ - 'postal_code' => '01310-100', - 'postcode' => '01310-100', - 'zip_code' => '01310-100', - 'zip' => 'invoices.zip', - 'area_code' => '11', - 'city' => 'São Paulo' + /** @When logging a request carrying two of them */ + $logger->info(message: 'request.received', context: [ + 'query_parameters' => ['sort' => 'created_at', 'document' => '12345678900'] ]); - /** @Then every name of the postal code is masked past the region, and the lookalikes are not */ + /** @Then both are masked, because a parameter is readable only when it is named */ self::assertStringContainsString( - 'data={"postal_code":"013**-***","postcode":"013**-***","zip_code":"013**-***",' - . '"zip":"invoices.zip","area_code":"11","city":"São Paulo"}', + 'data={"query_parameters":{"sort":"********","document":"********"}}', $this->logStream->contents() ); } - public function testRedactWhenPostalCodeWindowIsGivenThenItOverridesTheDefault(): void + public function testRedactWhenQueryParametersAreAllowedThenOnlyTheOthersAreMasked(): void { - /** @Given a structured logger keeping more of the code than the default three characters */ + /** @Given a structured logger keeping the query parameters this API answers by */ $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'payment-service') - ->withRedactions(PostalCodeRedaction::from(fields: ['postal_code'], visiblePrefixLength: 5)) + ->withComponent(component: 'ledger') + ->withRedactions(QueryParametersRedaction::keeping(fields: ['sort', 'page*'])) ->build(); - /** @When logging an address */ - $logger->info(message: 'address.given', context: ['postal_code' => '01310-100']); + /** @When logging a request carrying an allowed parameter, a wildcard match, and one nobody declared */ + $logger->info(message: 'request.received', context: [ + 'uri' => '/v1/clients', + 'query_parameters' => ['sort' => 'created_at', 'page_size' => '20', 'document' => '12345678900'], + 'body' => ['document' => '12345678900'] + ]); - /** @Then five characters survive, and not the three the default would keep */ - self::assertStringContainsString('data={"postal_code":"01310-***"}', $this->logStream->contents()); + /** @Then the undeclared parameter is masked, the allowed ones survive, and nothing outside the branch moves */ + self::assertStringContainsString( + 'data={"uri":"/v1/clients","query_parameters":{"sort":"created_at","page_size":"20",' + . '"document":"********"},"body":{"document":"12345678900"}}', + $this->logStream->contents() + ); } - public function testRedactWhenFieldIsSpelledInAnotherCaseThenTheSameRuleCoversIt(): void + public function testRedactWhenFilterComparesThenOperandIsMaskedAndOperatorSurvives(): void { - /** @Given a structured logger with the strategies that name their fields in snake case */ + /** @Given a structured logger masking filter operands */ $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'payment-service') - ->withRedactions(SecretRedaction::default(), PostalCodeRedaction::default(), BirthDateRedaction::default()) + ->withComponent(component: 'ledger') + ->withRedactions(FilterExpressionRedaction::default()) ->build(); - /** @When logging a payload that spells the same names in camel case and with an acronym */ - $logger->info(message: 'payer.registered', context: [ - 'postalCode' => '01310-100', - 'birthDate' => '1990-07-21', - 'accessToken' => 'at-1', - 'debitCardToken' => 'tok-1', - 'APIKey' => 'ak-1', - 'plan' => 'saas' + /** @When logging filters that compare, negate, and range over values */ + $logger->info(message: 'query.received', context: [ + 'equality' => 'status==active;document==12345678900', + 'negation' => 'status!=canceled', + 'range' => 'created_at=ge=2026-01-01,created_at=le=2026-02-01' ]); - /** @Then every spelling of a covered name is masked, and the field nobody named survives */ + /** @Then every operand is masked and every field and operator stays readable */ self::assertStringContainsString( - 'data={"postalCode":"013**-***","birthDate":"1990-**-**","accessToken":"********",' - . '"debitCardToken":"********","APIKey":"********","plan":"saas"}', + 'data={"equality":"status==********;document==********","negation":"status!=********",' + . '"range":"created_at=ge=********,created_at=le=********"}', $this->logStream->contents() ); } @@ -803,26 +773,56 @@ public function testRedactWhenSecretFieldsFollowTheNamingConventionThenEachIsMas ); } - public function testRedactWhenAllowListAndNullValueThenLeavesItUntouched(): void + public function testRedactWhenBirthDateTravelsUnderAnotherNameThenTheDefaultCoversIt(): void { - /** @Given a structured logger allowing only the city field */ + /** @Given a structured logger with the default birth date redaction */ $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'payment-service') - ->withRedactions(GenericRedaction::keeping(fields: ['city'], mask: Mask::fixed(length: 3))) + ->withRedactions(BirthDateRedaction::default()) ->build(); - /** @When logging with an absent value and a nested allowed field */ - $logger->info(message: 'address.saved', context: [ - 'city' => 'São Paulo', - 'street' => null, - 'nested' => ['city' => 'Rio', 'label' => 'x'] + /** @When logging the same date under each name it travels by, next to a field that only looks alike */ + $logger->info(message: 'payer.registered', context: [ + 'birth_date' => '1990-07-21', + 'birthdate' => '1990-07-21', + 'date_of_birth' => '1990-07-21', + 'birthplace' => 'São Paulo', + 'plan' => 'saas' ]); - /** @Then the absent value is written as is and the nested allow list still applies */ - $output = $this->logStream->contents(); + /** @Then the year and the separators survive in each of them, and the lookalike is untouched */ + self::assertStringContainsString( + 'data={"birth_date":"1990-**-**","birthdate":"1990-**-**","date_of_birth":"1990-**-**",' + . '"birthplace":"São Paulo","plan":"saas"}', + $this->logStream->contents() + ); + } - self::assertStringContainsString('"city":"São Paulo","street":null', $output); - self::assertStringContainsString('"nested":{"city":"Rio","label":"***"}', $output); + public function testRedactWhenPostalCodeTravelsUnderAnotherNameThenTheDefaultStillCoversIt(): void + { + /** @Given a structured logger with the default postal code redaction */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions(PostalCodeRedaction::default()) + ->build(); + + /** @When logging the same value under each name it travels by, next to fields that only look alike */ + $logger->info(message: 'address.given', context: [ + 'postal_code' => '01310-100', + 'postcode' => '01310-100', + 'zip_code' => '01310-100', + 'zip' => 'invoices.zip', + 'area_code' => '11', + 'city' => 'São Paulo' + ]); + + /** @Then every name of the postal code is masked past the region, and the lookalikes are not */ + self::assertStringContainsString( + 'data={"postal_code":"013**-***","postcode":"013**-***","zip_code":"013**-***",' + . '"zip":"invoices.zip","area_code":"11","city":"São Paulo"}', + $this->logStream->contents() + ); } }