TL;DR
-
cleaniquecoders/pii-protectionnow shipsSecretScrubber(pattern-based),LiteralScrubber(exact-value), andRedactedStrategy(fixed placeholder). - Secrets and PII are different problems. Running the PII scrubber over an infrastructure log deletes exactly the detail you need to debug it.
- Masking a secret should not preserve its length — length is itself a disclosure.
- Exact-value masking has three non-obvious safety rules: longest-first, minimum length, and skip pure digits.
I shipped a log viewer for a deployment platform recently. Great feature — until you realise that "show me the container logs" means "show me the connection string the container printed on boot."
So the day's work went into cleaniquecoders/pii-protection: three new pieces that turn "redact the logs" from a vague intention into something testable.
First: secrets are not PII
The package already had a PiiScrubber — it finds emails, phones, NRICs, IPs. The obvious move is to point it at your logs and call it done.
Don't. Think about what an infra log actually contains:
2026-08-07 09:14:02 connecting to postgres://app:s3cr3t@db:5432/app
2026-08-07 09:14:02 upstream 10.0.3.14 responded 502
The PII scrubber would happily blank out 10.0.3.14. Congratulations — you've removed the one thing that tells you which node is throwing 502s, and left the database password untouched.
That's why SecretScrubber is a sibling of PiiScrubber, not a mode of it. Personal data and machine credentials are different sets, and they belong in different passes so the caller chooses deliberately.
An analogy: PII redaction is closing the blinds. Secret redaction is locking the door. Doing one and calling it security is how people get robbed politely.
SecretScrubber — the patterns
Detection is a list of ordered regexes, keyed by type:
private const PATTERNS = [
// Whole PEM block including its armour. Must run first: it spans
// newlines and would otherwise be shredded by the narrower patterns.
'private_key' => '/-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----/s',
// scheme://user:password@host — keeps the scheme, user and host.
'url_credentials' => '#[a-z][a-z0-9+.\-]*://[^\s:@/]*:\K[^\s@/]+(?=@)#i',
'jwt' => '/\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b/',
'aws_access_key_id' => '/\b(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA|ANVA)[0-9A-Z]{16}\b/',
// Authorization headers — keeps the scheme word.
'authorization' => '/\b(?:bearer|basic)\s+\K[A-Za-z0-9._\-\/+=]{12,}/i',
];
Two details there are worth stealing even if you never use the package.
Order is most-structural first. The PEM pattern spans newlines. If a narrower pattern runs before it, it chews a chunk out of the middle of the key block and the PEM pattern no longer matches anything. Ordering isn't cosmetic here — it's correctness.
\K keeps the context. \K resets the reported match start, so the regex can require surrounding context without consuming it:
DB_PASSWORD=hunter2 → DB_PASSWORD=[redacted]
postgres://app:s3cr3t@db → postgres://app:[redacted]@db
Knowing which credential leaked is most of the value of the log line. A scrubber that turns the whole line into [redacted] is safe and useless.
There's also a detect() that reports {type, value, offset} without touching the text — which is what you want in a test, or in a CI check that fails a build when a fixture contains a live key.
RedactedStrategy — why not just use FullStrategy?
The package already had FullStrategy, which is length-preserving: hunter2 → *******. That's the right behaviour for PII, where a masked address should still read like an address.
For a credential it's a leak:
final class RedactedStrategy implements MaskStrategy
{
public function __construct(private string $placeholder = '[redacted]') {}
public function mask(string $value): string
{
return $this->placeholder;
}
}
A 16-star run and a 64-star run tell an attacker whether they're looking at an API key or a session token, and narrow an offline guess. Fixed placeholder, always.
This is the MaskStrategy contract earning its keep, by the way — the scrubbers don't know or care how masking happens, so swapping the strategy is a constructor argument, not a rewrite.
LiteralScrubber — because patterns are guesses
Here's the thing about pattern detection: it can only find secrets that look like secrets. A password that's just a word, a token your own system generated with no distinctive prefix — no regex is catching those.
But often the caller already holds the values. A deployment knows exactly which secrets it injected into that container. So don't guess:
$safe = (new LiteralScrubber)->scrub($logLine, [$token, $dbPassword]);
Certainty beats inference. Use both: patterns catch secrets the caller never issued, literals catch the ones with no shape.
The three rules that stop it doing more harm than good
None of these are obvious until the output is wrong.
1. Longest first. If one secret contains another, masking the short one first corrupts the long one and leaves part of it exposed:
it('masks the longest value first so nested values cannot corrupt it', function () {
// 'secret-value' contains 'secret'. Masking the short one first would
// leave '[redacted]-value' — the longer secret partially exposed.
expect((new LiteralScrubber)->scrub('the secret-value here', ['secret', 'secret-value']))
->toBe('the [redacted] here');
});
The sort happens in prepare(), and str_replace() walks the arrays in order — so the sort is the safety mechanism.
2. Minimum length. Blanking every occurrence of a 4-character value shreds unrelated text. Default is 6, configurable.
3. Skip pure digits. A port, a replica count, a timeout — none of those are secrets:
it('skips purely numeric values by default', function () {
expect((new LiteralScrubber)->scrub('listening on 5432 with 5432 backlog', ['5432']))
->toBe('listening on 5432 with 5432 backlog');
});
And prepare() is public on purpose. When someone passes a value and it doesn't get masked, they should be able to ask the object why, instead of concluding the scrubber is broken.
Testing this properly
Scrubbers are a joy to test because they're pure string in, string out — no framework, no container, no database:
it('scrubs the password out of a connection string but keeps the rest', function () {
expect((new SecretScrubber)->scrub('connecting to postgres://app:s3cr3t@db:5432/app'))
->toBe('connecting to postgres://app:[redacted]@db:5432/app');
});
it('scrubs any PEM private key type', function () {
foreach (['RSA ', 'EC ', 'OPENSSH ', ''] as $type) {
$text = "-----BEGIN {$type}PRIVATE KEY-----\nAAAA\n-----END {$type}PRIVATE KEY-----";
expect((new SecretScrubber)->scrub($text))->toBe('[redacted]');
}
});
Assert on the whole output, not just ->not->toContain($secret). A scrubber that returns an empty string passes the negative assertion perfectly.
The payoff: delete your bespoke redactor
The same day, on a separate (private) project, I ripped out a hand-rolled LogRedactor and had it delegate to this package instead. That's the actual argument for extracting things into packages: the bespoke version had three patterns and no tests, because nobody budgets a week for a redactor inside a feature ticket.
What I'd watch out for
-
Detection is best-effort.
SecretScrubbersays so in its own docblock. Treat it as defence in depth, not as permission to log credentials. - Regex over big log payloads costs something. Scrub at the boundary — when rendering a log view, capturing an exception, serialising a job — not in a hot loop.
-
The
assignmentpattern is broad by design. It matches anything whose key containspassword|secret|token|auth|_key. That will occasionally eat a field you wanted. That's the correct direction to be wrong in.
Next up: wiring the scrubbers into a queue-job payload serialiser, so failed jobs stop parking credentials in failed_jobs for six months.
Package: cleaniquecoders/pii-protection
Top comments (0)