Ключ, попавший в коммит, становится публичным в тот момент, когда публичным становится репозиторий, — и остаётся публичным, даже если вы удалите его следующим коммитом. 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?
Обычно нет: файл лежит на той же машине, что и приложение, которому он нужен в открытом виде. Шифрование добавит ключ, который надо где-то хранить, — и вы вернётесь к исходной задаче. Полезнее правильные права на файл и его отсутствие в репозитории. Другое дело — заметки и данные, которые вы хотите закрыть от чтения на самой машине: это отдельный сценарий, разобранный в статье про пароль и хранилище для заметок.
#Считается ли утечкой ключ, попавший только в локальный коммит?
Считайте, что да. Локальный коммит легко уходит в удалённый репозиторий вместе с веткой, попадает в архив резервной копии рабочей машины и в системы индексации. Ротация ключа стоит несколько минут, разбирательство после публикации — гораздо дороже.