Настройки попали в журнал: что скрывает SecretStr в Pydantic

Для расследования сбоя разработчик записал объект конфигурации в журнал. В нём оказались адрес сервиса и токен доступа. Удобная диагностическая строка стала ещё одним местом хранения credential. Если поле действительно содержит секрет, полезно выразить это в модели и сделать обычное представление безопаснее по умолчанию.

Секрет остаётся доступным только явным действием

SecretStr хранит строку, но в repr и JSON показывает маску. Рабочее значение извлекается через get_secret_value в том месте, где строится запрос к внешнему сервису. Такой вызов хорошо виден при чтении кода. В отличие от обычного поля str, диагностическое представление всей модели не печатает токен автоматически.

Это не шифрованное хранилище: процессу всё равно требуется исходное значение для обращения к сервису. Задача SecretStr — уменьшить случайное раскрытие через стандартное представление модели. После извлечения секрета обычная строка снова требует аккуратного обращения. Поэтому функция клиента должна использовать её в заголовке запроса, а не возвращать в общий словарь диагностики.

Проверяем не только вид маски

Понадобится Pydantic v2. Возьмём узнаваемый выдуманный маркер. Проверим его отсутствие в repr и JSON, наличие исходного значения только при явном извлечении и отказ для неподходящего типа. Реальных запросов к сети пример не выполняет.

import json
from pydantic import BaseModel, SecretStr, ValidationError

class ServiceConfig(BaseModel):
    endpoint: str
    token: SecretStr

marker = "demo-sensitive-marker-77"
config = ServiceConfig(endpoint="https://api.example.test", token=marker)
diagnostic = repr(config)
wire = config.model_dump_json()
assert marker not in diagnostic
assert marker not in wire
assert json.loads(wire)["token"] == "**********"
assert isinstance(config.model_dump()["token"], SecretStr)

def credentials(value: ServiceConfig) -> dict[str, str]:
    return {"Authorization": "Bearer " + value.token.get_secret_value()}

assert credentials(config)["Authorization"] == "Bearer " + marker
try:
    ServiceConfig(endpoint="https://api.example.test", token={"value": marker})
except ValidationError:
    pass
else:
    raise AssertionError("object token accepted")
print({"diagnostic": diagnostic, "json": json.loads(wire)})

Где маска может исчезнуть

Пользовательский field_serializer способен вернуть get_secret_value и снова раскрыть строку в JSON. Проверьте сериализаторы модели настроек и её базовых классов. Особенно опасен общий helper, который рекурсивно превращает все специальные типы в строки для журнала. Тест на отсутствие маркера должен выполняться для того метода экспорта, который реально использует приложение.

Также посмотрите журналирование исходных переменных окружения и HTTP-заголовков. Эти пути могут обходить модель целиком. Сохранение repr(config) с маской не исправит отдельный middleware, записывающий Authorization. Для выбранного логгера отправьте маркер в тестовое событие и проверьте конечную строку после форматирования и фильтров.

В HTTP-ответе лучше вообще не иметь поля токена, если клиенту оно не нужно. Отдельная публичная модель делает это очевидным. Замаскированное поле полезно для внутренней диагностики, но его присутствие во внешней схеме может вводить пользователей в заблуждение: они начнут сохранять маску как настоящее значение при редактировании настроек.

Не выполняйте round-trip конфигурации через замаскированный JSON в надежде восстановить credential. После такого экспорта исходный токен заменён строкой со звёздочками. Для сохранения настроек используйте выбранное секретное хранилище и разделяйте поля редактирования с диагностическим представлением. В тестах отдельно проверьте отсутствие замены действующего токена маской.

Для публичного HTTP-ответа механизм ограничения полей разобран в статье о response_model. Здесь рассматривается другой источник утечки: печать внутренней конфигурации до формирования внешнего ответа.

Продолжить на практике

Настройте логгер и проверьте итоговые диагностические строки выдуманным маркером. В продвинутом Python есть конфигурация приложения, логирование и тесты с подменой зависимостей: это полезное продолжение для аккуратного обращения с настройками. Откройте программу продвинутого Python.

Продолжить на практике

  • Python для продвинутых: ООП, типизация и тестирование ↗

    Практический курс-мост от базового Python к разработке поддерживаемых приложений. Вы разберётесь со структурой пакетов, объектной моделью, современной типизацией и автоматическими тестами, а затем соедините эти инструменты в небольшом приложении с хранением данных и логированием.