Перейти к содержимому

Секреты в git истории: почему revert не удаляет утечку и как очистить репозиторий

Секреты в git истории: почему revert не удаляет утечку и как очистить репозиторий
Время чтения: 12 мин.

По данным IBM X-Force Threat Intelligence Index 2025, атаки с использованием действительных учётных данных выросли на 71% за год, а в dark web ежедневно появляется более 6 000 свежих пар логин-пароль. Значительная часть этих данных приходит не из фишинга — их забирают прямо из git-истории репозиториев. Сценарий типичный до боли: разработчик коммитит AWS-ключ, через минуту делает git revert, считает проблему решённой — а через два часа обнаруживает чужие EC2-инстансы на своём аккаунте. Я разбирал подобные инциденты десятки раз, и каждый раз одна и та же ошибка: люди путают «отменить изменение в файле» и «стереть данные из истории». Это разные вещи. Ниже — разбор того, как git на самом деле хранит данные, почему стандартные средства отмены бесполезны против утечки и что с этим делать: от поиска секретов в репозитории до очистки и защиты CI/CD пайплайна.

Почему git revert не удаляет секреты из git истории

Объектная модель git: blob, tree, commit

Git — не просто система контроля версий. Это база данных объектов с адресацией по содержимому. Чтобы понять, почему revert не спасает, нужно разобраться, как git хранит файлы внутри.

Когда файл добавляется в репозиторий, git сохраняет его содержимое как blob (binary large object) — объект, хранящий полное содержимое файла. Набор файлов в одной директории образует tree — объект, ссылающийся на blob’ы и другие деревья. А commit — это указатель на tree плюс метаданные: автор, время, сообщение и ссылка на родительский коммит.

Каждый объект получает уникальный идентификатор — SHA-1 хэш (40-символьная строка, вычисленная из содержимого). Когда разработчик коммитит файл config.py с паролем P@ssw0rd, git создаёт blob с этим содержимым, вычисляет его хэш и сохраняет в директории .git/objects/. Этот blob не исчезает, пока существует репозиторий, — даже если в следующем коммите строку с паролем удалить.

Что делает revert — и что он не делает

git revert создаёт новый коммит поверх старого, который «отменяет» изменения предыдущего коммита. Старый коммит с секретом остаётся в цепочке истории — его видно через git log, а его blob по-прежнему лежит в .git/objects/.

git reset --hard HEAD~1 сдвигает указатель ветки на предыдущий коммит, но так называемый «висячий» коммит (dangling commit — коммит, на который больше не ссылается ни одна ветка) сохраняется локально. Если git push был сделан до reset, коммит останется и на удалённом сервере. По данным исследования Truffle Security, GitHub хранит висячие коммиты бессрочно — к ним можно получить доступ, зная даже первые четыре символа хэша.

git push --force ведёт себя так же: ссылки на коммит из веток пропадают, но сам объект остаётся в хранилище GitHub. Через GitHub Event API такие «удалённые» коммиты обнаруживаются массово — исследователи находили высокоценные секреты в висячих коммитах и получали вознаграждения по программам bug bounty на протяжении нескольких лет.

Суть проблемы: git — append-only система. Новые данные добавляются поверх старых, ничего не затирается физически. Поэтому revert, reset и даже force push — средства управления текущим состоянием кода, а не инструменты очистки истории.

Как убедиться, что секрет по-прежнему в истории

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

Для этого нужен терминал с установленным git и локально склонированный репозиторий.

git log -p --all -S 'AKIA' (где AKIA — стандартный префикс AWS Access Key ID; замени на свой паттерн) покажет каждый коммит, в котором строка была добавлена или удалена. Флаг -p выводит diff (разницу между версиями файла), -S (pickaxe) ищет коммиты, где изменилось количество вхождений строки. Для поиска любого упоминания строки в diff, включая перемещения, используй -G с regex-паттерном. --all охватывает все ветки. Если в выводе видна строка с +AKIA... — секрет был добавлен в этом коммите. Строка -AKIA... в следующем коммите означает лишь удаление из текущего файла, но оба коммита хранятся навсегда.

Для быстрой проверки конкретного коммита по хэшу: git show <hash> — покажет полный diff, включая секрет.

Поиск секретов в репозитории: git secrets scanning

Ручной поиск через git log -S работает, когда знаешь, что именно ищешь. В реальном проекте могут скрываться десятки забытых токенов, ключей и паролей. Для систематического поиска нужны специализированные инструменты.

Gitleaks: сканирование всей git истории

Gitleaks — open-source утилита, которая ищет hardcoded credentials (жёстко зашитые учётные данные) по регулярным выражениям и энтропии строк. Энтропия здесь — мера «случайности» набора символов: случайная строка aK3mZ9pLqR7x имеет высокую энтропию и с большой вероятностью окажется ключом, а осмысленный текст — низкую. Gitleaks поддерживает сотни встроенных правил для AWS, GCP, Stripe, GitHub-токенов и других провайдеров.

Установка: brew install gitleaks на macOS или бинарник из GitHub Releases.

Сканирование всей истории: gitleaks detect --source . --log-opts="--all" --verbose. Инструмент пройдёт каждый коммит во всех ветках и выведет найденные секреты с указанием файла, номера строки, типа секрета и хэша коммита. При обнаружении в выводе будет строка с описанием правила (например, aws-access-key-id), путь к файлу и хэш коммита. Пустой вывод — секретов в истории не обнаружено.

Для экспорта результатов в файл: --report-path=gitleaks-report.json — JSON-отчёт удобен для автоматической обработки и передачи команде безопасности.

Trufflehog: верификация живых секретов

Trufflehog работает по аналогичному принципу, но умеет кое-что ценное: проверять «живость» найденных секретов. Инструмент пытается использовать обнаруженный токен для аутентификации и сообщает, активен ли он. Для приоритизации это критично: живой AWS-ключ — инцидент уровня P0 (требует немедленной реакции), а отозванный полгода назад — задача на очистку без паники. Запуск: trufflehog git file://. --json.

По оценкам отраслевых исследований, в публичные git-репозитории ежедневно утекают тысячи секретов, а ежегодное число утечек корпоративных credentials исчисляется миллионами. Автоматические боты непрерывно сканируют репозитории и забирают учётные данные за минуты после коммита.

В терминах MITRE ATT&CK (открытая база тактик и техник атак; T-коды вроде T1552 — её идентификаторы) поиск секретов в файлах классифицируется как Credentials In Files (T1552.001, тактика Credential Access) — атакующий ищет учётные данные, оставленные в файлах. Найденный приватный SSH-ключ — Private Keys (T1552.004). Целенаправленный сбор данных из кодовой базы — Code Repositories (T1213.003, тактика Collection). Зачем это знать? Чтобы выстраивать детекцию: в SIEM можно настроить алерты на аномальное клонирование репозиториев или массовый доступ к .git/objects.

Утечка секретов через CI/CD — уязвимость CI раннера как вектор атаки

Утечка — не только проблема разработчика, случайно закоммитившего .env. CI/CD раннеры (агенты, которые выполняют пайплайны сборки и деплоя на выделенных серверах или контейнерах) сами по себе становятся вектором атаки.

Что раннер раскрывает при компрометации

CI-раннер — GitLab Runner, GitHub Actions runner, Jenkins agent — клонирует репозиторий в рабочую директорию, подгружает секреты из переменных окружения и выполняет скрипты сборки. В раннерах периодически обнаруживаются уязвимости, связанные с изоляцией рабочих директорий и доступом к кэшу. Если раннер скомпрометирован, атакующий получает доступ к трём зонам:

  • Рабочая директория с полным клоном репозитория, включая .git/objects — вся история со всеми когда-либо закоммиченными секретами
  • Переменные окружения с секретами CI/CD: токены деплоя, ключи Docker Registry, пароли баз данных
  • Кэш и артефакты предыдущих сборок других проектов, если раннер shared (разделяемый между несколькими проектами)

В MITRE ATT&CK цепочка выглядит так: начальный доступ через Exploit Public-Facing Application (T1190), если раннер имеет сетевой интерфейс; использование найденных учётных данных через Cloud Accounts (T1078.004) или Local Accounts (T1078.003) для горизонтального перемещения; модификация артефактов сборки — Compromise Software Supply Chain (T1195.002), ставящая под удар всех потребителей скомпрометированных сборок. Последнее соответствует риску A08:2021 — Software and Data Integrity Failures по OWASP Top 10 (недостаточная проверка целостности ПО и данных) — речь о нарушении целостности цепочки поставок.

Цифры подтверждают масштаб: по данным CrowdStrike Global Threat Report 2025, 75% вторжений используют действительные учётные данные. Verizon DBIR 2025 фиксирует: 38% утечек связаны с кражей credentials. Немалая часть этих учётных данных изначально утекает через неочевидные каналы — подобные описанному.

Реальный сценарий с shared runner

На аудитах я встречал характерную картину: shared GitLab Runner обслуживает десятки проектов на одном сервере. Один проект содержит в истории AWS-ключ (закоммичен и «удалён» через revert). Другой проект в CI-скрипте запускает git log -p для автоматической генерации changelog — и вывод, включающий секрет из первого проекта, попадает в артефакты сборки. Цепочка неочевидна, но итог один: секрет покидает периметр команды.

Рекомендация: использовать изолированные раннеры (по одному на проект или группу проектов с единым уровнем доверия) и никогда не хранить артефакты сборки без ревью содержимого.

Как удалить секреты из git истории: git filter-repo и BFG

Главное правило: сначала ротация, потом очистка. Не рекомендация — обязательный порядок действий.

Ротация — первый и обязательный шаг

Прежде чем переписывать историю:

  1. Отзови или пересоздай ключ в панели провайдера (AWS IAM Console, GCP Console, Stripe Dashboard)
  2. Проверь access logs провайдера на предмет несанкционированных API-вызовов с этим ключом
  3. Обнови ключ во всех системах, где он использовался

Как справедливо отмечает GitGuardian: если секрет попал в git-историю, считай его скомпрометированным навсегда — даже если репозиторий приватный. Ротация нейтрализует угрозу мгновенно; очистка истории — гигиена и снижение риска повторного обнаружения.

Этот принцип напрямую следует из NIST CSF PR.AA-01 (Identity Management, Authentication and Access Control) — организация обязана управлять учётными данными и отзывать скомпрометированные.

Очистка репозитория через git-filter-repo

git-filter-repo — рекомендованная замена устаревшему git filter-branch, о котором официальная документация git прямо говорит «не используйте». GitHub тоже рекомендует именно git-filter-repo с флагом --sensitive-data-removal.

Для работы нужен Python 3 и установленный git-filter-repo (pip install git-filter-repo), а также свежий клон репозитория.

Процесс по шагам:

  1. Склонируй репозиторий заново: git clone <URL_вашего_репозитория> && cd repo
  2. Удали файл из всей истории: git-filter-repo --sensitive-data-removal --invert-paths --path config/secrets.yml
  3. Проверь результат: git log -p --all -S 'AKIA' — вывод должен быть пустым. Пустой вывод означает, что строка больше не встречается ни в одном коммите ни в одной ветке
  4. Принудительно запуши: git push origin --force --all && git push origin --force --tags

Для замены конкретных строк (а не удаления файлов целиком) используй файл подстановок: git-filter-repo --sensitive-data-removal --replace-text replacements.txt, где файл содержит паттерны вида AKIA1234567890EXAMPLE==>***REMOVED***.

BFG Repo-Cleaner — альтернатива на Java, работает быстрее на крупных репозиториях. Вызов аналогичен: bfg --replace-text passwords.txt repo.git. BFG по умолчанию не изменяет содержимое файлов в последнем коммите каждой ветки (protected commits), но хэши всех коммитов в истории всё равно перезаписываются.

Побочные эффекты перезаписи истории

Переписывание истории — разрушительная операция с каскадом последствий. Знать о них нужно заранее, а не после force push:

Эффект Последствие Что делать
Изменённые хэши коммитов Все ссылки на старые коммиты (в тикетах, PR, CI) станут невалидны Предупредить команду, обновить ссылки
Расхождение с клонами коллег git pull из старого клона вернёт секрет обратно Все участники должны заново склонировать репозиторий
Сломанные PR Diff-представление закрытых PR перестанет работать Закрыть или смержить открытые PR до очистки
Невалидные подписи коммитов GPG-подписи привязаны к хэшам и станут недействительны Переподписать критичные коммиты
Ветки-защиты Force push может быть заблокирован branch protection rules Временно отключить защиту

Документация GitHub прямо предупреждает: координация с каждым, кто имел доступ к репозиторию, обязательна. Без неё очистка бесполезна — один git pull && git push из старого клона вернёт секрет в историю.

Безопасность CI/CD пайплайна: защита от утечки API ключей в git

Очистка — реактивная мера. Проактивная защита строится на трёх эшелонах.

Pre-commit хуки с gitleaks

Pre-commit хук — скрипт, запускающийся автоматически перед каждым git commit. Если скрипт обнаруживает проблему, коммит отклоняется. Последний барьер между секретом и историей.

Для настройки нужен фреймворк pre-commit (pip install pre-commit).

# .pre-commit-config.yaml в корне репозитория
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

После создания файла выполни pre-commit install. Теперь при каждом git commit gitleaks просканирует staged-файлы (файлы, добавленные в индекс через git add). Если обнаружит паттерн секрета — коммит будет отклонён с сообщением: имя правила, файл и строка. Секрет не попадёт в историю.

Автоматический git secrets scanning в CI

Даже если разработчик не настроил pre-commit хук или работает из IDE без интеграции, CI-сканер перехватит секрет при push.

name: Secret Scanning
on: [pull_request, push]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: gitleaks/gitleaks-action@v2

fetch-depth: 0 клонирует полную историю, чтобы gitleaks проверил все коммиты, а не только последний. При обнаружении секрета пайплайн упадёт с ошибкой, merge будет заблокирован. GitHub также предоставляет встроенный Secret Scanning (в GitHub Advanced Security), который может автоматически уведомить провайдера — AWS, Stripe, Google — для отзыва скомпрометированного ключа.

Отсутствие такой автоматической детекции соответствует риску A09:2021 — Security Logging and Monitoring Failures по OWASP Top 10: без логирования и мониторинга утечку невозможно обнаружить вовремя.

Переменные окружения вместо hardcoded credentials

Корневая причина большинства утечек — секреты, прописанные прямо в коде или конфигурационных файлах. Правильный подход: секреты хранятся в переменных окружения (environment variables) или в централизованном хранилище секретов (HashiCorp Vault и аналоги), а в код попадают через os.environ['DB_PASSWORD'] или injection на этапе CI.

В репозитории вместо реального .env хранится .env.example с заглушками — коллеги видят, какие переменные нужны, но подставляют реальные значения локально. Файлы .env, credentials.json, serviceAccount.json, *.pem, *.key добавляются в .gitignore (файл, указывающий git, какие файлы и директории игнорировать при коммите) на этапе инициализации проекта — не после первого инцидента.

Три эшелона — pre-commit хук, .gitignore, CI-сканер — формируют эшелонированную защиту. Если один уровень пропустит секрет, следующий перехватит.

По моему опыту, основная проблема с секретами в git-истории — не техническая, а организационная. Инструменты очистки существуют давно, pre-commit хуки ставятся за пять минут, gitleaks бесплатен. Но в большинстве команд, где я разбирал инциденты, секреты продолжают утекать по одной причине: ротация воспринимается как «потом». Разработчик находит утёкший ключ, делает revert, успокаивается. Ключ не ротирован. Через месяц этот же ключ всплывает в логах несанкционированного доступа.

Единственный рабочий подход — принимать любой секрет, попавший в коммит, скомпрометированным навсегда. Не «возможно», не «если репозиторий публичный» — навсегда. Это сдвигает приоритеты: ротация становится первым действием, а не опциональным шагом «когда-нибудь после очистки». И это меняет культуру: команда перестаёт надеяться на revert и начинает строить защиту до коммита — хуки, изолированные раннеры, централизованные хранилища секретов.

Тех, кто только переходит в ИБ из разработки, это может удивить — кажется, зачем такая паранойя из-за одной строчки? Но когда видишь, как один незакрытый AWS-ключ за выходные генерирует счёт на тысячи долларов за чужие крипто-майнеры, вопросы отпадают. Если хочешь пройти базу DevSecOps системно — IB Basics на codeby.school построен вокруг таких практических сценариев.

Эту тему и смежные навыки разбирают на практике в курсе «Git. Система контроля версий» Codeby Academy.