tiime / monolog-masker
A lightweight, zero-dependency Monolog processor to keep sensitive data and secrets out of your logs.
Requires
- php: >=8.2
- monolog/monolog: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- infection/infection: ^0.27
- innmind/black-box: ^6
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^10.5
This package is auto-updated.
Last update: 2026-05-29 15:54:03 UTC
README
A lightweight, zero-dependency Monolog processor to keep sensitive data and secrets out of your logs.
🛑 The Problem
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.
✅ The Solution
monolog-masker provides a simple Processor for Monolog that recursively intercepts and masks sensitive keys before they are ever written to your logs.
📦 Installation
You can install the package via composer:
composer require tiime/monolog-masker
🚀 Quick start
Push 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.
⚙️ Configuration
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.
Processor ordering
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.
Depth limit
Recursion is bounded by maxDepth (default 16); anything deeper — and any object
cycle — is replaced with [TRUNCATED] rather than traversed.
Masking strategies
| 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.
🎼 Symfony integration
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).
🧱 Architecture
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.
✅ Development
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.
📄 License
MIT — see LICENSE.