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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/Redactions/EmailRedaction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
16 changes: 9 additions & 7 deletions src/Redactions/PasswordRedaction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 41 additions & 1 deletion tests/StructuredLoggerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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 */
Expand Down