feat(security): PII-scrubbing процессор логов — Medium go-live

Monolog PiiScrubbingProcessor (телефоны/email -> [PHONE]/[EMAIL]) + ScrubPii tap на single/daily в config/logging.php. Pest 6/6 GREEN. Sentry-scrubbing (OPEN-И-16) не реализуем: sentry-laravel не установлен — open-item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-17 20:43:28 +03:00
parent f32366279f
commit e2425ce873
8 changed files with 353 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Logging;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
/**
* Monolog-процессор: маскирует ПДн в логах перед записью.
*
* Закрывает Medium go-live: laravel.log (LOG_LEVEL=debug) мог сохранить телефон/email
* открытым, если они попадут в текст исключения или контекст. Процессор ловит ВСЕ
* записи каналов, к которым подключён (см. App\Logging\ScrubPii + config/logging.php),
* централизованно надёжнее правки отдельных вызовов Log::.
*/
final class PiiScrubbingProcessor implements ProcessorInterface
{
/**
* Телефоны РФ: 11 цифр в формате 7XXXXXXXXXX / 8XXXXXXXXXX / +7XXXXXXXXXX.
* Lookbehind/lookahead не дают маскировать часть более длинной цифровой строки
* (например 14-значный технический id).
*/
private const PHONE_PATTERN = '/(?<!\d)(?:\+?7|8)\d{10}(?!\d)/';
private const EMAIL_PATTERN = '/[\p{L}0-9._%+\-]+@[\p{L}0-9.\-]+\.\p{L}{2,}/u';
public function __invoke(LogRecord $record): LogRecord
{
return $record->with(
message: $this->scrub($record->message),
context: $this->scrubArray($record->context),
extra: $this->scrubArray($record->extra),
);
}
private function scrub(string $value): string
{
$value = preg_replace(self::PHONE_PATTERN, '[PHONE]', $value) ?? $value;
return preg_replace(self::EMAIL_PATTERN, '[EMAIL]', $value) ?? $value;
}
/**
* @param array<array-key, mixed> $data
* @return array<array-key, mixed>
*/
private function scrubArray(array $data): array
{
$result = [];
foreach ($data as $key => $value) {
if (is_string($value)) {
$result[$key] = $this->scrub($value);
} elseif (is_array($value)) {
$result[$key] = $this->scrubArray($value);
} else {
$result[$key] = $value;
}
}
return $result;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Logging;
use Illuminate\Log\Logger;
/**
* Tap для config/logging.php: вешает PiiScrubbingProcessor на канал.
*
* Использование: 'tap' => [\App\Logging\ScrubPii::class] в описании канала.
*/
final class ScrubPii
{
public function __invoke(Logger $logger): void
{
$logger->getLogger()->pushProcessor(new PiiScrubbingProcessor);
}
}
+4
View File
@@ -63,6 +63,8 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
// PII-scrubbing: маскирует телефоны/email в записях laravel.log.
'tap' => [\App\Logging\ScrubPii::class],
],
'daily' => [
@@ -71,6 +73,8 @@ return [
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
// PII-scrubbing: маскирует телефоны/email в записях laravel.log.
'tap' => [\App\Logging\ScrubPii::class],
],
'slack' => [
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Logging\PiiScrubbingProcessor;
use Monolog\Level;
use Monolog\LogRecord;
function piiTestRecord(string $message, array $context = []): LogRecord
{
return new LogRecord(new DateTimeImmutable, 'testing', Level::Error, $message, $context);
}
test('маскирует телефон РФ в message → [PHONE]', function () {
$out = (new PiiScrubbingProcessor)(piiTestRecord('Сбой для 79161234567 при импорте'));
expect($out->message)->toBe('Сбой для [PHONE] при импорте')
->and($out->message)->not->toContain('79161234567');
});
test('маскирует +7 и 8 форматы телефона', function () {
$p = new PiiScrubbingProcessor;
expect($p(piiTestRecord('a +79161234567 b'))->message)->toBe('a [PHONE] b')
->and($p(piiTestRecord('a 89161234567 b'))->message)->toBe('a [PHONE] b');
});
test('маскирует email в message → [EMAIL]', function () {
$out = (new PiiScrubbingProcessor)(piiTestRecord('user ivan@example.ru вошёл'));
expect($out->message)->toBe('user [EMAIL] вошёл');
});
test('маскирует телефон и email во вложенном context', function () {
$out = (new PiiScrubbingProcessor)(piiTestRecord('msg', [
'phone' => '79991234567',
'nested' => ['email' => 'a@b.ru', 'note' => 'без пдн'],
]));
expect($out->context['phone'])->toBe('[PHONE]')
->and($out->context['nested']['email'])->toBe('[EMAIL]')
->and($out->context['nested']['note'])->toBe('без пдн');
});
test('не маскирует длинную цифровую строку (14 цифр) — нет ложного срабатывания', function () {
$out = (new PiiScrubbingProcessor)(piiTestRecord('id 79161234567890 ok'));
expect($out->message)->toBe('id 79161234567890 ok');
});
test('не-строковые значения context сохраняются как есть', function () {
$out = (new PiiScrubbingProcessor)(piiTestRecord('msg', ['count' => 42, 'flag' => true, 'nil' => null]));
expect($out->context['count'])->toBe(42)
->and($out->context['flag'])->toBe(true)
->and($out->context['nil'])->toBeNull();
});
@@ -0,0 +1,51 @@
# План v2: PII-scrubbing процессор логов
## Цель
Через TDD: `App\Logging\PiiScrubbingProcessor` (маскирование телефонов/email в
message/context/extra), tap `App\Logging\ScrubPii`, проводка в `config/logging.php`
(каналы single/daily). Сначала падающий юнит-тест процессора, затем реализация,
затем проводка, прогон. Из каталога `app`.
```skills-json
["test-driven-development"]
```
```steps-json
[
{"op": "Bash", "object": "cd \"c:/моя/проекты/портал crm/Документация/app\"", "ref": "D5"},
{"op": "Write", "object": "c:/моя/проекты/портал crm/Документация/app/tests/Feature/Logging/PiiScrubbingProcessorTest.php", "ref": "D5"},
{"op": "Bash", "object": "php artisan test tests/Feature/Logging/PiiScrubbingProcessorTest.php", "ref": "D5"},
{"op": "Write", "object": "c:/моя/проекты/портал crm/Документация/app/app/Logging/PiiScrubbingProcessor.php", "ref": "D1"},
{"op": "Write", "object": "c:/моя/проекты/портал crm/Документация/app/app/Logging/ScrubPii.php", "ref": "D2"},
{"op": "Edit", "object": "c:/моя/проекты/портал crm/Документация/app/config/logging.php", "ref": "D2"},
{"op": "Bash", "object": "php artisan test tests/Feature/Logging/PiiScrubbingProcessorTest.php", "ref": "D5"},
{"op": "Write", "object": "c:/моя/проекты/портал crm/Документация/docs/superpowers/runbooks/2026-06-17-log-pii-scrubbing.md", "ref": "D1"}
]
```
```verified-context-json
[
{
"id": "ctx-logging",
"kind": "EXTRACTED",
"ref": "app/config/logging.php",
"anchor": "'path' => storage_path('logs/laravel.log'),"
}
]
```
## Переговоры
### Круг 1
Довод по реализации: процессор Monolog ловит ВСЕ записи централизованно (надёжнее
правки вызовов Log:: по коду). Юнит-тест покрывает security-логику маскирования
(телефон/email в message и вложенном context, отсутствие ложного срабатывания на
длинных цифрах); tap+config — проводка. Вешаем на файловые каналы single/daily
(laravel.log — задокументированный риск).
Наставнику — прошу записать forward-рекомендацию по одному из вариантов: (а) принять
покрытие single/daily достаточным для этой итерации с последующим расширением на
stderr/papertrail/slack отдельным шагом; либо (б) расширить на все каналы сразу.
Рекомендация ожидается непустой — это конкретная развилка объёма проводки.
@@ -0,0 +1,47 @@
# План: PII-scrubbing процессор логов
## Цель
Через TDD: `App\Logging\PiiScrubbingProcessor` (маскирование телефонов/email в
message/context/extra), tap `App\Logging\ScrubPii`, проводка в `config/logging.php`
(каналы single/daily). Сначала падающий юнит-тест процессора, затем реализация,
затем проводка, прогон. Из каталога `app`.
```skills-json
["test-driven-development"]
```
```steps-json
[
{"op": "Bash", "object": "cd \"c:/моя/проекты/портал crm/Документация/app\"", "ref": "D5"},
{"op": "Write", "object": "c:/моя/проекты/портал crm/Документация/app/tests/Feature/Logging/PiiScrubbingProcessorTest.php", "ref": "D5"},
{"op": "Bash", "object": "php artisan test tests/Feature/Logging/PiiScrubbingProcessorTest.php", "ref": "D5"},
{"op": "Write", "object": "c:/моя/проекты/портал crm/Документация/app/app/Logging/PiiScrubbingProcessor.php", "ref": "D1"},
{"op": "Write", "object": "c:/моя/проекты/портал crm/Документация/app/app/Logging/ScrubPii.php", "ref": "D2"},
{"op": "Edit", "object": "c:/моя/проекты/портал crm/Документация/app/config/logging.php", "ref": "D2"},
{"op": "Bash", "object": "php artisan test tests/Feature/Logging/PiiScrubbingProcessorTest.php", "ref": "D5"},
{"op": "Write", "object": "c:/моя/проекты/портал crm/Документация/docs/superpowers/runbooks/2026-06-17-log-pii-scrubbing.md", "ref": "D1"}
]
```
```verified-context-json
[
{
"id": "ctx-logging",
"kind": "EXTRACTED",
"ref": "app/config/logging.php",
"anchor": "'path' => storage_path('logs/laravel.log'),"
}
]
```
## Переговоры
### Круг 1
Довод по реализации: процессор Monolog ловит ВСЕ записи централизованно (надёжнее
правки вызовов Log:: по коду). Юнит-тест покрывает security-логику маскирования
(телефон/email в message и вложенном context, отсутствие ложного срабатывания на
длинных цифрах); tap+config — проводка. Вешаем на файловые каналы single/daily
(laravel.log — задокументированный риск). Прошу наставника зафиксировать
forward-рекомендацию по достаточности покрытия каналов.
@@ -0,0 +1,33 @@
# Runbook: PII-scrubbing логов
**Дата:** 2026-06-17 · **Зона:** `app/` (backend) · **Связано:** Medium go-live (логи без маскирования ПДн)
## Что сделано
- `App\Logging\PiiScrubbingProcessor` (Monolog 3) — маскирует ПДн в `message`/
`context`/`extra` каждой записи: телефоны РФ (`7/8/+7` + 10 цифр) → `[PHONE]`,
email → `[EMAIL]`. Контекст обходится рекурсивно; не-строки не трогаются;
длинные цифровые id не маскируются (lookbehind/lookahead).
- `App\Logging\ScrubPii` — tap, вешает процессор на канал.
- `config/logging.php``'tap' => [ScrubPii::class]` на каналах `single` и `daily`
(оба пишут в `laravel.log` — задокументированный риск).
## Покрытие и расширение
- Покрыты файловые каналы `single`/`daily``stack`, т.к. делегирует в `single`).
- `stderr`/`papertrail` (driver `monolog`) — расширяются добавлением
`PiiScrubbingProcessor::class` в их `'processors'` массив; `slack`/`syslog`/
`errorlog` — через `'tap'`. Отложено как отдельный шаг (forward-рекомендация наставника).
## Sentry (OPEN-И-16) — фактическая находка
Проверено по `app/composer.json`: пакет `sentry`/`sentry-laravel` **НЕ установлен**;
`config/sentry.php` отсутствует; Sentry в приложении не сконфигурирован. Поэтому
«регекс-маска в config/sentry.php» **не реализуема в коде** на текущем состоянии —
это инфраструктурный Sentry (self-hosted) без Laravel-SDK интеграции. Отдельный
open-item для владельца: либо установить sentry-laravel + `before_send` scrubbing,
либо подтвердить, что приложение не шлёт события в Sentry.
## Проверка
`php artisan test tests/Feature/Logging/PiiScrubbingProcessorTest.php` (из `app`).
@@ -0,0 +1,77 @@
# Спека: PII-scrubbing процессор логов
## Цель
Закрыть Medium go-live: `config/logging.php` пишет в `laravel.log` (LOG_LEVEL=debug)
без маскирования ПДн — если телефон/email попадёт в текст исключения или контекст,
он осядет в логе открытым. Добавить Monolog-процессор, который централизованно
маскирует ПДн во ВСЕХ записях файловых каналов, не правя вызовы `Log::` по коду.
## Процессор маскирования {#D1}
`App\Logging\PiiScrubbingProcessor` (Monolog 3, `ProcessorInterface`):
- `__invoke(LogRecord $record): LogRecord` — возвращает запись с маскированными
`message`, `context`, `extra` (через `$record->with(...)`).
- Телефоны РФ: `/(?<!\d)(?:\+?7|8)\d{10}(?!\d)/``[PHONE]` (11-значные 7/8/+7;
lookbehind/lookahead не дают маскировать часть более длинной цифровой строки).
- Email: `/[\p{L}0-9._%+\-]+@[\p{L}0-9.\-]+\.\p{L}{2,}/u``[EMAIL]`.
- `context`/`extra` обходятся рекурсивно: строки маскируются, вложенные массивы —
рекурсивно, не-строки (int/bool/null/object) — без изменений.
## Проводка {#D2}
`App\Logging\ScrubPii` (tap): `__invoke(\Illuminate\Log\Logger $logger)`
`$logger->getLogger()->pushProcessor(new PiiScrubbingProcessor())`.
В `config/logging.php` добавить `'tap' => [\App\Logging\ScrubPii::class]` к каналам
`single` и `daily` (оба пишут в `laravel.log` — основной риск). Каналы `stderr`/
`papertrail` (driver `monolog`) расширяются через свой `processors` массив отдельно
по мере надобности; `null`/`emergency`/`stack` не требуют (stack делегирует в single).
## Sentry — фактическая проверка {#D3}
Проверено фактом: пакета `sentry`/`sentry-laravel` в `app/composer.json` НЕТ;
`config/sentry.php` отсутствует; Sentry в приложении не сконфигурирован. Значит
«регекс-маска в config/sentry.php» (OPEN-И-16) **не реализуема в коде** без установки
SDK — Sentry self-hosted это инфраструктура, но Laravel-интеграция отсутствует.
Это фиксируется как факт для владельца (отдельный open-item), не реализуется здесь.
## Граничные случаи {#D4}
- Длинная цифровая строка (например 14-значный id) НЕ маскируется (lookahead `(?!\d)`).
- Не-строковые значения context (int/bool/null/объекты) проходят без изменений.
- Процессор идемпотентен и без побочных эффектов (чистая трансформация записи).
- `[PHONE]`/`[EMAIL]` — фиксированные плейсхолдеры, не зависят от исходной длины.
## Критерий приёмки {#D5}
Pest-файл `tests/Feature/Logging/PiiScrubbingProcessorTest.php`:
- телефон (`79161234567`/`+79161234567`/`89161234567`) в `message``[PHONE]`,
исходные цифры отсутствуют;
- email в `message``[EMAIL]`;
- телефон и email во вложенном `context` (массив в массиве) → маскированы;
- 14-значная цифровая строка в `message` НЕ изменена (нет ложного срабатывания);
- не-строковые значения `context` (int) сохранены как есть.
## Переговоры
### Круг 1
Открытая точка для наставника: процессор вешается на `single`/`daily` (файловые
каналы `laravel.log` — задокументированный риск), `stderr`/`papertrail` оставлены на
будущее расширение, чтобы не раздувать правку. Прошу наставника зафиксировать
forward-рекомендацию: достаточно ли покрытия файловых каналов на этом шаге, либо
расширить на все каналы сразу. Это конкретный предмет рекомендации, не пустой слот.
```verified-context-json
[
{
"id": "ctx-logging",
"kind": "EXTRACTED",
"ref": "app/config/logging.php",
"anchor": "'path' => storage_path('logs/laravel.log'),"
}
]
```