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

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.
This commit is contained in:
bot-backend 2026-08-26 15:50:57 +03:00
parent 6dd2d26c60
commit c093eaafe5
3 changed files with 185 additions and 2 deletions

View file

@ -0,0 +1,129 @@
"""Пароли не уезжают в Loki: скруббер учётных данных в Alloy (#3114).
Прод-факт. `postgres_exporter` при неудачном скрейпе печатает полный DSN:
msg="error scraping dsn" dsn="postgresql://gendesign_reader:<ПАРОЛЬ>@tradein-postgres:5432/tradein?sslmode=disable"
Замер по Loki за сутки: 104 такие строки на инфра-хосте и 106 на продуктовом.
При ретенции 30 дней это порядка 6000 строк с паролями БД в хранилище, доступ
к которому даёт вход в Grafana.
ЧТО ПРОВЕРЕНО ЭКСПЕРИМЕНТОМ, А НЕ ПРЕДПОЛОЖЕНО. Первым решением напрашивалось
передать пароль отдельно от строки подключения (`DATA_SOURCE_URI` +
`DATA_SOURCE_USER` + `DATA_SOURCE_PASS_FILE`). Прогон на скретч-контейнерах с
настоящим файлом запросов показал, что это НЕ помогает: экспортер собирает
строку сам и логирует её целиком. Поэтому чистим на своей стороне, до отправки.
Скруббер защита в глубину, а не замена причине: конкретно эта ошибка уходит
грантом `pg_monitor`. Но он ловит любой пароль в URL, включая те компоненты, о
которых мы ещё не знаем.
Тесты ниже берут выражение ИЗ КОНФИГА и применяют его к настоящей строке из
прода проверяется фактическая маскировка, а не наличие нужных слов в файле.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[3]
CONFIGS = [
REPO_ROOT / "ops" / "metrics" / "alloy" / "alloy-apps.alloy",
REPO_ROOT / "ops" / "metrics" / "alloy" / "alloy-infra.alloy",
]
# Настоящая строка из Loki (пароль заменён на равный по форме).
LEAKED = (
'time=2026-08-26T12:34:20.945Z level=ERROR source=postgres_exporter.go:681 '
'msg="error scraping dsn" err="queryNamespaceMappings returned 1 errors" '
'dsn="postgresql://gendesign_reader:5fc281ce5d1bcf612417dffcae51d82fe5c3db2d7c4b4eac'
'@tradein-postgres:5432/tradein?sslmode=disable"'
)
SECRET = "5fc281ce5d1bcf612417dffcae51d82fe5c3db2d7c4b4eac"
# Строки, которые скруббер портить НЕ должен.
INNOCENT = [
"GET https://example.com/path",
"user@host",
"redis://redis:6379/0",
"https://metrics.gendsgn.ru/ingest/loki/api/v1/push",
]
def _expression(cfg: Path) -> str:
assert cfg.is_file(), f"нет {cfg} — конфиг переехал, гейт ослеп"
text = cfg.read_text(encoding="utf-8")
assert "loki.process" in text and "scrub_credentials" in text, (
f"{cfg.name}: скруббер пропал из конфига"
)
m = re.search(r'stage\.replace\s*\{[^}]*expression\s*=\s*"([^"]+)"', text, re.S)
assert m, f"{cfg.name}: не нашёл expression у stage.replace"
return m.group(1)
def _apply(expression: str, line: str) -> str:
"""Повторяет поведение Alloy: заменяется содержимое групп захвата."""
pat = re.compile(expression)
out, pos = [], 0
for m in pat.finditer(line):
for gi in range(1, (m.re.groups or 0) + 1):
s, e = m.span(gi)
out.append(line[pos:s])
out.append("***")
pos = e
out.append(line[pos:])
return "".join(out)
@pytest.mark.parametrize("cfg", CONFIGS, ids=lambda p: p.name)
def test_password_is_masked_in_real_leaked_line(cfg: Path) -> None:
"""Пароль из настоящей строки прода исчезает."""
masked = _apply(_expression(cfg), LEAKED)
assert SECRET not in masked, f"{cfg.name}: пароль пережил скруббер"
assert "***" in masked, f"{cfg.name}: маскировка не применилась вовсе"
@pytest.mark.parametrize("cfg", CONFIGS, ids=lambda p: p.name)
def test_user_and_host_survive(cfg: Path) -> None:
"""Пользователь и адрес остаются — без них строка ошибки бесполезна.
Ровно одна группа захвата: вторая затёрла бы имя пользователя, и диагностика
«какая учётка не смогла подключиться» была бы потеряна.
"""
masked = _apply(_expression(cfg), LEAKED)
assert "gendesign_reader" in masked, f"{cfg.name}: затёрто имя пользователя"
assert "tradein-postgres:5432" in masked, f"{cfg.name}: затёрт адрес"
@pytest.mark.parametrize("cfg", CONFIGS, ids=lambda p: p.name)
def test_innocent_lines_are_untouched(cfg: Path) -> None:
"""Обычные URL и почтовые адреса не портятся.
Слишком жадный скруббер хуже отсутствующего: он молча исказит логи, и это
заметят в тот момент, когда по ним будут разбирать аварию.
"""
expr = _expression(cfg)
for line in INNOCENT:
assert _apply(expr, line) == line, f"{cfg.name}: испорчена строка {line!r}"
@pytest.mark.parametrize("cfg", CONFIGS, ids=lambda p: p.name)
def test_journal_goes_through_scrubber_not_straight_to_loki(cfg: Path) -> None:
"""Источник журнала направлен в скруббер, а не напрямую в loki.write.
Обратный конец инварианта: сам по себе объявленный, но не включённый в
конвейер скруббер не чистит ничего и выглядит при этом как защита.
"""
text = cfg.read_text(encoding="utf-8")
src = re.search(r"loki\.source\.journal\s+\"[^\"]+\"\s*\{(.*?)\n\}", text, re.S)
assert src, f"{cfg.name}: не нашёл loki.source.journal"
body = src.group(1)
assert "loki.process.scrub_credentials.receiver" in body, (
f"{cfg.name}: журнал уходит мимо скруббера"
)
assert "loki.write.central.receiver" not in body, (
f"{cfg.name}: журнал всё ещё пишет напрямую в Loki в обход скруббера"
)

View file

@ -144,7 +144,34 @@ loki.source.journal "host" {
job = "journal",
}
relabel_rules = loki.relabel.journal.rules
forward_to = [loki.write.central.receiver]
forward_to = [loki.process.scrub_credentials.receiver]
}
// ── Скруббер учётных данных (#3114) ──────────────────────────────────────────
// Прод-факт: postgres_exporter при неудачном скрейпе печатает ПОЛНЫЙ DSN вместе
// с паролем. Замер по Loki за сутки — 104 строки на инфра-хосте и 106 на
// продуктовом, то есть при ретенции 30 дней в хранилище копится порядка 6000
// строк с паролями БД. Доступ к ним даёт вход в Grafana.
//
// Проверено на скретч-контейнерах: передача пароля отдельно
// (DATA_SOURCE_URI + DATA_SOURCE_USER + DATA_SOURCE_PASS_FILE) НЕ помогает —
// экспортер собирает строку подключения сам и логирует её целиком. Поэтому
// чистим на нашей стороне, до отправки в Loki.
//
// Это защита в глубину, а не замена причине: конкретно эта ошибка уходит
// грантом pg_monitor (#3114). Скруббер же ловит ЛЮБОЙ пароль в URL — в том
// числе из компонентов, о которых мы ещё не знаем.
//
// Маскируется только пароль: пользователь и адрес остаются, без них строка
// ошибки перестала бы годиться для диагностики. Одна группа захвата — Alloy
// заменяет содержимое групп, и вторая группа затёрла бы имя пользователя.
loki.process "scrub_credentials" {
forward_to = [loki.write.central.receiver]
stage.replace {
expression = "://[^:@/ ]+:([^@ ]+)@"
replace = "***"
}
}
loki.relabel "journal" {

View file

@ -103,7 +103,34 @@ loki.source.journal "host" {
job = "journal",
}
relabel_rules = loki.relabel.journal.rules
forward_to = [loki.write.central.receiver]
forward_to = [loki.process.scrub_credentials.receiver]
}
// ── Скруббер учётных данных (#3114) ──────────────────────────────────────────
// Прод-факт: postgres_exporter при неудачном скрейпе печатает ПОЛНЫЙ DSN вместе
// с паролем. Замер по Loki за сутки — 104 строки на инфра-хосте и 106 на
// продуктовом, то есть при ретенции 30 дней в хранилище копится порядка 6000
// строк с паролями БД. Доступ к ним даёт вход в Grafana.
//
// Проверено на скретч-контейнерах: передача пароля отдельно
// (DATA_SOURCE_URI + DATA_SOURCE_USER + DATA_SOURCE_PASS_FILE) НЕ помогает —
// экспортер собирает строку подключения сам и логирует её целиком. Поэтому
// чистим на нашей стороне, до отправки в Loki.
//
// Это защита в глубину, а не замена причине: конкретно эта ошибка уходит
// грантом pg_monitor (#3114). Скруббер же ловит ЛЮБОЙ пароль в URL — в том
// числе из компонентов, о которых мы ещё не знаем.
//
// Маскируется только пароль: пользователь и адрес остаются, без них строка
// ошибки перестала бы годиться для диагностики. Одна группа захвата — Alloy
// заменяет содержимое групп, и вторая группа затёрла бы имя пользователя.
loki.process "scrub_credentials" {
forward_to = [loki.write.central.receiver]
stage.replace {
expression = "://[^:@/ ]+:([^@ ]+)@"
replace = "***"
}
}
loki.relabel "journal" {