fix(observability): пароли БД не уезжают в Loki — скруббер учётных данных в Alloy (#3114) #3115

Merged
bot-backend merged 1 commit from fix/3114-scrub-credentials-in-logs into main 2026-08-26 13:44:18 +00:00
Owner

Закрывает пункт 2 из #3114 — тот, что делается кодом.

Что было

postgres_exporter при неудачном скрейпе печатает полный DSN вместе с паролем:

msg="error scraping dsn" err="queryNamespaceMappings returned 1 errors"
dsn="postgresql://gendesign_reader:<ПАРОЛЬ>@tradein-postgres:5432/tradein?sslmode=disable"

Замер по Loki за сутки: 104 такие строки на инфра-хосте, 106 на продуктовом. При ретенции 30 дней в хранилище накапливается порядка 6000 строк с паролями БД — и доступ к ним даёт вход в Grafana, с которой сегодня же снят внешний basic_auth (#3113). Каждое решение по отдельности нормально, вместе — неприятно.

Проверено экспериментом, а не предположено

Напрашивалось передать пароль отдельно от строки подключения: DATA_SOURCE_URI + DATA_SOURCE_USER + DATA_SOURCE_PASS_FILE. Не помогает — прогон на скретч-контейнерах показал, что экспортер собирает строку сам и логирует её целиком:

=== Б (как сейчас): DATA_SOURCE_NAME с паролем      → ПАРОЛЬ В ЛОГАХ (воспроизвелось)
=== А (предлагаемое): URI + USER + PASS_FILE        → ПАРОЛЬ В ЛОГАХ — способ НЕ помогает

Честно про путь: первые три попытки воспроизведения были неинформативны, и я едва не принял их за успех. Контейнер падал сразу (docker logs не отработал, «пароля нет» ничего не значило); затем не было скрейпа вовсе; затем не был подключён наш queries.yml. Утечка воспроизводится только при неудачном скрейпе с нашим файлом кастомных запросов — сообщение queryNamespaceMappings идёт именно оттуда.

Что стало

Ступень loki.process "scrub_credentials" между источником журнала и loki.write, в обоих конфигах Alloy:

stage.replace {
  expression = "://[^:@/ ]+:([^@ ]+)@"
  replace    = "***"
}

Пользователь и адрес остаются — без них строка ошибки перестаёт годиться для диагностики («какая учётка не смогла подключиться»). Ровно одна группа захвата: Alloy заменяет содержимое групп, вторая затёрла бы имя пользователя.

Регулярка без экранирования намеренно — Alloy не принимает \s в строке (unknown escape sequence, поймано на alloy fmt), поэтому класс задан явным пробелом.

Это защита в глубину, а не замена причине

Конкретно эта ошибка уходит грантом pg_monitor: запрос pg_wal_bytes в нашем ops/metrics/postgres/queries.yml использует pg_ls_waldir(), на который у читателя нет прав. Грант трогает боевую БД и остаётся за владельцем (#3114, пункт 1) — выполню по команде.

Скруббер же ловит любой пароль в URL, включая компоненты, о которых мы пока не знаем.

Тесты

backend/tests/ops/test_3114_log_credential_scrub.py, 8 штук — берут выражение из конфига и применяют к настоящей строке из прода, а не проверяют наличие нужных слов в файле:

  1. пароль из реальной утёкшей строки исчезает;
  2. пользователь и адрес выживают;
  3. обычные URL и почтовые адреса не портятся — слишком жадный скруббер хуже отсутствующего: он молча исказит логи, и это заметят ровно тогда, когда по ним будут разбирать аварию;
  4. журнал направлен в скруббер и больше не пишет в Loki напрямую — объявленный, но не включённый в конвейер скруббер не чистит ничего, а выглядит как защита.

Фальсификация: на исходных конфигах краснеют все 8.

Проверено помимо тестов: оба конфига прогнаны через alloy fmt образом grafana/alloy:v1.6.1 — синтаксис ok; tests/ops целиком — 43 passed.

Refs #3114, #3078

Закрывает пункт 2 из #3114 — тот, что делается кодом. ## Что было `postgres_exporter` при неудачном скрейпе печатает **полный DSN вместе с паролем**: ``` msg="error scraping dsn" err="queryNamespaceMappings returned 1 errors" dsn="postgresql://gendesign_reader:<ПАРОЛЬ>@tradein-postgres:5432/tradein?sslmode=disable" ``` Замер по Loki за сутки: **104** такие строки на инфра-хосте, **106** на продуктовом. При ретенции 30 дней в хранилище накапливается порядка 6000 строк с паролями БД — и доступ к ним даёт вход в Grafana, с которой сегодня же снят внешний basic_auth (#3113). Каждое решение по отдельности нормально, вместе — неприятно. ## Проверено экспериментом, а не предположено Напрашивалось передать пароль отдельно от строки подключения: `DATA_SOURCE_URI` + `DATA_SOURCE_USER` + `DATA_SOURCE_PASS_FILE`. **Не помогает** — прогон на скретч-контейнерах показал, что экспортер собирает строку сам и логирует её целиком: ``` === Б (как сейчас): DATA_SOURCE_NAME с паролем → ПАРОЛЬ В ЛОГАХ (воспроизвелось) === А (предлагаемое): URI + USER + PASS_FILE → ПАРОЛЬ В ЛОГАХ — способ НЕ помогает ``` Честно про путь: первые три попытки воспроизведения были **неинформативны**, и я едва не принял их за успех. Контейнер падал сразу (`docker logs` не отработал, «пароля нет» ничего не значило); затем не было скрейпа вовсе; затем не был подключён наш `queries.yml`. Утечка воспроизводится только при неудачном скрейпе **с нашим файлом кастомных запросов** — сообщение `queryNamespaceMappings` идёт именно оттуда. ## Что стало Ступень `loki.process "scrub_credentials"` между источником журнала и `loki.write`, в обоих конфигах Alloy: ``` stage.replace { expression = "://[^:@/ ]+:([^@ ]+)@" replace = "***" } ``` **Пользователь и адрес остаются** — без них строка ошибки перестаёт годиться для диагностики («какая учётка не смогла подключиться»). Ровно **одна** группа захвата: Alloy заменяет содержимое групп, вторая затёрла бы имя пользователя. **Регулярка без экранирования намеренно** — Alloy не принимает `\s` в строке (`unknown escape sequence`, поймано на `alloy fmt`), поэтому класс задан явным пробелом. ## Это защита в глубину, а не замена причине Конкретно эта ошибка уходит грантом `pg_monitor`: запрос `pg_wal_bytes` в **нашем** `ops/metrics/postgres/queries.yml` использует `pg_ls_waldir()`, на который у читателя нет прав. Грант трогает боевую БД и остаётся за владельцем (#3114, пункт 1) — выполню по команде. Скруббер же ловит **любой** пароль в URL, включая компоненты, о которых мы пока не знаем. ## Тесты `backend/tests/ops/test_3114_log_credential_scrub.py`, 8 штук — берут выражение **из конфига** и применяют к настоящей строке из прода, а не проверяют наличие нужных слов в файле: 1. пароль из реальной утёкшей строки исчезает; 2. пользователь и адрес выживают; 3. обычные URL и почтовые адреса не портятся — слишком жадный скруббер хуже отсутствующего: он молча исказит логи, и это заметят ровно тогда, когда по ним будут разбирать аварию; 4. журнал направлен **в** скруббер и больше не пишет в Loki напрямую — объявленный, но не включённый в конвейер скруббер не чистит ничего, а выглядит как защита. **Фальсификация:** на исходных конфигах краснеют все 8. Проверено помимо тестов: оба конфига прогнаны через `alloy fmt` образом `grafana/alloy:v1.6.1` — синтаксис ok; `tests/ops` целиком — 43 passed. Refs #3114, #3078
lekss361 added 1 commit 2026-08-26 12:51:32 +00:00
fix(observability): пароли не уезжают в Loki — скруббер учётных данных (#3114)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 17m33s
c093eaafe5
postgres_exporter при неудачном скрейпе печатает полный DSN вместе с паролем.
Замер по Loki за сутки: 104 такие строки на инфра-хосте и 106 на продуктовом.
При ретенции 30 дней это порядка 6000 строк с паролями БД в хранилище, доступ
к которому даёт вход в Grafana - причём внешний basic_auth с витрины сегодня
же снят (#3113).

Проверено экспериментом, а не предположено. Напрашивалось передать пароль
отдельно от строки подключения (DATA_SOURCE_URI + DATA_SOURCE_USER +
DATA_SOURCE_PASS_FILE). Прогон на скретч-контейнерах с настоящим файлом
запросов показал: НЕ помогает - экспортер собирает строку сам и логирует её
целиком. Первые три попытки воспроизведения были неинформативны (контейнер
падал сразу; скрейпа не было; не подключён файл кастомных запросов) - утечка
воспроизводится только при неудачном скрейпе с нашим queries.yml.

Стало: ступень loki.process между источником журнала и loki.write, маскирует
пароль в любом URL вида scheme://user:pass@host. Пользователь и адрес
остаются - без них строка ошибки перестаёт годиться для диагностики. Ровно
одна группа захвата: Alloy заменяет содержимое групп, вторая затёрла бы имя
пользователя.

Это защита в глубину, а не замена причине. Конкретно эта ошибка уходит грантом
pg_monitor (запрос pg_wal_bytes в нашем queries.yml требует pg_ls_waldir) -
это боевая БД и остаётся за владельцем. Скруббер же ловит любой пароль в URL,
включая компоненты, о которых мы ещё не знаем.

Регулярка без экранирования намеренно: Alloy не принимает \s в строке
(unknown escape sequence), поэтому класс задан явным пробелом. Оба конфига
прогнаны через `alloy fmt` образом grafana/alloy:v1.6.1 - синтаксис ok.

Тесты (8) берут выражение ИЗ КОНФИГА и применяют к настоящей строке из прода:
пароль исчезает; пользователь и адрес остаются; обычные URL и почтовые адреса
не портятся; журнал направлен в скруббер, а не мимо него. Фальсификация: на
исходных конфигах краснеют все 8. tests/ops целиком - 43 passed.
bot-backend merged commit 35d46f3526 into main 2026-08-26 13:44:18 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3115
No description provided.