Секрет попадает в репозиторий почти всегда одинаково. Разработчик случайно коммитит файл с ключом доступа, замечает это, удаляет файл, делает коммит «убрал лишнее» и выдыхает.

Ключ при этом остаётся на месте: он лежит в объектах истории и достаётся одной командой. У всех, кто успел склонировать, — тоже.

Как ключ попадает в историю

Собираю ситуацию с нуля:

git init -q
echo 'API_KEY=sk-test-0000000000000000' > config.env
git add . && git commit -qm 'add config'
rm config.env
git add -A && git commit -qm 'remove secret config'

История выглядит убедительно:

3b41bf3 remove secret config
1b1f9cd add config

Файла в рабочем каталоге нет, в последнем коммите его тоже нет. Теперь ищем:

git log -p --all | grep -c 'sk-test-0000000000000000'
2

Два вхождения: добавление и удаление. Точнее — через поиск по всем коммитам:

git grep 'sk-test' $(git rev-list --all)
1b1f9cd365abbd8bb5426ab8e5ab4aa9a986a8a4:config.env:API_KEY=sk-test-0000000000000000

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

Найти такие объекты можно и не зная, где искать:

git rev-list --objects --all | head

Почему git rm и переписывание истории помогают не всегда

Допустим, вы знаете правильный ответ: переписать историю через git filter-repo или BFG, выкинув файл из всех коммитов. Это работает для вашего репозитория. Дальше начинаются нюансы, о которых обычно не думают:

У всех, кто уже склонировал, история прежняя. Переписывание у вас не удаляет объекты у них. Каждая копия репозитория — полноценная база со всеми версиями.

На хостинге объекты живут после переписывания. Ссылки на старые коммиты остаются доступными по прямому адресу, пока сборщик мусора их не уберёт, а сроки тут не гарантированы. Плюс в форках объекты остаются независимо от того, что вы сделали у себя.

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

Отсюда правило: любой секрет, попавший в коммит, считается скомпрометированным. Не «возможно скомпрометированным» — просто скомпрометированным.

Правильный порядок действий

Сначала — отозвать и перевыпустить ключ. Это первое, а не последнее. Пока ключ действует, всё остальное не имеет смысла.

Потом — проверить, где он использовался: логи доступа провайдера, счета за услуги, необычная активность. Отозванный ключ мог поработать несколько часов.

И только затем — чистить историю: git filter-repo, форс-пуш, уведомление всем, кто работает с репозиторием, чтобы переклонировали.

Как не попадать

Проверка перед коммитом. Хук pre-commit с поиском по шаблонам ключей: gitleaks, detect-secrets, trufflehog. Ловят не всё, но типовые ключи с узнаваемым префиксом закрывают, а настройка сводится к одному файлу конфигурации.

Проверка на стороне сервера. Хостинги умеют сканировать пуши и блокировать те, где нашлись секреты. Включается галочкой в настройках репозитория.

.gitignore с самого начала проекта. *.env, *.pem, credentials.json, каталоги с конфигурацией окружений. Дописывают его обычно уже после первого инцидента.

Секреты не в файлах, а в хранилище. Переменные окружения из менеджера секретов, а не config.env рядом с кодом. Тогда в репозитории нечему утекать.

И полезная привычка на будущее: прежде чем считать, что старый секрет из репозитория убран, выполните git grep по всем коммитам. Результат иногда неожиданный — особенно в репозиториях, которым несколько лет.