gendesign/backend/tests/ops/test_3114_log_credential_scrub.py
bot-backend c093eaafe5
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
fix(observability): пароли не уезжают в Loki — скруббер учётных данных (#3114)
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.
2026-08-26 15:50:57 +03:00

129 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Пароли не уезжают в 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 в обход скруббера"
)