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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'),"
|
||||
}
|
||||
]
|
||||
```
|
||||
Reference in New Issue
Block a user