All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m38s
Ревью-minor: якорь [?&] вплотную к имени пропускал client_secret=, refresh_token=, auth_token=, webhook_secret=. Разрешаем префикс [\w.-]* перед альтернацией — маскируем имена, ОКАНЧИВАЮЩИЕСЯ на чувствительное слово. Кейс not_a_secret_name заменён на честные отрицательные: ?secretary= и ?tokens_page= (не оканчиваются на secret/token) плюс /api/v1/token-info в пути. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
72 lines
3.7 KiB
Python
72 lines
3.7 KiB
Python
"""Секреты из query-строки не попадают в лог процесса (#3154).
|
||
|
||
Прод-факт: uvicorn пишет в access-log ПОЛНЫЙ путь вместе с query, а лог уезжает
|
||
в Loki (ретенция 30 суток, доступ по входу в Grafana):
|
||
|
||
INFO: 172.18.0.3:60322 - "POST /api/v1/trade-in/ops/glitchtip-webhook
|
||
?secret=<64 hex> HTTP/1.1" 200 OK
|
||
|
||
Скруббер в Alloy (#3115) это не ловит: там одно выражение под форму
|
||
``scheme://user:pass@host`` (DSN postgres_exporter, #3114). Чиним в СВОЁМ
|
||
процессе — тогда секрета нет и в `docker logs`, до отправки куда-либо.
|
||
|
||
Фильтр вешается на логгер (`logging.Filter`), а не на форматтер: uvicorn.access
|
||
кладёт путь в ``record.args``, до форматирования он уже там. Поэтому берём
|
||
``record.getMessage()`` и, если что-то замаскировали, подменяем msg/args.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
|
||
# Имена параметров, значение которых маскируем: имя ОКАНЧИВАЕТСЯ на чувствительное
|
||
# слово, поэтому перед альтернацией допускаем префикс (`client_secret`,
|
||
# `refresh_token`, `webhook_secret`). Значение — до следующего `&`, пробела или
|
||
# кавычки (access-строка uvicorn обрамляет запрос кавычками).
|
||
_SENSITIVE_QUERY = re.compile(
|
||
r"([?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=)"
|
||
r"[^&\s\"'<>]+",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def scrub_query_secrets(text: str) -> str:
|
||
"""Заменяет значения чувствительных query-параметров на ``***``.
|
||
|
||
Имя параметра и остальная строка сохраняются — иначе access-лог перестал бы
|
||
годиться для диагностики.
|
||
"""
|
||
return _SENSITIVE_QUERY.sub(r"\1***", text)
|
||
|
||
|
||
class QuerySecretFilter(logging.Filter):
|
||
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан."""
|
||
|
||
def filter(self, record: logging.LogRecord) -> bool:
|
||
message = record.getMessage()
|
||
scrubbed = scrub_query_secrets(message)
|
||
if scrubbed != message:
|
||
record.msg = scrubbed
|
||
record.args = ()
|
||
return True
|
||
|
||
|
||
def install_query_secret_filter(*logger_names: str) -> None:
|
||
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.
|
||
|
||
Двумя местами, потому что uvicorn в своём log-config ставит `uvicorn.access`
|
||
собственный handler с ``propagate = False`` — до корневого его записи не
|
||
доходят. А фильтр на handler'ах корня закрывает всё остальное приложение
|
||
(записи дочерних логгеров фильтры родителя не проходят, фильтры handler'а —
|
||
проходят).
|
||
|
||
Идемпотентно: повторный вызов не наплодит дублей.
|
||
"""
|
||
targets: list[logging.Logger | logging.Handler] = [
|
||
logging.getLogger(name) for name in logger_names or ("uvicorn.access",)
|
||
]
|
||
targets.extend(logging.getLogger().handlers)
|
||
for target in targets:
|
||
if not any(isinstance(f, QuerySecretFilter) for f in target.filters):
|
||
target.addFilter(QuerySecretFilter())
|