diff --git a/app/app/Logging/PiiScrubbingProcessor.php b/app/app/Logging/PiiScrubbingProcessor.php new file mode 100644 index 00000000..ce75ec43 --- /dev/null +++ b/app/app/Logging/PiiScrubbingProcessor.php @@ -0,0 +1,64 @@ +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 $data + * @return array + */ + 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; + } +} diff --git a/app/app/Logging/ScrubPii.php b/app/app/Logging/ScrubPii.php new file mode 100644 index 00000000..52ca498f --- /dev/null +++ b/app/app/Logging/ScrubPii.php @@ -0,0 +1,20 @@ + [\App\Logging\ScrubPii::class] в описании канала. + */ +final class ScrubPii +{ + public function __invoke(Logger $logger): void + { + $logger->getLogger()->pushProcessor(new PiiScrubbingProcessor); + } +} diff --git a/app/config/logging.php b/app/config/logging.php index b09cb25d..02bd472a 100644 --- a/app/config/logging.php +++ b/app/config/logging.php @@ -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' => [ diff --git a/app/tests/Feature/Logging/PiiScrubbingProcessorTest.php b/app/tests/Feature/Logging/PiiScrubbingProcessorTest.php new file mode 100644 index 00000000..ef914e43 --- /dev/null +++ b/app/tests/Feature/Logging/PiiScrubbingProcessorTest.php @@ -0,0 +1,57 @@ +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(); +}); diff --git a/docs/superpowers/plans/2026-06-17-log-pii-scrubbing-plan-v2.md b/docs/superpowers/plans/2026-06-17-log-pii-scrubbing-plan-v2.md new file mode 100644 index 00000000..7bb97cfd --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-log-pii-scrubbing-plan-v2.md @@ -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 отдельным шагом; либо (б) расширить на все каналы сразу. +Рекомендация ожидается непустой — это конкретная развилка объёма проводки. diff --git a/docs/superpowers/plans/2026-06-17-log-pii-scrubbing-plan.md b/docs/superpowers/plans/2026-06-17-log-pii-scrubbing-plan.md new file mode 100644 index 00000000..ad896285 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-log-pii-scrubbing-plan.md @@ -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-рекомендацию по достаточности покрытия каналов. diff --git a/docs/superpowers/runbooks/2026-06-17-log-pii-scrubbing.md b/docs/superpowers/runbooks/2026-06-17-log-pii-scrubbing.md new file mode 100644 index 00000000..8cdbd720 --- /dev/null +++ b/docs/superpowers/runbooks/2026-06-17-log-pii-scrubbing.md @@ -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`). diff --git a/docs/superpowers/specs/2026-06-17-log-pii-scrubbing-spec.md b/docs/superpowers/specs/2026-06-17-log-pii-scrubbing-spec.md new file mode 100644 index 00000000..77515d04 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-log-pii-scrubbing-spec.md @@ -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(...)`). +- Телефоны РФ: `/(?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')," + } +] +```