Если оно работает — это не значит, что оно безопасно
В прошлых шести уроках мы писали 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"
Что произойдёт:
- Завтра кто-то форкнет приватный репо → токены у форкнувшего.
- Через год репо мигрируют open-source → токены в публичном гитхабе.
- CI-логи покажут URL при ошибке → токены в Slack у всех, кто видит CI.
- 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 года:
- Локально — секреты в
.envфайле (не в git). - В CI/CD — секреты как зашифрованные переменные пайплайна (GitHub Secrets, GitLab CI/CD Variables).
- В production — секреты из менеджера секретов (Vault, AWS Secrets Manager, GCP Secret Manager) ИЛИ как env-переменные в Kubernetes Secret.
- В коде —
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 raw | postgresql://user:pass@host:5432/db |
| SQLAlchemy + psycopg3 | postgresql+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(), тоже допустимо.
Пароль в URL должен быть URL-encoded, если содержит специальные символы (#, @, :, /, ?). Иначе разбор URL сломается. Используйте urllib.parse.quote_plus(password). Лучше — сразу избегайте таких символов в паролях для машин.
TLS для Postgres: уровни sslmode
Postgres поддерживает несколько уровней TLS. Это критично, если БД доступна через интернет (managed Postgres в облаке).
| sslmode | Что значит | Безопасно? |
|---|---|---|
disable | Без TLS | Только локально |
allow | Если сервер хочет — попробуем TLS | Слабо |
prefer | Пробуем TLS, fallback на plain | Default Postgres, слабо |
require | Только TLS, сертификат не проверяем | Шифровано, но MITM возможен |
verify-ca | TLS + проверка, что сертификат подписан доверенным CA | Лучше |
verify-full | TLS + CA + проверка hostname в сертификате | Это вы хотите в проде |
postgresql://user:[email protected]:5432/etl?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca.pem
verify-full — единственный, который защищает от
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-й коннект — отказ.
Решения:
- Уменьшить
pool_sizeу каждого процесса. Не «больше — всегда лучше». - — внешний пуллер. Клиенты коннектятся в pgbouncer (он бесконечно гибкий), pgbouncer держит малое число коннектов в Postgres. Стандарт в любой более-менее серьёзной DE-инфраструктуре.pgbouncer
- 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-файлов недостаточно — они должны откуда-то взяться, на каждом инстансе/контейнере, защищённо. Есть три популярных подхода:
- — self-hosted. Приложение получает короткоживущий токен (через Kubernetes service account, AppRole, etc), запрашивает секрет, кладёт в env.HashiCorp Vault
- AWS Secrets Manager / GCP Secret Manager — облачные аналоги. То же самое, но управляется провайдером.
- 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:
- — самый популярный сейчас. Ставится в pre-commit, при коммите сканирует diff, блокирует, если нашёл что-то похожее на секрет.gitleaks
- 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. Теперь каждый коммит проверяется автоматически. Это страховка, не альтернатива здравому смыслу — но катит ловить промахи.
Что делать, если уже закоммитили секрет
Стандартный сценарий: «вчера запушил токен, что делать».
- Сначала — отозвать токен. На GitHub: Settings → Developer Settings → Personal Access Tokens → Delete. На Postgres: смените пароль пользователя. Это первый шаг, в эту секунду.
- Не убираем коммит. Если репо публичное — токен уже в кэшах GitHub-индекса, в форках, в Wayback Machine. Просто переписать историю — не помогает.
- Создаём новый токен, обновляем в
.env/Vault. - Проверяем audit-логи — не использовали ли его уже атакующие.
- Если репо приватное, и форков не было — можно перевалить историю через
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. - Валидатор на
sslmode—prefer/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_connectionsPostgres. Pgbouncer для серьёзных кейсов. - pre-commit + gitleaks — must-have страховка от закоммиченных секретов.
- Если закоммитил секрет — сначала ротация, потом всё остальное.
Это конец модуля. Junior, дошедший досюда, умеет тянуть данные из любого REST API c retry и pagination, заливать их в Postgres через psycopg3/SQLAlchemy, и делать всё это без захардкоженных секретов. Это базовый «инструмент» DE — теперь в Module 07 разберём, как обрабатывать эти данные через pandas.