diff --git a/README.md b/README.md index bdddd19..9af874a 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,8 @@ PhoneRedaction::from(fields: ['phone', 'mobile', 'whatsapp'], visibleSuffixLengt #### Password redaction -Masks the entire value. No characters are preserved. +Masks the entire value with a fixed-length mask (default: 8 characters). The original value's length is never revealed +in the output, preventing information leakage about password size. ```php use TinyBlocks\Logger\StructuredLogger; @@ -184,15 +185,21 @@ $logger = StructuredLogger::create() ->build(); $logger->info(message: 'login.attempt', context: ['password' => 's3cr3t!']); -# password → "*******" +# password → "********" + +$logger->info(message: 'login.attempt', context: ['password' => '123']); +# password → "********" (same mask regardless of length) ``` -With custom fields: +With custom fields and fixed mask length: ```php use TinyBlocks\Logger\Redactions\PasswordRedaction; -PasswordRedaction::from(fields: ['password', 'secret', 'token']); +PasswordRedaction::from(fields: ['password', 'secret', 'token'], fixedMaskLength: 12); +# "s3cr3t!" → "************" +# "ab" → "************" +# "long_password" → "************" ``` #### Name redaction diff --git a/src/Redactions/EmailRedaction.php b/src/Redactions/EmailRedaction.php index a333f23..87cbdfe 100644 --- a/src/Redactions/EmailRedaction.php +++ b/src/Redactions/EmailRedaction.php @@ -24,10 +24,10 @@ private function __construct(array $fields, int $visiblePrefixLength) return str_repeat('*', strlen($value)); } - $localPart = substr($value, 0, $atPosition); $domain = substr($value, $atPosition); - $visiblePrefix = substr($localPart, 0, $visiblePrefixLength); + $localPart = substr($value, 0, $atPosition); $maskedSuffix = str_repeat('*', max(0, strlen($localPart) - $visiblePrefixLength)); + $visiblePrefix = substr($localPart, 0, $visiblePrefixLength); return sprintf('%s%s%s', $visiblePrefix, $maskedSuffix, $domain); } diff --git a/src/Redactions/PasswordRedaction.php b/src/Redactions/PasswordRedaction.php index df17672..3ec2ba1 100644 --- a/src/Redactions/PasswordRedaction.php +++ b/src/Redactions/PasswordRedaction.php @@ -9,21 +9,23 @@ final readonly class PasswordRedaction implements Redaction { + private const int DEFAULT_FIXED_MASK_LENGTH = 8; + private Redactor $redactor; - private function __construct(array $fields) + private function __construct(array $fields, int $fixedMaskLength) { $this->redactor = new Redactor( fields: $fields, - maskingFunction: static function (string $value): string { - return str_repeat('*', strlen($value)); - } + maskingFunction: static fn(): string => str_repeat('*', $fixedMaskLength) ); } - public static function from(array $fields): PasswordRedaction - { - return new PasswordRedaction(fields: $fields); + public static function from( + array $fields, + int $fixedMaskLength = self::DEFAULT_FIXED_MASK_LENGTH + ): PasswordRedaction { + return new PasswordRedaction(fields: $fields, fixedMaskLength: $fixedMaskLength); } public static function default(): PasswordRedaction diff --git a/tests/StructuredLoggerTest.php b/tests/StructuredLoggerTest.php index 5f66be3..48a95b6 100644 --- a/tests/StructuredLoggerTest.php +++ b/tests/StructuredLoggerTest.php @@ -528,7 +528,7 @@ public function testLogWithPasswordRedactionOnShortValue(): void /** @Then the password should still be fully masked */ $output = $this->streamContents(); - self::assertStringContainsString('**', $output); + self::assertStringContainsString('********', $output); self::assertStringNotContainsString('"password":"ab"', $output); } @@ -556,6 +556,46 @@ public function testLogWithPasswordRedactionOnNestedField(): void self::assertStringContainsString('"username":"admin"', $output); } + + public function testLogWithPasswordRedactionDoesNotRevealValueLength(): void + { + /** @Given a structured logger with password redaction */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'auth-service') + ->withRedactions(PasswordRedaction::default()) + ->build(); + + /** @When logging passwords of different lengths */ + $logger->info(message: 'login.short', context: ['password' => '123']); + $logger->info(message: 'login.long', context: ['password' => 'mySuperLongP@ssw0rd!123']); + + /** @Then both should produce the same fixed-length mask */ + $lines = array_filter(explode("\n", $this->streamContents())); + + self::assertStringContainsString('"password":"********"', $lines[0]); + self::assertStringContainsString('"password":"********"', $lines[1]); + } + + public function testLogWithPasswordRedactionWithCustomFixedMaskLength(): void + { + /** @Given a structured logger with password redaction configured with a custom fixed mask length */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'auth-service') + ->withRedactions(PasswordRedaction::from(fields: ['password'], fixedMaskLength: 12)) + ->build(); + + /** @When logging with a password field */ + $logger->info(message: 'login.attempt', context: ['password' => 'abc']); + + /** @Then the mask should have exactly 12 asterisks */ + $output = $this->streamContents(); + + self::assertStringContainsString('"password":"************"', $output); + self::assertStringNotContainsString('abc', $output); + } + public function testLogWithNameRedaction(): void { /** @Given a structured logger with name redaction */