fix(ops/alloy): второй слой маскировки query-секретов на сборщике логов (apps + infra) — defence-in-depth к #3353 #3377

Merged
bot-backend merged 1 commit from fix/3354-alloy-query-secret-scrub into main 2026-09-05 20:53:43 +00:00
Collaborator

Closes #3354 (follow-up #3154/#3353). Приложение маскирует query-секреты само (log_scrub.py), но строки от других процессов (journald, сайдкар, GlitchTip/Forgejo с ?token=) шли в Loki как есть.

Что сделано

loki.process "scrub_credentials" — вторая stage.replace (#3115 не тронут) в обоих конфигах (alloy-apps.alloy, alloy-infra.alloy — Forgejo/GlitchTip пишут в тот же Loki):

(?i)[?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=([^&\s"'<>]+)  →  ***

Поправка к постановке issue: stage.replace в Alloy заменяет содержимое захват-групп, а не весь матч (доказано рабочей стадией #3114 — группа на пароле, user@host уцелевает). С ([?&]…=)[^&\s"]+ → ${1}*** имя параметра затёрлось бы литералом, а секрет остался. Поэтому группа на ЗНАЧЕНИИ, replace = "***". Множество имён посимвольно равно _SENSITIVE_QUERY приложения.

Валидация

Локального alloy и docker-демона нет; сделано: построчная сверка с соседней стадией + скрипт, распарсивший строковые литералы файла как JSON (проверка экранировки \\w), скомпилировавший regex и прогнавший семантику замены групп: uvicorn-строка → secret=***; 6 параметров в одной строке (вкл. client_secret, refresh_token, Access_Token) маскируются; ?design=/signal_id/tokenize — нет; DSN после #3114 не портится. alloy fmt/validate на хосте — при приёмке.

Деплой и ловушка

deploy-metrics.yml (триггер ops/metrics/**) → agent-apps (Poincare) и agent-infra (Beget). Конфиг — bind-mount → up -d считает сервис неизменившимся, Alloy сам не перечитывает: после деплоя нужен docker restart gendesign-alloy на каждом хосте (или /-/reload). Проверка доезда: docker exec gendesign-alloy grep -c apikey /etc/alloy/config.alloy (в старой версии 0).

Живая приёмка (сделаю)

Проба процессом БЕЗ фильтра приложения (logger 'test?secret=zzz-probe-3354' на хосте → journald → только новая стадия) → в Loki строка с secret=*** и без zzz-probe-3354. Отрицательный результат без положительного маркера не засчитывается.

Closes #3354 (follow-up #3154/#3353). Приложение маскирует query-секреты само (`log_scrub.py`), но строки от других процессов (journald, сайдкар, GlitchTip/Forgejo с `?token=`) шли в Loki как есть. ## Что сделано `loki.process "scrub_credentials"` — вторая `stage.replace` (#3115 не тронут) в **обоих** конфигах (`alloy-apps.alloy`, `alloy-infra.alloy` — Forgejo/GlitchTip пишут в тот же Loki): ``` (?i)[?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=([^&\s"'<>]+) → *** ``` **Поправка к постановке issue:** `stage.replace` в Alloy заменяет содержимое захват-групп, а не весь матч (доказано рабочей стадией #3114 — группа на пароле, `user@host` уцелевает). С `([?&]…=)[^&\s"]+ → ${1}***` имя параметра затёрлось бы литералом, а секрет остался. Поэтому группа на ЗНАЧЕНИИ, `replace = "***"`. Множество имён посимвольно равно `_SENSITIVE_QUERY` приложения. ## Валидация Локального `alloy` и docker-демона нет; сделано: построчная сверка с соседней стадией + скрипт, распарсивший строковые литералы файла как JSON (проверка экранировки `\\w`), скомпилировавший regex и прогнавший семантику замены групп: uvicorn-строка → `secret=***`; 6 параметров в одной строке (вкл. `client_secret`, `refresh_token`, `Access_Token`) маскируются; `?design=`/`signal_id`/`tokenize` — нет; DSN после #3114 не портится. **`alloy fmt`/`validate` на хосте — при приёмке.** ## Деплой и ловушка `deploy-metrics.yml` (триггер `ops/metrics/**`) → `agent-apps` (Poincare) и `agent-infra` (Beget). Конфиг — bind-mount → `up -d` считает сервис неизменившимся, Alloy сам не перечитывает: после деплоя нужен `docker restart gendesign-alloy` на каждом хосте (или `/-/reload`). Проверка доезда: `docker exec gendesign-alloy grep -c apikey /etc/alloy/config.alloy` (в старой версии 0). ## Живая приёмка (сделаю) Проба процессом БЕЗ фильтра приложения (`logger 'test?secret=zzz-probe-3354'` на хосте → journald → только новая стадия) → в Loki строка с `secret=***` и без `zzz-probe-3354`. Отрицательный результат без положительного маркера не засчитывается.
bot-backend added 1 commit 2026-09-05 20:50:07 +00:00
fix(metrics): маскировать секреты из query-строки в Alloy до отправки в Loki
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
ed71292f03
Скруббер #3115 знал одну форму — пароль в DSN (scheme://user:pass@host).
Секрет в query-строке (`?secret=<64 hex>` вебхука GlitchTip, #3154) проходил
насквозь и оседал в Loki на 30 суток ретенции.

Приложение чистит это у себя (#3353), но фильтр стоит на логгере ОДНОГО
процесса. Второй слой на сборщике закрывает всё, что придёт мимо: sidecar,
чужой процесс, будущий логгер без фильтра. Множество имён параметров взято
из log_scrub.py дословно, включая суффиксные client_secret/refresh_token.

Группа захвата стоит на значении, а не на имени параметра: Alloy заменяет
содержимое ГРУПП, а не весь совпавший фрагмент. Группа вокруг `?secret=`
затёрла бы имя и оставила сам секрет — то есть ровно наоборот.

Стадия добавлена и в alloy-infra.alloy: на инфра-хосте Forgejo и GlitchTip
с их `?token=` в адресах пишут в тот же Loki, дефект там тот же.

Closes #3354
Author
Collaborator

Валидация, которую воркер не мог сделать локально, выполнена на живом бинаре прода: файл ветки alloy-apps.alloy скопирован в контейнер gendesign-alloy (Alloy v1.6.1) → alloy fmt /tmp/cfg.alloyOK, синтаксис River валиден. Различающий маркер apikey: 1 в файле ветки, 0 в текущем /etc/alloy/config.alloy (проба увидит доезд). CI зелёный; мерж — по вердикту ревью. После деплоя deploy-metrics.yml: docker restart gendesign-alloy на обоих хостах (bind-mount, up -d не перечитает) + живая проба logger 'test?secret=zzz-probe-3354' → Loki.

Валидация, которую воркер не мог сделать локально, выполнена на живом бинаре прода: файл ветки `alloy-apps.alloy` скопирован в контейнер `gendesign-alloy` (Alloy **v1.6.1**) → `alloy fmt /tmp/cfg.alloy` — **OK, синтаксис River валиден**. Различающий маркер `apikey`: 1 в файле ветки, 0 в текущем `/etc/alloy/config.alloy` (проба увидит доезд). CI зелёный; мерж — по вердикту ревью. После деплоя `deploy-metrics.yml`: `docker restart gendesign-alloy` на обоих хостах (bind-mount, `up -d` не перечитает) + живая проба `logger 'test?secret=zzz-probe-3354'` → Loki.
bot-backend merged commit 1cff12cc71 into main 2026-09-05 20:53:43 +00:00
Author
Collaborator

Приёмка #3377 на проде (2026-09-06, оба хоста) — по значению.

Конфиг доехал через deploy-metrics (server / agent-infra / agent-apps — success на 1cff12cc), но на Beget контейнер gendesign-alloy продолжал читать СТАРЫЙ файл: bind-mount одного файла держит inode, а git checkout в деплое пишет новый — stat в контейнере показывал inode 572205 (mtime 26.08) против 551819 на хосте, grep -c apikey = 0. docker restart gendesign-alloy перепривязал путь → inode 551819, apikey=1. На Poincare контейнер файл видел, рестарт сделан для перечитывания.

Живая проба рабочим трактом (logger на хосте → journald → Alloy → Loki на Beget), без фильтра приложения:

  • Poincare ({host="apps"}): gendesign-probe GET /x?secret=***&token=*** HTTP/1.1zzz-probe-3354/zzz-tok-3354 в Loki отсутствуют → MASKED.
  • Beget ({host="infra"}): gendesign-probe-infra GET /y?api_key=*** HTTP/1.1zzz-infra-3354 отсутствует → MASKED.

alloy fmt на прод-бинаре v1.6.1 — OK, оба контейнера healthy после рестарта.

Ловушка на будущее: docker compose up -d в deploy-metrics.yml (agent-шаг) не перечитывает одно-файловый bind-mount после смены inode — нужен --force-recreate alloy или docker restart после up. Заведу отдельный issue.

**Приёмка #3377 на проде (2026-09-06, оба хоста) — ✅ по значению.** Конфиг доехал через `deploy-metrics` (server / agent-infra / agent-apps — success на `1cff12cc`), но **на Beget контейнер `gendesign-alloy` продолжал читать СТАРЫЙ файл**: bind-mount одного файла держит inode, а `git checkout` в деплое пишет новый — `stat` в контейнере показывал inode 572205 (mtime 26.08) против 551819 на хосте, `grep -c apikey` = 0. `docker restart gendesign-alloy` перепривязал путь → inode 551819, apikey=1. На Poincare контейнер файл видел, рестарт сделан для перечитывания. Живая проба **рабочим трактом** (`logger` на хосте → journald → Alloy → Loki на Beget), без фильтра приложения: - Poincare (`{host="apps"}`): `gendesign-probe GET /x?secret=***&token=*** HTTP/1.1` — `zzz-probe-3354`/`zzz-tok-3354` в Loki отсутствуют → **MASKED**. - Beget (`{host="infra"}`): `gendesign-probe-infra GET /y?api_key=*** HTTP/1.1` — `zzz-infra-3354` отсутствует → **MASKED**. `alloy fmt` на прод-бинаре v1.6.1 — OK, оба контейнера `healthy` после рестарта. Ловушка на будущее: `docker compose up -d` в `deploy-metrics.yml` (agent-шаг) **не перечитывает** одно-файловый bind-mount после смены inode — нужен `--force-recreate alloy` или `docker restart` после `up`. Заведу отдельный issue.
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#3377
No description provided.