Файл с паролем от продуктовой базы данных попал в коммит полгода назад. Его давно удалили из рабочей копии, но история Git помнит всё, и любой, кто клонирует репозиторий, получает этот файл вместе с ним. Простое удаление в новом коммите проблему не решает, потому что старый снимок остаётся целостным и доступным. Выход один: переписать историю так, чтобы секрета в ней не было вообще. Именно для этой задачи существует git filter-repo, и именно он сегодня считается правильным инструментом вместо устаревшего git filter-branch.

Почему git filter-branch уходит в прошлое и чем хорош filter-repo

Сам Git при вызове git filter-branch честно предупреждает, что команда медленная, легко портит репозиторий при неверных опциях и рекомендует использовать внешнюю утилиту git filter-repo. Это не преувеличение. filter-branch на среднем репозитории с несколькими тысячами коммитов может работать десятки минут, тогда как filter-repo справляется за секунды. Разница объясняется архитектурой: filter-repo использует быстрый поток fast-export/fast-import вместо последовательного вызова обработчиков на каждый коммит.

Кроме скорости, filter-repo безопаснее по умолчанию. Он отказывается работать в репозитории, который не является свежим клоном, требует флаг --force для "грязной" копии, не оставляет висячие ссылки на старые объекты без необходимости и сам выполняет сборку мусора после переписывания. По сути, автор собрал в одном инструменте весь накопленный опыт болезненных переписываний истории.

Утилита написана на Python и распространяется одним файлом, что упрощает установку в любой среде, включая CI.

Установка через pip и pipx и подготовка свежего клона

Самый надёжный способ поставить filter-repo это пакетный менеджер Python. Если pipx уже установлен, команда выглядит так:

pipx install git-filter-repo

Либо классический вариант:

pip3 install --user git-filter-repo

Проверка результата простая:

git filter-repo --version
2.45.0

Единственная строка вывода показывает установленную версию. Если вместо номера версии появляется ошибка "command not found", значит каталог скриптов pip не попал в PATH, и стоит проверить переменную окружения.

Теперь ключевой момент, который многие пропускают и потом удивляются отказу в работе. filter-repo настаивает на свежем клоне. Не на репозитории, где вы работаете месяцами, а на чистой копии, полученной прямо перед операцией. Причина практичная: переписывание истории разрушительно, и если что-то пойдёт не так, у вас должен остаться нетронутый оригинал на сервере или в другом каталоге.

Для полноценной зачистки берём зеркальный клон:

git clone --mirror Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript.:team/billing.git billing-cleanup
cd billing-cleanup

Флаг --mirror вытягивает все ветки, теги и прочие ссылки, а не только ветку по умолчанию. Это важно: секрет может существовать в теге релиза, в забытой feature ветке или в stash подобной ссылке.

Если filter-repo запустить в обычном рабочем репозитории, появится вежливое, но твёрдое предупреждение:

Aborting: refusing to rewrite repo history since it does not look like a fresh clone.
(expected freshly packed repo)

Please operate on a fresh clone instead. If you want to proceed anyway, use --force.

Разбор по строкам: первая строка сообщает, что история не будет переписана, потому что репозиторий не похож на свежий клон. Вторая уточняет признак: утилита ожидает только что упакованный репозиторий. Четвёртая строка даёт два пути, правильный через новый клон и обходной через --force, который оправдан лишь тогда, когда вы точно понимаете риск.

Удаление файлов с секретами через --path и --invert-paths

Самый частый сценарий: в историю закоммичен целый файл, например config/secrets.txt, .env или приватный ключ deploy_key.pem. Удалить его из всех коммитов сразу помогает пара опций --path и --invert-paths:

git filter-repo --path config/secrets.txt --invert-paths --force

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

Типичный вывод после прогона выглядит примерно так:

Parsed 2431 commits
New history written in 0.83 seconds; now repacking/cleaning...
Repacking your repo and cleaning out old unneeded objects
Enumerating objects: 14892, done.
Counting objects: 100% (14892/14892), done.
Delta compression using up to 8 threads
Compressing objects: 100% (5120/5120), done.
Writing objects: 100% (14892/14892), done.
Total 14892 (delta 9761), reused 14892 (delta 9761)
Completely finished after 2.14 seconds.

Разбор: Parsed сообщает, сколько коммитов разобрано из потока fast-export. Далее фиксируется время записи новой истории, меньше секунды на две с половиной тысячи коммитов, что для filter-branch было бы фантастикой. Строки Enumerating, Counting, Compressing и Writing это уже внутренняя переупаковка Git: старые объекты, ссылавшиеся на секретный файл, не попали в новый поток и физически исчезают из базы объектов. Финальная строка подтверждает полное завершение.

Когда файлов несколько, перечислять их по одному неудобно. Тогда используют --paths-from-file:

cat > /tmp/secret-paths.txt <<'EOF'
config/secrets.txt
.env
certs/deploy_key.pem
glob:**/*.p12
EOF
git filter-repo --paths-from-file /tmp/secret-paths.txt --invert-paths --force

Формат простой: один путь на строку, поддерживаются glob-шаблоны через префикс glob:. Обратите внимание на регистр и точное совпадение путей: ошибка в одном символе означает, что файл останется в истории, а вы об этом узнаете только при проверке.

После прогона обязательно убеждаемся, что файла больше нигде нет:

git log --all -- config/secrets.txt

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

Замена паролей в тексте файлов через --replace-text

Удаление файла целиком подходит не всегда. Чаще секрет зашит в нормальный конфиг внутри строки, и резать файл нельзя. Для таких случаев есть --replace-text: утилита проходит по содержимому каждого блоба и заменяет совпадения на выбранный маркер.

Сначала готовим файл выражений:

cat > /tmp/replacements.txt <<'EOF'
SuperSecret2024!
db_pass=[A-Za-z0-9]+==>db_pass=REMOVED
AKIA[A-Z0-9]{16}==>***REMOVED_AWS_KEY***
EOF
git filter-repo --replace-text /tmp/replacements.txt --force

Каждая строка файла это правило. Литеральная строка без ==> заменяется на стандартный маркер REMOVED. Регулярное выражение со стрелкой ==> заменяется на текст справа от неё. Честно говоря, второй вариант удобнее для последующего аудита: по маркеру REMOVED_AWS_KEY легко понять, какой именно тип секрета вырезали.

Важный нюанс: --replace-text затирает совпадения во всех блобах истории, включая текстовые файлы любого назначения. Если пароль короткий или распространённый, скажем qwerty123, можно случайно задеть легитимный текст в документации или тестовых фикстурах. Поэтому лучше действовать по конкретным значениям секретов или по хорошо узнаваемым паттернам вроде префиксов ключей облачных провайдеров.

После замены проверяем результат поиском по истории:

git rev-list --all | xargs -I{} git grep -l "SuperSecret2024" {} 2>/dev/null

Тишина в ответ означает, что ни в одном коммите не осталось ни одного файла с этой строкой.

Соберём весь порядок действий в аккуратную последовательность, от обнаружения секрета до снабжения команды чистой историей:

  1. Сделать зеркальный клон репозитория в отдельный каталог и ничего в нём не коммитить;
  2. Составить список путей или значений секретов и прогнать filter-repo с нужными опциями и флагом --force;
  3. Проверить результат поиском по всей истории и убедиться, что рабочая сборка проходит;
  4. Выполнить принудительную публикацию зеркала на сервер со всеми ветками и тегами;
  5. Отозвать и заменить скомпрометированные ключи и пароли, а команде сообщить об инвалидации старых клонов.

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

Переписанная история это новая история: у всех коммитов после точки вырезания изменились хеши, и обычный push сервер отклонит. Ещё одна деталь: зеркальный клон по умолчанию не хранит адрес origin в привычном виде: filter-repo удаляет remote, чтобы вы случайно не отправили мусор не туда. Адрес добавляем заново:

git remote add origin Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript.:team/billing.git
git push origin --force --mirror

Флаг --mirror при push делает серверное состояние ссылок точной копией локального: все ветки, все теги, все остальные ссылки. Это принципиально. Часто встречающаяся ошибка это push только main и нескольких тегов вручную, после чего секрет спокойно живёт в ветке old-experiment или теге v0.9-rc, и через месяц его находит сканер безопасности.

Если на сервере включена защита веток, force push будет отклонён, и права на принудительную публикацию придётся временно выдать в настройках репозитория. После операции защиту включают обратно.

Типичные ошибки при такой публикации предсказуемы. Первая это попытка сделать push из обычного рабочего репозитория поверх переписанной истории: Git предложит сделать pull, и если согласиться, старая история вольётся обратно через merge, вернув секрет на место. Вторая ошибка это удалённый репозиторий с включённым запретом на force push по умолчанию: в выводе появится строка "remote: error: denying non-fast-forward refs/heads/main", которая означает ровно одно: сервер защищает ветку от переписывания, и ограничение нужно снять на время операции. Третья ошибка это забытые вторичные remote: если у проекта есть зеркало на внутреннем резервном сервере или в системе архивации, переписывать историю нужно и там, иначе проверка безопасности найдёт секрет в самом неожиданном месте.

Дальше часть, которую технически не автоматизируешь: уведомление команды. Каждый, у кого есть старый клон, работает с репозиторием, чья история больше не существует. Если коллега сделает push из старой копии, секрет вернётся в историю мгновенно, причём теперь уже с новыми коммитами поверх. Правильный сценарий для всех участников простой: удалить локальную копию и клонировать заново. Никаких хитрых rebase из старых репозиториев после переписывания делать не нужно, проще начисать чисто.

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

Ротация ключей и профилактика повторных утечек

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

Профилактика строится в три слоя. Первый слой это локальная гигиена: файлы с секретами держат в .gitignore с первого дня проекта, а в репозиторий кладут шаблоны вроде .env.example с пустыми значениями. Второй слой это pre-commit хуки и сканеры, которые ищут паттерны ключей до того, как коммит уйдёт на сервер; поймать утечку до push стоит в разы дешевле, чем переписывать историю. Третий слой это хранение секретов в менеджерах секретов или переменных окружения CI, а не в файлах рядом с кодом.

Отдельно помогает дисциплина мелочей: не логировать переменные окружения целиком, не коммитить дампы конфигурации с продакшена, проверять git diff --cached перед коммитом. Эти привычки скучные, но именно они отличают команду, которая переписывает историю раз в жизнь ради опыта, от команды, которая делает это каждый квартал по тревоге.

filter-repo берёт на себя самую тяжёлую техническую часть инцидента: быстро, детерминированно и безопасно для структуры репозитория. Остальное это организационная работа. Свежий клон, точный список путей и значений, зеркальный push, заново выданные клоны у коллег и обязательная ротация секретов. Выполненная целиком, эта цепочка действительно закрывает утечку, а не создаёт иллюзию безопасности.