Разработчики часто используют принудительные пуши (git push --force), чтобы переписать историю коммитов — например, когда случайно закоммитили секреты и хотят удалить их из репозитория. 

На первый взгляд кажется, что коммит исчез. Однако на самом деле GitHub его не стирает: «удаленный» коммит остается доступен по хэшу — пусть и без прямых ссылок. GitHub продолжает хранить такие «висячие» коммиты (dangling commit) вечно, а в GH Archive такие случаи фиксируются как пуш-ивенты без коммитов (zero-commit PushEvents). 

Я просканировал все такие пуш-ивенты за последние пять лет и вытащил из них секреты, за которые выручил порядка $25 000 по баг-баунти. Вместе с Truffle Security мы выложили в открытый доступ инструмент, который поможет вам отыскать собственные удаленные коммиты с забытыми секретами, прежде чем до них доберется кто-то другой.

Open-source инструмент Force Push Secret Scanner, обнаруживающий секреты в «висячих» коммитах
Open-source инструмент Force Push Secret Scanner, обнаруживающий секреты в «висячих» коммитах

Меня зовут Шарон Бризинов. Я занимаюсь исследованием низкоуровневых эксплойтов в устройствах OT/IoT, но время от времени берусь и за баг-баунти.

Недавно я опубликовал пост о том, как заработал $64 000, восстанавливая секреты из файлов, которые пользователи считали удаленными [перевод на Хабре]. Эта статья породила достаточно оживленные дискуссии. 

После публикации я много общался с разными людьми, в том числе с CEO Truffle Security Диланом, который предложил несколько интересных идей для дальнейшего изучения новых способов крупномасштабной охоты за секретами. 

В конце концов я пришел к простой идее — воспользоваться GitHub Event API и проектом GH Archive, чтобы просканировать все пуш-ивенты без коммитов на наличие секретов. 

Проекты, блоги и ресурсы, которые помогли мне в работе

По сути, я просто объединил уже готовые наработки и создал инструмент, который автоматизирует охоту на секреты. В этом посте я расскажу, как пришел к выводу, что коммиты на GitHub на самом деле никуда не исчезают — и объясню, как найти все «удаленные» коммиты.

Что подразумевается под удалением коммита?

В предыдущем посте я говорил о том, как обнаружил в репозиториях GitHub файлы, считавшиеся удаленными. В частности, мне удалось воссоздать «висячие» блобы (dangling blobs) — объекты, которые были удалены и на которые больше не ссылается ни один коммит или дерево… по крайней мере, я так считал. Побеседовав с сотрудниками Truffle, я выяснил, что на самом деле у каждому такому блобу соответствует свой «висячий» коммит. Тогда я провел небольшое исследование и нашел способ, позволяющий обнаружить 100% таких коммитов по всему GitHub. 

Допустим, вы случайно закоммитили и запушили секрет в репозиторий. Как это исправить? Обычно пользователи возвращают HEAD к предыдущему коммиту и принудительно пушат изменения:

По сути, это удаляет текущий коммит, делая его недоступным по ссылкам. Но, как выяснили neodyme и TruffleHog, даже после «удаления» коммита из репозитория GitHub помнит его вечно. Если вы знаете полный хэш коммита, то сможете получить доступ к содержимому, которое считали стертым. Более того, как обнаружили TruffleHog, не нужен даже полный хэш коммита — достаточно лишь подобрать перебором первые четыре шестнадцатеричных символа.

Принудительный пушинг: наглядный пример

Разберемся, как это работает, на примере моего тестового репозитория test-oops-commit. Попробуем найти удаленный коммит 9eedfa00983b7269a75d76ec5e008565c2eff2ef

Чтобы визуализировать наши коммиты, я подготовил простой bash-скрипт get_commits.sh, который показывает объекты типа commit, tree и blob:

git rev-list --all | while read commit; do
   echo "Commit: $commit"
   git cat-file -p "$commit" | grep '^tree\|^parent'
   git ls-tree -r "$commit"
   echo
 done

Мы начнем с создания простого репозитория с единственным коммитом (в нем — файл README.md):

Далее мы создаем новый файл secret.txt, в котором лежит наш секрет my-password-is-iloveu. Мы «случайно» коммитим и пушим секрет на GitHub.

В дереве коммитов мы видим, что появился новый коммит 9eedfa, который связан с новым деревом и новым блобом для файла secret.txt. То же самое видно при запуске git rev-list --all, git log или даже просто в веб-интерфейсе GitHub.

Упс! Мы понимаем, что допустили ошибку, и решаем удалить коммит.

Чтобы удалить коммит, перемещаем HEAD ветки на предыдущий коммит и делаем принудительный пуш:

Теперь удаляем локальную версию репозитория, снова клонируем репозиторий и смотрим на дерево коммитов. Отлично, коммит действительно исчез!

Но... мы-то помним хэш удаленного коммита. Подставляем его в URL и видим, что коммит по-прежнему открывается: 9eedfa00983b7269a75d76ec5e008565c2eff. На самом деле, даже первые четыре символа — 9eed — уже достаточно, чтобы получить доступ к коммиту. 

На этот раз мы видим сообщение о том, что коммит был не принадлежит ни одной ветке репозитория.

Почему так происходит?

Когда вы делаете принудительный пуш после отката (git reset --hard HEAD~1 и git push --force), вы удаляете из ветки ссылку на коммит, делая его недостижимым для стандартных средств навигации Git (например, git log его больше не покажет). При этом коммит всё равно остается доступен на GitHub, потому что GitHub хранит reflog. 

Почему? Точно не знаю, но в документации по GitHub есть некоторые намеки. GitHub — это не просто git-сервер. Это гораздо более сложная система со множеством уровней: пул-реквесты, форки, настройки приватности и публичности и многое другое. 

Мое предположение: для поддержки всех этих фич GitHub сохраняет все коммиты — и просто не удаляет их. Вот пара примеров:

  • Что такое пул-реквесты? Как уже писали в Aqua Security, это просто временные ветки, которые можно получить, запросив все ссылки при помощи следующей команды:

git -c "remote.origin.fetch=+refs/*:refs/remotes/origin/*" fetch origin
  • Как работает сеть форков GitHub? Что происходит, когда мы «форкаем» репозиторий? Все данные копируются, в том числе и коммиты, которые вы могли удалить.

В этих, а также, вероятно, во многих других случаях (аудит? мониторинг?) GitHub хранит все коммиты и не удаляет их, даже если вы перезаписываете HEAD и «удаляете» коммит.

GitHub Event API

Итак, на самом деле коммиты не удаляются. Допустим. Но нам всё равно нужно знать полный хэш коммита или хотя бы первые четыре шестнадцатеричных символа (без учета коллизий:16^4=65536). 

Как выяснилось, у TruffleHog есть как раз есть для этого подходящий инструмент. Но, как можно догадаться, работает он медленно — перебирает тысячи префиксов. Такой подход плохо масштабируется и может занять день-два даже для одного репозитория.

Но есть и другой, более быстрый способ. Речь о GitHub Event API — это часть REST API GitHub, которая позволяет получать информацию о событиях, происходящих на платформе. События — это различные действия пользователей, например:

  • пушинг кода;

  • открытие или закрытие issue или пул-реквестов;

  • создание репозиториев;

  • добавление комментариев;

  • создание форков репозитория;

  • добавление звезды репозиторию.

Можете попробовать его сами: 

curl http://api.github.com/events

Пара примечаний:

  • Не требуется никаких API-токенов или аутентификации!

  • Список всех типов событий, которые поддерживает GitHub, можно посмотреть здесь.

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

  • Работает только для публичных репозиториев.

Теперь мы можем мониторить данные о коммитах во всех публичных репозиториях GitHub и собирать все хэши коммитов. Больше не нужно их подбирать! Но это всё равно слишком много — речь идет о миллионах событий в час. А как же старые события? Они для нас потеряны?

К счастью для нас, ещё много лет назад замечательный разработчик Илья Григорик создал инструмент, который слушает поток событий GitHub и систематически архивирует его. Этот проект с открытым исходным кодом называется GH Archive, его сайт — gharchive.org

То есть если мы, например, хотим получить все публичные события в GitHub за 1 января 2015 года в 15:00 по UTC, то их можно просто скачать по этой ссылке.

Вот случайный пример PushEvent из архива 2015-01-01-15

Находим коммиты, удаленные через force push

Чтобы идентифицировать коммиты, удаленные принудительным пушем, достаточно поискать пуш-ивенты, в которых ноль коммитов. 

Почему пуш-ивент Git может не содержать коммитов? Это означает, что произошел принудительный пуш с откатом ветки — по сути, HEAD просто сдвинули назад, не добавляя новых коммитов! Как уже объяснил в начале статьи, я использую для таких событий термин zero-commit PushEvents.

Давайте рассмотрим небольшой пример. Скачаем произвольный архив с GH Archive и поищем там подобное событие: 

Если мы случайно выберем одно из нужных событий, то увидим, что массив commits — пустой. А если посмотрим на коммит до него (before) — на тот, что был «удален», — то увидим, что GitHub всё ещё хранит эту запись спустя десять лет!

Вот этот коммит: 

https://github.com/grapefruit623/gcloud-python/commit/e9c3d31212847723aec86ef96aba0a77f9387493

Замечу, что коммит before мог быть не единственным удаленным коммитом. Иногда принудительный пуш перезаписывает несколько коммитов за раз. 

Таким образом, зная организацию (или имя пользователя), название репозитория и хэш коммита, мы довольно легко можем просканировать содержимое «удаленного» коммита (или коммитов) и поискать чужие секреты, используя обычные Git-команды:

Что делает этот скрипт:

  • Клонирует репозиторий в минимальной конфигурации. --filter=blob:none позволяет загрузить только историю без содержимого файлов; blobs; --no-checkout — не выполнять checkout рабочей директории.

  • Получает конкретный коммит (before).

  • Запускает поиск секретов с помощью TruffleHog. TruffleHog автоматически скачивает содержимое файлов (блобов), которые нужно сканировать. 

Эта команда выполняет поиск секретов по всем коммитам, начиная с коммита before и двигаясь обратно к началу ветки. Такой подход гарантирует, что будут охвачены все данные, если принудительный пуш перезаписал сразу несколько коммитов. Но есть нюанс: при этом частично будут просканированы и обычные, не «висячие» коммиты. Выпущенный нами инструмент чуть эффективнее: он сканирует только коммиты, на которые никто не ссылается.

GitHub не указывает точных лимитов на Git-операции, но слишком частые обращения — например, массовые clone или fetch — могут привести к замедлению ответа или ограничению частоты Git-запросов. Подробнее об этом можно прочитать здесь.

Кстати, извлечь удаленный коммит можно и другими способами — например, через GitHub API или вручную через веб-интерфейс GitHub.

GitHub API

Запрашиваем патч коммита c помощью GitHub REST API: 

https://api.github.com/repos/<ORG>/<REPO-NAME>/commits/<HASH>

https://api.github.com/repos/github/gitignore/commits/e9552d855c356b062ed82b83fcaacd230821a6eb

Зарегистрированным пользователям доступно до 5000 запросов в час. Незарегистрированным — всего 60. Заголовок ответа сервера x-ratelimit-remaining показывает, сколько запросов к API у вас осталось. 

Прямой доступ через GitHub.com

Коммит можно также просмотреть прямо через веб-интерфейс GitHub. Вот пример: 

https://github.com/<ORG>/<REPO-NAME>/commit/<HASH>

https://github.com/github/gitignore/commit/e9552d855c356b062ed82b83fcaacd230821a6eb

Хотя задокументированного ограничения на частоту запросов нет, при высокой нагрузке WAF GitHub может внезапно заблокировать доступ без предупреждения.

Автоматизируем поиск секретов

Итак, теперь у нас есть все необходимые ингредиенты: мы можем получать все данные о событиях GitHub, находить пуш-ивенты без коммитов, извлекать «удаленные» коммиты (хэшbefore), а затем сканировать их на наличие секретов при помощи TruffleHog. Осталось собрать всё воедино.

Хотя… Зачем что-то собирать, если всё уже готово? Вместе с исследовательской командой Truffle Security мы создали Force Push Secret Scanner — open-source тулу, которая автоматически ищет в GH Archive все «висячие» коммиты, сделанные с аккаунта пользователя или организации. 

Дисклеймер: мы разработали этот инструмент, чтобы помочь специалистам по кибербезопасности (в частности, blue team) оценить потенциальную угрозу. Пожалуйста, применяйте его ответственно.

Вот команда для запуска:

python force_push_scanner.py --db-file /path/to/force_push_commits.sqlite3 --scan <github_org/user>

Начинаем охоту за секретами

Я просканировал все «висячие» коммиты на GitHub с 2020 года с помощью своей новой тулы и обнаружил тысячи активных секретов. Но как выделить самые интересные — те, что связаны с крупными организациями? 

Мой рецепт успеха включал всего три ингредиента: ручной отбор секретов, вайб-кодинг и нейросети (куда же без них).

Ручной поиск

Для начала я исследовал данные вручную. Созданная мной утилита сохраняет каждый найденный секрет в аккуратном JSON-файле. Вот как выглядит один из них:

На этом этапе я просматривал секреты вручную и фильтровал заведомо неинтересное. Например, отбросил все коммиты от авторов со стандартными почтовыми адресами (gmail.com, outlook.com, mail.ru и так далее) и сосредоточился на коммитах авторов с корпоративной почтой. Хоть такой подход и неидеален, он стал хорошей стартовой точкой. Мне удалось найти действительно стоящие секреты. 

Чтобы понять, насколько чувствительным является найденный ключ, я пытался разобраться, к чему он дает доступ и кто им владеет. Использовал для этого как open-source инструменты (secrets-ninja, TruffleHog Analyze), так и самописные скрипты. 

Я применял такой анализ только в отношении секретов, которые попадали под конкретные программы баг-баунти. Когда находил что-то действительно важное или потенциально уязвимое, репортил напрямую или через багбаунти-платформу.

Вайб-кодим платформу для триажа секретов

После сотни-другой ручных проверок я решил, что с меня хватит: пора было масштабироваться. С помощью Vercel v0 я навайбкодил целую платформу для анализа найденных секретов. 

Платформа была очень простой: чистый фронтенд без бэкенда, который принимал .zip с JSON-файлами от сканера и показывал их в виде удобной таблицы для быстрого просмотра. Я вручную отмечал, какие находки уже проверил, а фильтры позволяли быстро выцеплять интересные экземпляры.

А ещё я добавил визуализацию с помощью графиков, и они сразу выдали пару интересных наблюдений. 

График по месяцам ясно показал: старые секреты чаще уже отозваны или просрочены.

Чем свежее коммит — тем больше шансов, что секрет ещё активен
Чем свежее коммит — тем больше шансов, что секрет ещё активен

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

Наиболее ценными находками оказались токены доступа GitHub (personal access token, PAT) и учетки AWS. Именно они принесли самые крупные вознаграждения :)

Также я построил график по частоте утечек в зависимости от имени файла. Результат предсказуем: абсолютный лидер по утечкам — файл .env

Наряду с .env-файлами чаще всего утекали: index.js, application.properties, app.js, server.js, .env.example, docker-compose.yml, Unknown, README.md, main.py, appsettings.json, db.js, .env.local, settings.py, config.py, app.py, config.env, application.yml, config.json, config.js, WeatherManager.swift, .env.production, database.js, hardhat.config.js, script.js, App.js, .env.development, hardhat.config.ts, index.ts, config.ts, secrets.txt, main.js, index.html, docusaurus.config.js, default.json, Dockerfile, vercel.json, application-dev.yml, api-client.ts, docker-compose.yaml, api_keys.py
Наряду с .env-файлами чаще всего утекали: index.js, application.properties, app.js, server.js, .env.example, docker-compose.yml, Unknown, README.md, main.py, appsettings.json, db.js, .env.local, settings.py, config.py, app.py, config.env, application.yml, config.json, config.js, WeatherManager.swift, .env.production, database.js, hardhat.config.js, script.js, App.js, .env.development, hardhat.config.ts, index.ts, config.ts, secrets.txt, main.js, index.html, docusaurus.config.js, default.json, Dockerfile, vercel.json, application-dev.yml, api-client.ts, docker-compose.yaml, api_keys.py

Искусственный интеллект

Мое решение для триажа секретов работало прекрасно, но отсматривать все находки самостоятельно — занятие трудоемкое. Идеальное решение — построить систему, которая автоматически обрабатывает секреты, извлекая информацию о том, с каким аккаунтом они связаны. Эти данные затем можно передавать агенту на LLAMA, который бы идентифицировал потенциально ценные секреты. 

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

Кейc: предотвращаем крупномасштабный взлом через цепочку поставок

В одном из удаленных коммитов я нашел персональный токен доступа GitHub (PAT), принадлежащий разработчику. Он допустил утечку этого секрета, случайно закоммитив свои скрытые файлы конфигурации (dot files). 

Я проверил токен и выяснил, что он дает админский доступ ко ВСЕМ репозиториям Istio.

Istio — это опенсорсная service mesh-платформа для управления взаимодействием между микросервисами. У главного репозитория Istio 36 тысяч звезд и 8 тысяч форков. Им пользуются десятки компаний, работающих с распределенными приложениями (особенно на базе микросервисной архитектуры) — включая Google, IBM, Red Hat и других. 

Повторюсь: у меня появился доступ уровня ADMIN ко всем репозиториям Itsio (а их много). Теоретически я мог:

  • считывать переменные окружения; 

  • править пайплайны;

  • пушить код; 

  • создавать новые релизы; 

  • или просто удалить весь проект целиком. 

Масштаб потенциальной атаки на цепочку поставок — пугающий. К счастью, у Istio есть страница для отправки баг-репортов. Их команда отреагировала мгновенно — токен был отозван сразу после того, как я сообщил о проблеме. 

Что в итоге?

Это был очень любопытный проект. Я объединил несколько уже известных идей, автоматизировал поиск и в итоге нашел тысячи активных секретов, многие из которых пылились на GitHub годами. К тому же собрал на коленке довольно удобную платформу для охоты за секретами, позволяющую искать иголки в стоге сена. Ну и заработал более $25 000 на этих находках.

Чему нас учит этот кейс? Полагаю, разработчикам стоит избавиться от распространенного заблуждения о том, что удаление коммита спасает от утечки. Если секрет попал в историю Git — будь то блоб, коммит или что-то ещё — считайте, что он уже скомпрометирован и должен быть немедленно отозван. Это должно стать аксиомой для всех, кто пишет и выкладывает код.

PURP — телеграм-канал, где кибербезопасность раскрывается с обеих сторон баррикад

t.me/purp_sec — инсайды и инсайты из мира этичного хакинга и бизнес-ориентированной защиты от специалистов Бастиона

Комментарии (1)


  1. Alesh
    15.07.2025 10:31

    Интересно, многих пробил холодный пот?)