Data Masker

Пакет php-concept/extension-data-masker з’являється не «для красивого API логера», а з конкретного болю: щойно ви пишете корисні логи, у файл потрапляють email, паролі, токени, шматки тіла запиту. Том storage/logs/, бекап у S3, доступ усієї команди до Docker volume — і «дебаг» стає витоком.

Core і Logging не знають вашого списку секретів і не маскують «все підряд»: чужа магія або ховає занадто багато (немає чим дебажити), або замало (витік лишається). Data Masker — тонкий шар правил «що замінити», який sinks (Logging, пізніше validation/SQL log) викликають перед записом. Підключаєте, коли з’явився цей біль — у рецепті одразу з Monolog: Логування і маскування. API builder-а — Masking brick.

Коли потрібен і коли ні

  • Потрібен, якщо в лог/debug потрапляє context з персональними полями, exception message з credentials, або SQL з літералами паролів.
  • Не обов’язковий, поки логер пише лише безособові рядки без context (рідкісний випадок) або ви взагалі без Logging.
  • Не замінює авторизацію доступу до логів і шифрування дисків — лише зменшує шкоду, коли файл уже хтось відкрив.

Збірка через Stack

У додатку capability реєструє addMasking(): у контейнері з’являється DataMaskerInterface з вашими правилами. Це ще не маскує логи — лише кладе сервіс. Кожен sink сам каже «хочу masker»:

$stack->addMasking()
    ->setPatterns([
        '/[a-z0-9_\-\+\.]+@[a-z0-9\-]+\.[a-z]{2,}/i' => '***@***.***',
    ])
    ->setKeyPatterns([
        '/^email$/i',
        '/.*password.*/i',
        '/.*token.*/i',
    ]);

$stack->addLogging()
    ->toRotatingFile($rootPath . 'storage/logs/app.log')
    ->withMasking(); // require(LOGGING, MASKING)

Без withMasking() на Logging правила лежать у контейнері марно — і це навмисно: той самий masker пізніше можна повісити на Database (withQueryLogging() + withMasking()) або Validation log, не чіпаючи інші sinks. Деталі app-каналу — Logging brick (withMasking()).

Методи MaskingBuilder:

Метод Параметри Навіщо
setPatterns() array<string, string> — regex → заміна Фрагменти вже «розчинені» в message / trace / SQL-рядку
setKeyPatterns() list<string> — regex на ім’я ключа Ціле значення в array/object context за ім’ям поля
setRules() list<class-string<DataMaskerRuleInterface>> Власна логіка, коли regex недостатньо

Як працює mask()

Єдиний вхід — DataMaskerInterface::mask(mixed $data): mixed. Якщо правил немає — дані повертаються як є (no-op). Інакше:

  • array — deep clone, для кожного ключа: якщо isSensitiveKey($key) → значення ***; інакше рекурсія в значення
  • object — clone + reflection по властивостях (та сама логіка імен)
  • string — послідовно apply() усіх rules (regex-заміни тощо)
  • інше (int, bool, null) — без змін

Logger Monolog вішає processor: маскує і $record->message, і $record->context перед handler. Тому key pattern на email ховає context, а string pattern — email у тексті повідомлення.

// типовий результат після key pattern /^email$/i
$masker->mask(['email' => 'ada@example.com', 'ok' => 1]);
// ['email' => '***', 'ok' => 1]

// string pattern на адресу в тексті
$masker->mask('User ada@example.com failed');
// 'User ***@***.*** failed'

Key patterns vs string patterns

Секрети потрапляють у логи двома шляхами — тому й два механізми.

Key patterns — коли ваш код уже поклав дані в структурований context: $logger->info('…', ['email' => $email, 'password' => $pwd]). Достатньо домовитись про імена полів (/^email$/i, /.*password.*/i). Значення замінюється цілком на *** — не треба вгадувати формат пароля. Це найнадійніший шлях для вашого коду.

String patterns (setPatterns(['/regex/' => 'replacement']) — коли секрет уже всередині рядка: exception message, trace, чужий SQL dump, "User ada@… failed". Тут key patterns безсилі: ключа email немає. Regex шукає фрагмент і підставляє заміну (наприклад email → ***@***.***).

На практиці майже завжди потрібні обидва: key — для вашого context, string — для того, що «просочилось» у текст. Рецепт кроку 8 показує обидва на contact-формі.

Правило за замовчуванням: RegexDataMaskerRule

Якщо передали patterns і/або keyPatterns, ServiceProvider додає один RegexDataMaskerRule:

  • isSensitiveKey() — preg_filter по списку keyPatterns
  • apply() — preg_replace усіх pattern → replacement

Окремо реєструвати цей клас не потрібно — Stack робить це з аргументів builder-а.

Власні rules

Regex не покриває все (наприклад, маскувати лише останні 4 цифри картки за алгоритмом, або ховати поле лише в певному namespace). Тоді реалізуєте DataMaskerRuleInterface:

use Concept\Extensions\DataMasker\Contracts\DataMaskerRuleInterface;

final class CardLastFourRule implements DataMaskerRuleInterface
{
    public function isSensitiveKey(string $key): bool
    {
        return (bool) preg_match('/^card_number$/i', $key);
    }

    public function apply(string $value): string
    {
        if (!preg_match('/^\d{12,19}$/', $value)) {
            return $value;
        }

        return str_repeat('*', max(0, strlen($value) - 4)) . substr($value, -4);
    }
}
$stack->addMasking()
    ->setKeyPatterns(['/^email$/i'])
    ->setRules([CardLastFourRule::class]);

Клас має бути резолвибельним з контейнера (автоwiring або ваш provider). Rules застосовуються після (разом із) Regex-правилом: sensitive key перевіряється по всіх rules; для рядків — послідовний apply().

Пряме використання

Masker не прив’язаний лише до Monolog. Будь-який сервіс може інжектити DataMaskerInterface і чистити дані перед відправкою в зовнішній audit, Slack, telemetry:

public function __construct(
    private readonly DataMaskerInterface $masker,
) {}

public function audit(array $payload): void
{
    $safe = $this->masker->mask($payload);
    // …
}

clearRules() на інтерфейсі — для тестів / рідкісних сценаріїв скидання; у звичайному HTTP-запиті правила задає Bootstrap один раз через Stack.

Склад пакета

Concept\Extensions\DataMasker\
├── Contracts/
│   ├── DataMaskerInterface.php      # mask(), clearRules()
│   └── DataMaskerRuleInterface.php  # isSensitiveKey(), apply()
├── DataMasker.php                   # обхід array/object/string
├── RegexDataMaskerRule.php          # patterns + keyPatterns
└── DataMaskerServiceProvider.php    # реєстрація в League Container

Залежності свідомо вузькі: PHP 8.4, League Container, подія ExtensionAwakened. Немає прив’язки до Monolog — Logging тягне masker опційно через factory.

Мінімальний чеклист правил

  • Імена полів форм і API, які ви самі кладете в context
  • password, *token*, secret, authorization
  • Email / картка / телефон у string patterns, якщо вони бувають у message
  • Перевірка: один POST з реальним email → у файлі ***, не адреса
  • Контрольна перевірка: прибрати withMasking() → email знову відкритий (доводить, що маскує саме цей ланцюг)

Практика в додатку — 8. Логування і маскування. Stack — Masking brick. Sink — Logging brick (withMasking()). Наступні sinks (validation / SQL) — Діагностичні логи.