Learning Platform
Урок 08.07 · 20 мин
Начальный
SecretsSecuritypydantic-settingsTLSConnection pool
Почему секреты в Git — это катастрофа Observability: почему нельзя логировать credentials

Если оно работает — это не значит, что оно безопасно

В прошлых шести уроках мы писали Authorization: Bearer ghp_xxx и postgresql://etl:secret@host/db прямо в коде. Это нормально для обучения. В production-коде это первая причина утечек данных и взломов.

Этот урок — про то, где хранить токены/пароли, как их прокинуть в код, как не закоммитить их в git и как защитить connection до Postgres от перехвата.

К концу урока вы должны без подсказки сказать:

  • почему password = "secret123" в коде — это плохо;
  • почему .env идёт в .gitignore, но .env.example — нет;
  • почему sslmode=require это не то же самое, что sslmode=verify-full;
  • что делать, когда обнаружили, что закоммитили токен в репо вчера.

Антипаттерны (увы, повсеместные)

Антипаттерн 1: хардкод credentials в коде.

# config.py — попадает в git
DATABASE_URL = "postgresql://etl_user:[email protected]/etl"
GITHUB_TOKEN = "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789"

Что произойдёт:

  1. Завтра кто-то форкнет приватный репо → токены у форкнувшего.
  2. Через год репо мигрируют open-source → токены в публичном гитхабе.
  3. CI-логи покажут URL при ошибке → токены в Slack у всех, кто видит CI.
  4. Bug-bounty охотники гуглят ghp_ по гитхабу → токен скомпрометирован через 5 минут после коммита.

GitHub постоянно сканирует публичные коммиты на known token patterns и автоматически отзывает токены — но это не помогает с private repos, и за время «до отзыва» атакующий уже сделал, что хотел.

Антипаттерн 2: credentials в командной строке.

python run.py --db-password=Sup3rSecret

Это попадает в ps, в history, в логи auditd — кто-то на машине прочтёт.

Антипаттерн 3: открытое .env, забытое в коммите.

git status
# Untracked files:
#   .env    ← ВОТ ОНО

git add .
git commit -m "fix bug"
# токены закоммитились

.env должен быть в .gitignore до того, как там появятся секреты. Не наоборот.

Правильный подход: переменные окружения + pydantic-settings

Стандартная схема для DE 2026 года:

  1. Локально — секреты в .env файле (не в git).
  2. В CI/CD — секреты как зашифрованные переменные пайплайна (GitHub Secrets, GitLab CI/CD Variables).
  3. В production — секреты из менеджера секретов (Vault, AWS Secrets Manager, GCP Secret Manager) ИЛИ как env-переменные в Kubernetes Secret.
  4. В кодеpydantic-settings читает env-переменные, валидирует, возвращает типизированный объект.

Это мы уже видели в уроке 04 модуля 2. Здесь применим к нашему модулю.

# src/myetl/settings.py
from pydantic import Field, SecretStr, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="forbid",
    )

    # GitHub API
    github_token: SecretStr = Field(..., description="GitHub Personal Access Token")
    github_user_agent: str = Field("my-etl/1.0")

    # PostgreSQL
    database_url: PostgresDsn = Field(..., description="postgresql+psycopg://user:pass@host:port/db")
    database_pool_size: int = Field(5, ge=1, le=50)
    database_ssl_mode: str = Field("verify-full")

    # Logging
    log_level: str = Field("INFO")


# Один на процесс
settings = Settings()

И .env рядом с проектом (в .gitignore!):

GITHUB_TOKEN=ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789
DATABASE_URL=postgresql+psycopg://etl_user:secret@localhost:5432/etl_db
DATABASE_POOL_SIZE=10

И обязательно.env.example, который в git идёт (без значений секретов):

GITHUB_TOKEN=
DATABASE_URL=postgresql+psycopg://USER:PASSWORD@HOST:PORT/DBNAME
DATABASE_POOL_SIZE=5

Это работает как документация: новый разработчик клонирует репо, копирует .env.example.env, заполняет своими значениями. Никакого «а где у нас хранятся переменные?».

SecretStr: чтобы случайно не залогировать

SecretStr от pydantic — обёртка над строкой, которая не показывает значение при логировании/repr:

from pydantic import SecretStr

token = SecretStr("ghp_xxx")
print(token)              # SecretStr('**********')
print(repr(token))        # SecretStr('**********')
print(token.get_secret_value())   # ghp_xxx — явный getter

Это спасает от классической ошибки:

log.info("config loaded", config=settings)
# Без SecretStr: токен попал в JSON-лог, в Loki, в Slack-канал alerts.
# С SecretStr: значение скрыто.

В DE-коде все креды должны быть SecretStr. Это микроразница в типе, которая дисциплинирует не лить пароли в лог.

Loud failure: лучше упасть на старте, чем работать впустую

pydantic-settings валидирует на инициализации. Если обязательного поля нет — ValidationError и crash на старте, не где-то в середине пайплайна:

class Settings(BaseSettings):
    github_token: SecretStr = Field(...)    # ... означает "обязательно"
    database_url: PostgresDsn = Field(...)

PostgresDsn — валидатор от pydantic. Если в .env написана криво URL — упадёт сразу, не на первой попытке коннекта.

# Когда settings.py импортируется:
settings = Settings()    # ValidationError, если что-то не так

# Дальше нигде не нужно проверять
def get_engine():
    return create_engine(str(settings.database_url))    # уже точно валидный

Это паттерн fail loudly — лучше упасть на старте на пустом env-файле, чем работать 3 часа и потом понять, что половина запросов уходила в None.

Connection strings: формы для разных драйверов

postgresql://... — стандартная Postgres-DSN. Но психопг с SQLAlchemy ждут немного разный синтаксис:

ДрайверURL
psycopg3 rawpostgresql://user:pass@host:5432/db
SQLAlchemy + psycopg3postgresql+psycopg://user:pass@host:5432/db
SQLAlchemy + psycopg2 (legacy)postgresql+psycopg2://user:pass@host:5432/db
SQLite (local)sqlite:///path/to/file.db

В URL можно передать параметры Postgres через ?:

postgresql://user:pass@host:5432/db?sslmode=require&connect_timeout=10

sslmode, connect_timeout, application_name — частые параметры. Можно и через kwargs у psycopg.connect(), тоже допустимо.

WARNING

Пароль в URL должен быть URL-encoded, если содержит специальные символы (#, @, :, /, ?). Иначе разбор URL сломается. Используйте urllib.parse.quote_plus(password). Лучше — сразу избегайте таких символов в паролях для машин.

TLS для Postgres: уровни sslmode

Postgres поддерживает несколько уровней TLS. Это критично, если БД доступна через интернет (managed Postgres в облаке).

sslmodeЧто значитБезопасно?
disableБез TLSТолько локально
allowЕсли сервер хочет — попробуем TLSСлабо
preferПробуем TLS, fallback на plainDefault Postgres, слабо
requireТолько TLS, сертификат не проверяемШифровано, но MITM возможен
verify-caTLS + проверка, что сертификат подписан доверенным CAЛучше
verify-fullTLS + CA + проверка hostname в сертификатеЭто вы хотите в проде
postgresql://user:[email protected]:5432/etl?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca.pem

verify-full — единственный, который защищает от

MITM
. Между require и verify-full бездна — require шифрует, но не проверяет, что на той стороне. Атакующий с поддельным сертификатом успешно подставит себя.

Managed Postgres-провайдеры (AWS RDS, GCP Cloud SQL, Yandex Managed Postgres) дают ca.pem для верификации. Скачивайте его, кладите рядом с проектом, прописывайте sslrootcert.

Connection pool: лимиты у клиента и у сервера

pool_size=10 у нас в SQLAlchemy — это на один процесс. Если у вас:

  • 4 воркера Airflow,
  • каждый держит 10 коннектов,
  • DAG поднимает 5 параллельных задач,

— получаем до 200 одновременных коннектов к Postgres. Postgres по умолчанию настроен на max_connections=100. 101-й коннект — отказ.

Решения:

  1. Уменьшить pool_size у каждого процесса. Не «больше — всегда лучше».
  2. pgbouncer
    — внешний пуллер. Клиенты коннектятся в pgbouncer (он бесконечно гибкий), pgbouncer держит малое число коннектов в Postgres. Стандарт в любой более-менее серьёзной DE-инфраструктуре.
  3. Increase max_connections у Postgres — последнее средство, упирается в RAM (каждый коннект — отдельный backend-процесс с ~10MB).

В DE-контексте pgbouncer обычно уже стоит у DBA. Вы как разработчик пишете код, который коннектится к pgbouncer-у, не напрямую в Postgres. Для вас это прозрачно (тот же протокол), но есть нюанс: pgbouncer в режиме transaction pooling не поддерживает prepared statements между разными транзакциями. SQLAlchemy с psycopg3 обычно справляется, но если что-то странно ломается — спросите DBA про pooling mode.

Secret managers (awareness)

Для прода .env-файлов недостаточно — они должны откуда-то взяться, на каждом инстансе/контейнере, защищённо. Есть три популярных подхода:

  1. HashiCorp Vault
    — self-hosted. Приложение получает короткоживущий токен (через Kubernetes service account, AppRole, etc), запрашивает секрет, кладёт в env.
  2. AWS Secrets Manager / GCP Secret Manager — облачные аналоги. То же самое, но управляется провайдером.
  3. Kubernetes Secrets + External Secrets Operator — стандарт в K8s. Secret прокидывается в Pod как env или volume.

В коде это выглядит одинаково: при старте процесса env-переменные уже заполнены, pydantic-settings их читает. Сам код не знает, что секрет пришёл из Vault, а не из .env. Это и есть преимущество — никакой vault-specific логики в основном коде.

Junior на старте карьеры обычно работает с env-переменными в CI и .env локально. Vault/Secrets Manager — это уровень DevOps команды, awareness достаточно.

.gitignore и pre-commit secrets scan

.gitignore — обязательный минимум:

.env
.env.local
.env.*.local
*.pem
*.key
*.p12
.aws/credentials

Но это спасает только от случайного коммита самого файла. От git commit -m "fix" -- config.py с захардкоженным секретом внутри — не спасает.

Для второго слоя защиты — pre-commit hook со scanner:

  • gitleaks
    — самый популярный сейчас. Ставится в pre-commit, при коммите сканирует diff, блокирует, если нашёл что-то похожее на секрет.
  • trufflehog — то же, плюс более агрессивные эвристики.
  • detect-secrets — от Yelp, со своей логикой baseline.

В .pre-commit-config.yaml:

repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

Установка: uv tool install pre-commit && pre-commit install. Теперь каждый коммит проверяется автоматически. Это страховка, не альтернатива здравому смыслу — но катит ловить промахи.

Что делать, если уже закоммитили секрет

Стандартный сценарий: «вчера запушил токен, что делать».

  1. Сначала — отозвать токен. На GitHub: Settings → Developer Settings → Personal Access Tokens → Delete. На Postgres: смените пароль пользователя. Это первый шаг, в эту секунду.
  2. Не убираем коммит. Если репо публичное — токен уже в кэшах GitHub-индекса, в форках, в Wayback Machine. Просто переписать историю — не помогает.
  3. Создаём новый токен, обновляем в .env/Vault.
  4. Проверяем audit-логи — не использовали ли его уже атакующие.
  5. Если репо приватное, и форков не было — можно перевалить историю через git filter-repo. Но всё равно сначала ротация секрета.

Принцип: «секрет, попавший в git, считаем скомпрометированным навсегда». Ротация — единственный путь.

DE-кейс: полный Settings класс

Собираем настоящий конфиг для нашего проекта:

from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="forbid",
    )

    # GitHub API
    github_token: SecretStr = Field(..., description="PAT с правами repo:read")
    github_user_agent: str = Field("my-etl/1.0")

    # PostgreSQL — раздельные поля, собираем DSN в свойство
    pg_host: str = Field(...)
    pg_port: int = Field(5432, ge=1, le=65535)
    pg_user: str = Field(...)
    pg_password: SecretStr = Field(...)
    pg_database: str = Field(...)
    pg_sslmode: str = Field("verify-full")
    pg_sslrootcert: str | None = Field(None)

    # Pool
    pool_size: int = Field(5, ge=1, le=50)
    pool_recycle: int = Field(1800)

    # Logging
    log_level: str = Field("INFO")

    @field_validator("pg_sslmode")
    @classmethod
    def validate_sslmode(cls, v: str) -> str:
        allowed = {"disable", "require", "verify-ca", "verify-full"}
        if v not in allowed:
            raise ValueError(f"pg_sslmode must be one of {allowed}, got {v}")
        return v

    @property
    def database_url(self) -> str:
        params = [f"sslmode={self.pg_sslmode}"]
        if self.pg_sslrootcert:
            params.append(f"sslrootcert={self.pg_sslrootcert}")
        query = "&".join(params)
        return (
            f"postgresql+psycopg://"
            f"{self.pg_user}:{self.pg_password.get_secret_value()}"
            f"@{self.pg_host}:{self.pg_port}/{self.pg_database}"
            f"?{query}"
        )


settings = Settings()

Заметьте:

  • Все секреты как SecretStr.
  • Валидатор на sslmodeprefer/allow не дадим выставить.
  • database_url — computed property. В коде используется как settings.database_url, нигде не собирается строка вручную.
  • Без .env (или с пропущенным PG_USER) — ValidationError на старте.

В main.py:

from sqlalchemy import create_engine
from .settings import settings

engine = create_engine(
    settings.database_url,
    pool_size=settings.pool_size,
    pool_recycle=settings.pool_recycle,
    pool_pre_ping=True,
)

Никаких секретов в коде. Никаких хардкодов. Всё валидируется на старте.

Что мы получили

  • Креды никогда не в коде, никогда не в git. .env локально, env-переменные в CI/prod.
  • pydantic-settings для типизированной валидации; SecretStr для скрытия в логах.
  • Fail loudly — отсутствующий env приводит к crash на старте, а не к проблеме в проде через неделю.
  • TLS для Postgres — только sslmode=verify-full с sslrootcert в проде. require — самообман.
  • Pool: помните о max_connections Postgres. Pgbouncer для серьёзных кейсов.
  • pre-commit + gitleaks — must-have страховка от закоммиченных секретов.
  • Если закоммитил секрет — сначала ротация, потом всё остальное.

Это конец модуля. Junior, дошедший досюда, умеет тянуть данные из любого REST API c retry и pagination, заливать их в Postgres через psycopg3/SQLAlchemy, и делать всё это без захардкоженных секретов. Это базовый «инструмент» DE — теперь в Module 07 разберём, как обрабатывать эти данные через pandas.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7