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