alert-ack: значение секрета вебхука GlitchTip больше не пишется в лог — строка доступа и ошибки разбора дают secret=*** #3588

Merged
bot-backend merged 1 commit from fix/3576-alert-ack-redact-secret into main 2026-09-17 13:15:02 +00:00
Collaborator

Что изменено

ops/metrics/alert-ack/app.py: Handler.log_message маскирует значения секретных query-параметров (secret, token, *_secret, *_token, api_key, password, sig… — то же выражение, что у бэкенда МЕРЫ в tradein-mvp/backend/app/core/log_scrub.py из #3154 и у Alloy из #3354; совпадение проверено побайтно). Импортировать выражение нельзя: сервис намеренно без зависимостей, в контейнер монтируется один app.py.

Почему именно log_message: в BaseHTTPRequestHandler строка запроса попадает в лог двумя путями, и оба проходят через этот метод:

  • строка доступа: log_request'"%s" %s %s' % (requestline, code, size);
  • ошибка разбора запроса: log_errorcode 400, message Bad request syntax ('POST /glitchtip?secret=… junk HTTP/1.1'), а следом та же строка ещё раз в строке доступа с кодом 400.

Свои записи сервиса (glitchtip webhook: invalid or missing secret и т.п.) ни пути, ни значения не содержат.

Улики (значение секрета нигде не приводится)

  • Код: log.info("%s %s", self.address_string(), fmt % args), строка запроса выводится целиком. Формат со значением в docker logs зафиксирован в самой задаче (09:35 17.09).

  • Beget, только чтение: контейнер пересоздан деплоем в 2026-09-17T12:50Z, в его docker logs строк с secret= пока 0 (GlitchTip с тех пор не присылал). Лог-драйвер journald, но журнал прежних контейнеров пользователю деплоя недоступен (не в группе adm), поэтому там не проверял.

  • Loki. Утверждение задачи «секрет лежит и в Loki» не подтвердилось. Запросы query_range за 29 суток, выполнены внутри gendesign-loki:

    • {container=~".*alert-ack.*"} |~ "(?i)secret=": 106 строк, из них 106 с secret=***, значения нет ни в одной. Период 12.09–17.09, то есть с появления /glitchtip;
    • {host="infra"} |~ "(?i)[?&][a-z_.-]*secret=[^*]": 0 строк по всему инфра-хосту, включая Caddy.

    Значение маскирует стадия stage.replace в alloy-infra.alloy (#3354) до отправки. Открытым текстом значение лежит только локально на Beget: в docker logs и в journald. Эта правка закрывает первый слой, в самом процессе, как и #3154 для бэкенда МЕРЫ. По пункту 3 задачи: за срок хранения Loki чистить нечего.

Тесты

Тест добавлен в backend/tests/ops/test_3078_alert_ack.py: test_sekret_iz_query_ne_popadaet_v_log, три параметра. Сервер поднимается на настоящем сокете, записи лога ловит caplog. Проверки по значению: выдуманного значения нет ни в одной записи, а …=*** стоит в ожидаемом числе записей (1 / 1 / 2). Второе условие не даёт тесту позеленеть, если лог просто замолчит.

  1. POST /glitchtip?secret=<проба>: строка доступа;
  2. GET /ack/x?a=1&access_token=<проба>&b=2: имя с префиксом, соседние параметры сохраняются;
  3. POST /glitchtip?secret=<проба> junk HTTP/1.1: ошибка разбора, две записи.

Локально (Python 3.12, venv бэкенда):

  • pytest tests/ops/test_3078_alert_ack.py: 12 passed, rc=0;
  • pytest tests/ops/: 145 passed, 4 failed, rc=1. Все 4 падения в test_2203_backup_trailer_grep_dashdash.py (mktemp: unrecognized option --suffix — на macOS нет GNU mktemp). Эти тесты не касаются изменённых файлов. Гейт доезда test_metrics_single_file_mounts.py из #3557 зелёный;
  • ops/metrics/alert-ack/test_app.py: 13 passed, rc=0;
  • ruff check и ruff format тестового файла: чисто. Pre-commit (ruff, ruff format, detect private key) пройден.

CI. Правка ops/** включает backend=true в ci.yml, и backend-tests гоняет backend/tests/ops/, так что новый тест в CI исполняется. При этом ops/metrics/alert-ack/test_app.py не запускает ни одна джоба: у бэкенда testpaths = ["tests"], а ci-tradein.yml эти пути не собирает. Там 13 тестов, в том числе keep-alive-тесты с прода от 12.09. Дыра существовала до этого PR и здесь не чинится, поэтому новый тест положен в CI-исполняемый файл.

Фальсификация (без stash: копия файла → порча → возврат копии → cmp)

  1. В log_message снята только маскировка (fmt % args без sub): 3 failed, rc=1. Каждый параметр красный с AssertionError: значение секрета в логе, в том числе на записи ошибки разбора code 400, message Bad request syntax (...).
  2. Тело log_message заменено на pass, лог молчит: 3 failed, rc=1 (срабатывает проверка числа записей с =***).

После возврата cmp показал совпадение с исправленной версией, прогон: 12 passed, rc=0.

Как доезжает

deploy-metrics.yml запускается по ops/metrics/**. В джобе server при профиле alerts безусловно выполняется up -d --force-recreate alert-ack (#3490). Гейт test_metrics_single_file_mounts.py из #3557 считает это путём доезда для пофайлового маунта ./ops/metrics/alert-ack/app.py:/app/app.py:ro и сейчас зелёный. На проде маунт /opt/gendesign/ops/metrics/alert-ack/app.py → /app/app.py.

Приёмка после деплоя (Beget)

  1. Код в контейнере, а не на диске. Маркер, которого нет в старой версии: docker exec gendesign-alert-ack grep -c _SENSITIVE_QUERY /app/app.py → ≥ 1. Инод: stat -c %i /opt/gendesign/ops/metrics/alert-ack/app.py равен docker exec gendesign-alert-ack stat -c %i /app/app.py.

  2. Тестовый запрос с выдуманным значением, не с настоящим секретом: curl -s -o /dev/null -w '%{http_code}\n' -X POST 'https://metrics.gendsgn.ru/glitchtip?secret=probe3576x' -H 'Content-Type: application/json' -d '{}'401. Затем:

    • docker logs --since 10m gendesign-alert-ack 2>&1 | grep -c probe3576x0;
    • docker logs --since 10m gendesign-alert-ack 2>&1 | grep -c 'glitchtip?secret=\*\*\*'≥ 1.

    Проверка различает версии: на старом коде первая команда дала бы ≥ 1.

  3. При следующем настоящем алерте GlitchTip в docker logs появится "POST /glitchtip?secret=*** HTTP/1.1" 200 -.

Ротация секрета — за владельцем

В этом PR не делается. Значение уже осело в docker logs/journald прежних контейнеров на Beget, а PR историю не чистит. Менять нужно одновременно ALERT_ACK_GLITCHTIP_SECRET в env alert-ack и URL получателя GlitchTip (AlertRecipient id=6). Пункт приёмки задачи «старый секрет → 401, новый принимается» проверяется только после ротации, поэтому здесь Refs, а не Closes.

Вне объёма

  • Токен кнопки /ack/<token> стоит в пути, а не в query. Он пишется в лог как есть и не маскируется ни здесь, ни в Alloy, то есть лежит в Loki. Ссылка одноразовая и живёт 24 часа, но с доступом к Grafana можно нажать «принято» за дежурного. Не трогал; если это важно, нужна отдельная задача.
  • Имя параметра в percent-кодировке (secr%65t=) выражение не ловит, хотя parse_qs его декодирует. Отправить такое может только тот, кто уже знает секрет, поэтому оставлено.

Refs #3576

🤖 Generated with Claude Code

## Что изменено `ops/metrics/alert-ack/app.py`: `Handler.log_message` маскирует значения секретных query-параметров (`secret`, `token`, `*_secret`, `*_token`, `api_key`, `password`, `sig`… — то же выражение, что у бэкенда МЕРЫ в `tradein-mvp/backend/app/core/log_scrub.py` из #3154 и у Alloy из #3354; совпадение проверено побайтно). Импортировать выражение нельзя: сервис намеренно без зависимостей, в контейнер монтируется один `app.py`. Почему именно `log_message`: в `BaseHTTPRequestHandler` строка запроса попадает в лог двумя путями, и оба проходят через этот метод: - строка доступа: `log_request` → `'"%s" %s %s' % (requestline, code, size)`; - ошибка разбора запроса: `log_error` → `code 400, message Bad request syntax ('POST /glitchtip?secret=… junk HTTP/1.1')`, а следом та же строка ещё раз в строке доступа с кодом 400. Свои записи сервиса (`glitchtip webhook: invalid or missing secret` и т.п.) ни пути, ни значения не содержат. ## Улики (значение секрета нигде не приводится) - Код: `log.info("%s %s", self.address_string(), fmt % args)`, строка запроса выводится целиком. Формат со значением в `docker logs` зафиксирован в самой задаче (09:35 17.09). - Beget, только чтение: контейнер пересоздан деплоем в 2026-09-17T12:50Z, в его `docker logs` строк с `secret=` пока 0 (GlitchTip с тех пор не присылал). Лог-драйвер `journald`, но журнал прежних контейнеров пользователю деплоя недоступен (не в группе `adm`), поэтому там не проверял. - **Loki. Утверждение задачи «секрет лежит и в Loki» не подтвердилось.** Запросы `query_range` за 29 суток, выполнены внутри `gendesign-loki`: - `{container=~".*alert-ack.*"} |~ "(?i)secret="`: 106 строк, из них 106 с `secret=***`, значения нет ни в одной. Период 12.09–17.09, то есть с появления `/glitchtip`; - `{host="infra"} |~ "(?i)[?&][a-z_.-]*secret=[^*]"`: 0 строк по всему инфра-хосту, включая Caddy. Значение маскирует стадия `stage.replace` в `alloy-infra.alloy` (#3354) до отправки. Открытым текстом значение лежит только локально на Beget: в `docker logs` и в journald. Эта правка закрывает первый слой, в самом процессе, как и #3154 для бэкенда МЕРЫ. По пункту 3 задачи: за срок хранения Loki чистить нечего. ## Тесты Тест добавлен в `backend/tests/ops/test_3078_alert_ack.py`: `test_sekret_iz_query_ne_popadaet_v_log`, три параметра. Сервер поднимается на настоящем сокете, записи лога ловит `caplog`. Проверки по значению: выдуманного значения нет ни в одной записи, а `…=***` стоит в ожидаемом числе записей (1 / 1 / 2). Второе условие не даёт тесту позеленеть, если лог просто замолчит. 1. `POST /glitchtip?secret=<проба>`: строка доступа; 2. `GET /ack/x?a=1&access_token=<проба>&b=2`: имя с префиксом, соседние параметры сохраняются; 3. `POST /glitchtip?secret=<проба> junk HTTP/1.1`: ошибка разбора, две записи. Локально (Python 3.12, venv бэкенда): - `pytest tests/ops/test_3078_alert_ack.py`: **12 passed, rc=0**; - `pytest tests/ops/`: 145 passed, 4 failed, rc=1. Все 4 падения в `test_2203_backup_trailer_grep_dashdash.py` (`mktemp: unrecognized option --suffix` — на macOS нет GNU mktemp). Эти тесты не касаются изменённых файлов. Гейт доезда `test_metrics_single_file_mounts.py` из #3557 зелёный; - `ops/metrics/alert-ack/test_app.py`: **13 passed, rc=0**; - `ruff check` и `ruff format` тестового файла: чисто. Pre-commit (ruff, ruff format, detect private key) пройден. **CI.** Правка `ops/**` включает `backend=true` в `ci.yml`, и `backend-tests` гоняет `backend/tests/ops/`, так что новый тест в CI исполняется. При этом **`ops/metrics/alert-ack/test_app.py` не запускает ни одна джоба**: у бэкенда `testpaths = ["tests"]`, а `ci-tradein.yml` эти пути не собирает. Там 13 тестов, в том числе keep-alive-тесты с прода от 12.09. Дыра существовала до этого PR и здесь не чинится, поэтому новый тест положен в CI-исполняемый файл. ## Фальсификация (без stash: копия файла → порча → возврат копии → `cmp`) 1. В `log_message` снята только маскировка (`fmt % args` без `sub`): **3 failed, rc=1**. Каждый параметр красный с `AssertionError: значение секрета в логе`, в том числе на записи ошибки разбора `code 400, message Bad request syntax (...)`. 2. Тело `log_message` заменено на `pass`, лог молчит: **3 failed, rc=1** (срабатывает проверка числа записей с `=***`). После возврата `cmp` показал совпадение с исправленной версией, прогон: 12 passed, rc=0. ## Как доезжает `deploy-metrics.yml` запускается по `ops/metrics/**`. В джобе `server` при профиле `alerts` безусловно выполняется `up -d --force-recreate alert-ack` (#3490). Гейт `test_metrics_single_file_mounts.py` из #3557 считает это путём доезда для пофайлового маунта `./ops/metrics/alert-ack/app.py:/app/app.py:ro` и сейчас зелёный. На проде маунт `/opt/gendesign/ops/metrics/alert-ack/app.py → /app/app.py`. ## Приёмка после деплоя (Beget) 1. Код в контейнере, а не на диске. Маркер, которого нет в старой версии: `docker exec gendesign-alert-ack grep -c _SENSITIVE_QUERY /app/app.py` → ≥ 1. Инод: `stat -c %i /opt/gendesign/ops/metrics/alert-ack/app.py` равен `docker exec gendesign-alert-ack stat -c %i /app/app.py`. 2. Тестовый запрос с **выдуманным** значением, не с настоящим секретом: `curl -s -o /dev/null -w '%{http_code}\n' -X POST 'https://metrics.gendsgn.ru/glitchtip?secret=probe3576x' -H 'Content-Type: application/json' -d '{}'` → `401`. Затем: - `docker logs --since 10m gendesign-alert-ack 2>&1 | grep -c probe3576x` → **0**; - `docker logs --since 10m gendesign-alert-ack 2>&1 | grep -c 'glitchtip?secret=\*\*\*'` → **≥ 1**. Проверка различает версии: на старом коде первая команда дала бы ≥ 1. 3. При следующем настоящем алерте GlitchTip в `docker logs` появится `"POST /glitchtip?secret=*** HTTP/1.1" 200 -`. ## Ротация секрета — за владельцем В этом PR не делается. Значение уже осело в `docker logs`/journald прежних контейнеров на Beget, а PR историю не чистит. Менять нужно одновременно `ALERT_ACK_GLITCHTIP_SECRET` в env alert-ack и URL получателя GlitchTip (`AlertRecipient` id=6). Пункт приёмки задачи «старый секрет → 401, новый принимается» проверяется только после ротации, поэтому здесь `Refs`, а не `Closes`. ## Вне объёма - Токен кнопки `/ack/<token>` стоит в **пути**, а не в query. Он пишется в лог как есть и не маскируется ни здесь, ни в Alloy, то есть лежит в Loki. Ссылка одноразовая и живёт 24 часа, но с доступом к Grafana можно нажать «принято» за дежурного. Не трогал; если это важно, нужна отдельная задача. - Имя параметра в percent-кодировке (`secr%65t=`) выражение не ловит, хотя `parse_qs` его декодирует. Отправить такое может только тот, кто уже знает секрет, поэтому оставлено. Refs #3576 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 1 commit 2026-09-17 13:06:27 +00:00
alert-ack: значение секрета из query больше не пишется в лог (#3576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 23s
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 2m57s
CI / backend-tests (pull_request) Successful in 7m35s
8b9b541736
GlitchTip шлёт секрет резервного вебхука только в `?secret=`, а
BaseHTTPRequestHandler печатает строку запроса целиком: в строке доступа
(log_request) и в тексте ошибки разбора (log_error, «Bad request syntax
('POST /glitchtip?secret=…')»). Оба пути сходятся в log_message — маскируем
там одним выражением, тем же, что у бэкенда МЕРЫ (#3154, log_scrub.py) и у
Alloy (#3354). Импортировать его нельзя: сервис намеренно без зависимостей.

Тест в backend/tests/ops/test_3078_alert_ack.py (его гоняет CI по ops/**):
настоящий сокет, три строки запроса — доступ, имя с префиксом, ошибка
разбора; значения в записях нет, `=***` стоит в ожидаемом числе записей.

Ротация секрета — за владельцем, здесь не делается.

Refs #3576

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Collaborator

Состояние до деплоя (17.09, Beget, только чтение), чтобы было с чем сравнивать приёмку:

  • docker exec gendesign-alert-ack grep -c _SENSITIVE_QUERY /app/app.py0. Маркера в работающем коде нет, значит пункт 1 приёмки различает версии;
  • инод хоста и контейнера совпадает: 585871 = 585871;
  • grep и stat в образе python:3.12-slim есть, команды приёмки исполнимы как написаны.
Состояние до деплоя (17.09, Beget, только чтение), чтобы было с чем сравнивать приёмку: - `docker exec gendesign-alert-ack grep -c _SENSITIVE_QUERY /app/app.py` → `0`. Маркера в работающем коде нет, значит пункт 1 приёмки различает версии; - инод хоста и контейнера совпадает: `585871` = `585871`; - `grep` и `stat` в образе `python:3.12-slim` есть, команды приёмки исполнимы как написаны.
bot-backend merged commit a82239b931 into main 2026-09-17 13:15:02 +00:00
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#3588
No description provided.