A lightweight, zero-dependency Monolog processor to keep sensitive data and secrets out of your logs.
When logging requests, exceptions, or context arrays, it's easy to accidentally leak sensitive information like passwords, API keys, or Personally Identifiable Information (PII) into your log files or monitoring systems (Datadog, Sentry, ELK). This can lead to security breaches and GDPR compliance issues.
monolog-masker provides a simple Processor for Monolog that recursively intercepts and masks sensitive keys before they are ever written to your logs.
You can install the package via composer:
composer require tiime/monolog-maskerPush the processor onto your logger. With the defaults you are protected in one line:
use Monolog\Logger;
use Tiime\MonologMasker\MaskerBuilder;
$logger = new Logger('app');
// Push FIRST so Monolog runs it LAST β see "Processor ordering" below.
$logger->pushProcessor(MaskerBuilder::create()->buildProcessor());
$logger->info('payment', [
'db_password' => 'super-secret',
'card_number' => '4242 4242 4242 4242',
'amount' => 1000,
]);
// context becomes:
// ['db_password' => 'ββββββββ', 'card_number' => 'ββββββββ', 'amount' => 1000]The processor walks message, context and extra and masks, secure by default:
- Sensitive keys β values whose key contains a known sensitive segment
(
password,token,api_key,authorization, β¦). Matching is segment-aware, so compound names likedb_password,userTokenorx-api-keyare caught too (but nottokenizer). The whole sub-tree under a sensitive key is collapsed. - Sensitive values β tokens that look like a secret or PII (email, IBAN, JWT,
Bearer β¦, AWS/Google keys, PEM blocks, and Luhn-validated card numbers), wherever they appear β including the log message. Only the matched sub-string is masked, so surrounding text is preserved. - Objects in the context are traversed too (
JsonSerializable,Stringable, or public properties), so secrets carried by DTOs don't slip through unmasked.
Everything is wired through the fluent, immutable MaskerBuilder:
use Tiime\MonologMasker\MaskerBuilder;
use Tiime\MonologMasker\Strategy\PartialMaskStrategy;
$processor = MaskerBuilder::create()
->withSensitiveKeys(['x-internal-token']) // add to the default key list
->withValuePatterns(['fr_phone' => '/\b0[1-9](?:\d{2}){4}\b/'])
->withStrategy(new PartialMaskStrategy(visible: 4)) // keep last 4 chars: "βββ1234"
->maxDepth(20)
->buildProcessor();
$logger->pushProcessor($processor);Other knobs:
withKeyMatcher()/withValueMatcher()β replace detection entirely with your ownKeyMatcherInterface/ValueMatcherInterface.withoutValueMatching()β key-based masking only (skip value detection).matchKeysExactly()β whole-string key matching instead of segment-aware (no compound-key detection, fewer false positives).maskMessage(false)β stop masking the log message.traverseObjects(false)β leave objects untouched.
Monolog runs processors in reverse of their push order. To also mask the
extra data added by other processors (e.g. WebProcessor), push the masker
first so it runs last.
Recursion is bounded by maxDepth (default 16); anything deeper β and any object
cycle β is replaced with [TRUNCATED] rather than traversed.
| Strategy | Result | When |
|---|---|---|
FullMaskStrategy (default) |
ββββββββ |
Zero leakage β recommended |
PartialMaskStrategy(visible: N) |
βββ1234 |
Keep a tail for debugging/correlation |
Both are configurable (placeholder, mask character, number of visible chars), and
you can implement MaskStrategyInterface for anything else.
Register the processor with the MonologBundle via the monolog.processor tag.
Heads-up:
MaskerBuilderis immutable (with*()returns a new instance), so Symfony'scalls:cannot configure it. Use the builder as a factory object (defaults) or a small factory class (custom config).
Defaults β one service:
# config/services.yaml
services:
monolog_masker.builder:
class: Tiime\MonologMasker\MaskerBuilder
factory: ['Tiime\MonologMasker\MaskerBuilder', 'create']
Tiime\MonologMasker\Processor\MaskingProcessor:
factory: ['@monolog_masker.builder', 'buildProcessor']
tags:
# low priority so it runs LAST and also masks `extra` from other processors
- { name: monolog.processor, priority: -100 }Custom config β via a factory class:
// src/Logging/MaskingProcessorFactory.php
namespace App\Logging;
use Tiime\MonologMasker\MaskerBuilder;
use Tiime\MonologMasker\Processor\MaskingProcessor;
use Tiime\MonologMasker\Strategy\PartialMaskStrategy;
final class MaskingProcessorFactory
{
public static function create(): MaskingProcessor
{
return MaskerBuilder::create()
->withSensitiveKeys(['x-internal-token'])
->withStrategy(new PartialMaskStrategy(visible: 4))
->buildProcessor();
}
}# config/services.yaml
services:
Tiime\MonologMasker\Processor\MaskingProcessor:
factory: ['App\Logging\MaskingProcessorFactory', 'create']
tags:
- { name: monolog.processor, priority: -100 }Scope it to a handler or channel if needed:
- { name: monolog.processor, handler: main } (or channel: app).
The masking engine is decoupled from Monolog so it can be tested and reused on its own:
Maskerβ recursive, immutable engine (never mutates its input; traverses arrays and objects; bounded by a max depth that also guards against cycles).MaskingProcessorβ thin Monolog adapter (ProcessorInterface).Matcher\*β pluggable detection: keys (KeyListMatcher,SegmentKeyMatcher) and values (RegexValueMatcher,CreditCardMatcher,ChainValueMatcher).Strategy\*β pluggable masking (FullMaskStrategy,PartialMaskStrategy).MaskerBuilderβ fluent factory tying it all together with secure defaults.
This package follows the Tiime conventions (Docker + make):
make install # install dependencies
make validate # cs-check + phpstan (max) + coverage + infection + proofs β run before any commit
make test # unit tests only
make coverage # unit tests + 100% line/method coverage gate (pcov)
make infection # mutation testing (MSI 100%)
make proofs # property-based tests (black-box)The suite is held to 100% line & method coverage and 100% MSI (mutation score, via Infection). Both are enforced as hard gates in CI, so a weak test that executes code without actually asserting its behaviour fails the build.
On top of the example-based unit tests, the library's invariants are checked with
property-based tests (innmind/black-box) β idempotence,
no-leak, structure preservation, depth bounds, etc. β over hundreds of randomly generated,
auto-shrinking inputs. These live in tests/Property (suite property, run via make proofs);
coverage and mutation gates stay scoped to the deterministic unit suite.
MIT β see LICENSE.