Гайды

Секреты в проекте: переменные окружения и защита от коммита

Ключ, попавший в коммит, остаётся в истории даже после удаления. Ставим три слоя защиты: игнор-правила, pre-commit хук с рабочими шаблонами и тест, который защищает сам себя.

Ключ, попавший в коммит, становится публичным в тот момент, когда публичным становится репозиторий, — и остаётся публичным, даже если вы удалите его следующим коммитом. Git хранит старый объект, он достижим по хешу, его находят автоматические сканеры. Удаление файла в новом коммите не отменяет утечку. Отменяет её только отзыв ключа.

Из этого следует единственный разумный порядок работы: защита ставится до ошибки. Хук, который отказывает в коммите, стоит десять минут. Ротация всего, что успело утечь, стоит вечер и один неприятный разговор.

#Что понадобится

  • Git с рабочим каталогом .git/hooks (то есть любой).
  • Возможность прогонять тесты локально или в CI.
  • Пять минут на то, чтобы честно ответить на вопрос «а где сейчас лежат мои ключи».

Работа разовая и делается один раз на проект — желательно на первом же этапе маршрута о том, как поднять свой ИИ-сервис, пока в репозитории ещё нечему течь.

#Шаг 1. Три слоя, а не один

Один хук — это иллюзия защиты, потому что .git/hooks не версионируется: он живёт только в вашем клоне. Коллега, свежий клон, машина сборки — все они окажутся без него. Поэтому слоёв три.

Слой Что ловит Когда работает Слабость
.gitignore целые классы файлов: .env*, *.db, *.pem, логи всегда не ловит ключ, вписанный в код
pre-commit хук секреты в изменённых строках на git commit, если установлен ставится вручную в каждом клоне
тест в наборе то же по всему дереву на каждом прогоне тестов медленнее, но не обходится

Ключевое требование: слои 2 и 3 обязаны использовать один и тот же набор правил, вынесенный в отдельный файл. Иначе они разъедутся, и то, что хук считает секретом, тест пропустит — или наоборот, и тогда хук начнут отключать.

Третий слой — не паранойя. Он единственный, кто защищает от коммита, сделанного с ключом --no-verify, и от клона, где хук просто не поставили.

#Шаг 2. Игнор-правила — паттернами, а не именами

Типовая ошибка — перечислить в .gitignore конкретные файлы, которые уже есть. Через месяц появляется .env.local, и он не подпадает ни под одно правило.

# секреты и креды
.env
.env.*
!.env.example
*.pem
*.pfx
*.p12
*.ppk
id_rsa
id_rsa.*
.netrc
.npmrc
.pypirc
billing_secrets.json

# данные и то, что из них следует
*.db
*.db-wal
*.db-shm
*.sqlite
*.sqlite3
*.session
*.log

Строка *.db важнее, чем кажется. Файл базы — это не «данные», это контейнер, в котором могут лежать ключи открытым текстом. В Persona так и есть: ключ участника вида byo_api_key_<провайдер> пишется в таблицу пользовательских настроек без шифрования. Ключ владельца может уехать в зашифрованное хранилище, но при его недоступности ложится в обычное. Файл базы, попавший в репозиторий, — это утечка всех ключей всех, кто на инстансе.

!.env.example — исключение для шаблона. Шаблон обязан существовать и обязан быть с пустыми значениями. Правило, которое стоит закрепить тестом: каждая секретная переменная, встречающаяся в коде, документирована в шаблоне, и все значения в шаблоне пусты. Недокументированная переменная — это самый частый путь к захардкоженному ключу: следующий человек не нашёл, куда класть значение, и вписал его в код.

#Шаг 3. Хук, который ловит форму, а не слово

Главный принцип: сканер, который кричит на каждое совпадение, отключат, и после этого он не защищает ничего. Поэтому ловить надо форму реальных ключей и на реальной длине.

Что стоит проверять:

  • ключи провайдеров с их характерными префиксами и длинами;
  • токены доступа к репозиториям — классические и мелкозернистые;
  • токены и вебхуки мессенджеров;
  • токены ботов вида «числовой идентификатор, двоеточие, длинная строка»;
  • блоки приватных ключей PEM и их аналогов;
  • учётные данные внутри URL: схема://пользователь:пароль@хост;
  • JSON Web Token как формат;
  • присвоение непустого значения известной секретной переменной окружения;
  • имена файлов, которых в репозитории быть не должно.

Длина — не косметика, а главный регулятор шума. Строка вида ghp_ плюс тридцать шесть символов — это живой токен; та же приставка плюс тридцать два символа — чья-то заглушка в тесте. Флагом стоит помечать только первое.

Хук выглядит так:

#!/bin/sh
# .git/hooks/pre-commit — шим, делегирующий версионированному скрипту
[ -n "$SKIP_SECRET_SCAN" ] && exit 0
exec python ops/secret_scan.py --staged

Именно шим, а не сам скрипт. Тогда правила лежат в репозитории, правятся в обычном ревью и вступают в силу без переустановки хука. Установщик хука кладёт этот шим в .git/hooks/pre-commit — один раз на клон, и это единственное ручное действие.

#Шаг 4. Отдельно — секреты, уезжающие в логи

Это тот класс утечек, который игнор-правила не видят в принципе: ключ никуда не коммитится, он просто печатается в журнал, а журнал потом попадает в архив диагностики или в обращение в поддержку.

У нас такие строки были: токен доступа по ссылке уходил в лог открытым текстом. И тут выяснилось, что регулярное выражение здесь бесполезно — по тексту одинаково совпадают reason="missing_token", token_id=… и max_tokens=…, а сканер с девяноста процентами ложных срабатываний живёт неделю. Работает разбор исходного кода как дерева, со срабатыванием только тогда, когда аргумент и есть секрет: голое имя или атрибут, названные token, secret, password, api_key. На нашей кодовой базе это дало два попадания и ни одного ложного.

Подробнее про то, что вообще должно быть в событии лога и чего в нём быть не должно, — в статье про структурированные логи.

#Проверка результата

Хук проверяется попыткой его пробить, а не фактом установки.

# 1. поставить хук
sh ops/install_hooks.sh

# 2. попробовать закоммитить заведомый секрет
printf 'KEY = "ghp_%s"\n' "$(head -c 27 /dev/urandom | base64 | tr -d '/+=' | head -c 36)" > /tmp/leak.py
git add -f /tmp/leak.py 2>/dev/null || cp /tmp/leak.py ./leak.py && git add leak.py
git commit -m "тест хука"     # ожидание: коммит отклонён с указанием файла и строки

# 3. убрать за собой
git restore --staged leak.py && rm leak.py

# 4. прогнать проверку по всему дереву
python ops/secret_scan.py --tree

Критерий приёмки: пункт 2 отклонён, пункт 4 отработал по всему дереву и дал ноль находок. Если пункт 2 прошёл — либо хук не установлен, либо порог длины выше, чем ваш тестовый ключ.

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

#Что может пойти не так

Ложное срабатывание на файле с образцами. Не отключайте правило целиком. Правильный порядок: сначала пометка на конкретной строке, потом — исключение файла целиком с указанной причиной. И заведите тест, который валит сборку, когда исключение устарело: исключённый файл удалён или причина исчезла. Иначе список исключений растёт и однажды прикрывает настоящую утечку.

Хук не установлен в новом клоне. Ровно для этого нужен третий слой. Не пытайтесь решить это дисциплиной.

--no-verify вошёл в привычку. Признак того, что сканер шумит. Чините пороги, а не приучайте себя обходить.

Ключ уже в истории. Хук здесь не поможет. Порядок действий — в статье про что делать, если ключ утёк. Если коротко: сначала отзыв, потом всё остальное.

Секрет утёк не через git. Полный экспорт базы, зашифрованная резервная копия, дамп аварийного завершения с телом запроса — все они содержат то же самое и не проверяются никаким хуком. Про это и про остальные неочевидные последствия своего хостинга — в статье про риски самостоятельного хостинга.

#Частые вопросы

#Где вообще должны лежать секреты?

В файле .env вне репозитория либо в каталоге данных приложения, который лежит за пределами рабочей копии. В коде — никогда, в базе открытым текстом — по возможности никогда, но честно проверьте, так ли это у вас: у нас, например, ключи участников лежат именно так, и об этом надо знать, а не догадываться. Развёрнуто про варианты хранения — в статье про безопасное хранение API-ключа.

#Не проще ли взять готовый сканер секретов?

Готовый инструмент закрывает большую часть шаблонов и его стоит взять, если он вам подходит. Свой набор правил выигрывает в двух вещах: он знает про ваши переменные окружения и ваши имена файлов, и он не разъезжается между хуком и тестом, потому что это один файл.

#Что делать, если сканер тормозит коммиты?

Сканируйте только изменённые строки в хуке и всё дерево — в тестах. Проверка полного дерева на каждом коммите избыточна и именно она заставляет людей ставить --no-verify.

#Нужно ли шифровать .env?

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

#Считается ли утечкой ключ, попавший только в локальный коммит?

Считайте, что да. Локальный коммит легко уходит в удалённый репозиторий вместе с веткой, попадает в архив резервной копии рабочей машины и в системы индексации. Ротация ключа стоит несколько минут, разбирательство после публикации — гораздо дороже.

Хотите ИИ, который помнит вас?

Persona держит контекст между чатами и работает на вашем железе — если развернуть её у себя и подключить локальную модель.

Создать аккаунт