From c093eaafe5cc9f40626aa612c4e6f08391795137 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 26 Aug 2026 15:50:57 +0300 Subject: [PATCH] =?UTF-8?q?fix(observability):=20=D0=BF=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BD=D0=B5=20=D1=83=D0=B5=D0=B7=D0=B6=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=20=D0=B2=20Loki=20=E2=80=94=20=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D1=83=D0=B1=D0=B1=D0=B5=D1=80=20=D1=83=D1=87=D1=91=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20(#3114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ops/test_3114_log_credential_scrub.py | 129 ++++++++++++++++++ ops/metrics/alloy/alloy-apps.alloy | 29 +++- ops/metrics/alloy/alloy-infra.alloy | 29 +++- 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 backend/tests/ops/test_3114_log_credential_scrub.py diff --git a/backend/tests/ops/test_3114_log_credential_scrub.py b/backend/tests/ops/test_3114_log_credential_scrub.py new file mode 100644 index 00000000..0bcffa97 --- /dev/null +++ b/backend/tests/ops/test_3114_log_credential_scrub.py @@ -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 в обход скруббера" + ) diff --git a/ops/metrics/alloy/alloy-apps.alloy b/ops/metrics/alloy/alloy-apps.alloy index 05acbb8c..9d0b931b 100644 --- a/ops/metrics/alloy/alloy-apps.alloy +++ b/ops/metrics/alloy/alloy-apps.alloy @@ -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" { diff --git a/ops/metrics/alloy/alloy-infra.alloy b/ops/metrics/alloy/alloy-infra.alloy index bc55f1dd..1b688957 100644 --- a/ops/metrics/alloy/alloy-infra.alloy +++ b/ops/metrics/alloy/alloy-infra.alloy @@ -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" {