
TL;DR: я построил систему, которая клонирует и сканирует тысячи публичных GitHub-репозиториев — и находит в них утекшие секреты.
В каждом репозитории я восстанавливал удаленные файлы, находил непривязанные («висячие») объекты, распаковывал .pack-файлы и находил API-ключи, активные токены и учетки. А когда сообщил компаниям об утечках, заработал более $64 000 на баг-баунти.
С чего все началось
Меня зовут Шарон Бризинов. Я занимаюсь исследованием низкоуровневых эксплойтов в устройствах OT/IoT и время от времени охочусь за уязвимостями в рамках баг-баунти.
Многие багхантеры сканируют репозитории GitHub в поисках случайно засвеченных учетных данных. Я решил копнуть глубже: восстанавливать секреты из файлов, которые авторы считали удаленными. Разработчики часто забывают: если что-то попало в Git, оно остается в истории — даже если из рабочей директории всё подчистили.
Чтобы проверить гипотезу, я просканировал десятки тысяч корпоративных репозиториев, анализируя их историю коммитов в поисках конфиденциальных данных. Результаты впечатлили: я обнаружил множество удаленных файлов с API-ключами, лог��нами, паролями и даже действующими токенами сессий.
Ниже расскажу, как собирал репозитории, писал скрипты, находил секреты и отправлял отчеты об их утечке.
Внутреннее устройство Git
Для начала советую прочитать статью How Git Internally Works — она ясно и просто объясняет внутреннее устройство Git.
Git — это распределенная система управления версиями, которая отслеживает изменения в файлах и позволяет разработчикам совместно работать над проектами. Она сохраняет полную историю изменений, позволяя при необходимости возвращаться к предыдущим состояниям, создавать ветки и объединять изменения. По сути Git устроен как файловая система с адресацией по содержимому, в которой каждая версия файла хранится в репозитории как уникальный объект.
Git отслеживает всё — файлы, папки, коммиты — в виде объектов, каждый из которых идентифицируется хэшем SHA-1 или SHA-256 (в зависимости от конфигурации). Существует четыре типа объектов:
Блоб (blob, binary large object) — объект с содержимым файла.
Дерево (tree) — отражает структуру каталогов.
Коммит (commit) — снэпшот + метаданные.
Тэг (tag) — аннотированная метка.
Блобы и .pack-файлы
Блоб — это объект, в котором Git хранит содержимое файла. Он не содержит информации об имени файла или его расположении — только сами данные.
Когда Git впервые сохраняет объект, он записывает его как несвязанный объект (loose object), примерно в таком виде:
.git/objects/ab/cdef1234567890...Здесь ab — это первые два символа SHA-хэша, а cdef1234567890… — его продолжение. Данные сжимаются с помощью алгоритма zlib и представляют собой содержимое одного файла.
Для экономии пространства и повышения производительности Git упаковывает несвязанные объекты в .pack-файлы. По умолчанию это происходит, когда число loose-объектов достигает 6700.
.git/objects/pack/pack-<hash>.pack.pack-файлы имеют сложную и очень интересную структуру. К счастью, чтобы извлечь их содержимое, нам не обязательно понимать детали формата — достаточно воспользоваться командойgit-unpack-objects.

Иногда в репозитории появляются «висячие» объекты (dangling objects) — валидные коммиты, блобы, деревья или теги, на которые больше не ссылается ни одна ветка, тег, stash или reflog. Обычно они возникают при переписывании истории — например, при использовании команд git commit --amend, rebase, reset или при удалении ветки. Хотя такие объекты уже не входят в активную историю, Git по умолчанию хранит их еще в течение двух недель, чтобы их можно б��ло восстановить. Найти такие объекты можно с помощью команды git fsck --dangling.
Как устроены коммиты в Git
Каждый коммит в Git представляет собой снэпшот репозитория в определенный момент времени. Коммиты неизменяемы, они идентифицируются по хэшу SHA-1/SHA-256.
Коммит содержит:
Ссылку на объект дерева, описывающий структуру файлов.
Указатели на родительские коммиты, формирующие граф с историей изменений.
Метаданные, в том числе имя автора, временную метку и сообщение коммита.
Благодаря дельта-сжатию Git эффективно хранит коммиты: записывает только изменения, а не полные копии файлов.

Почему удаленные файлы не исчезают
Когда файл удаляется при помощи git rm или просто перемещается из рабочей директории и изменения коммитятся, он исчезает из текущего снэпшота, но все равно продолжает храниться в истории репозитория. Это происходит по двум причинам:
Коммиты в Git неизменны. После создания каждый коммит и все связанные с ним объекты сохраняются в
.git/objectsи продолжают существовать, даже если на них больше не ссылаются ни одна ветка, ни один тег. Объекты, на которые ничто не ссылается («висячие»), не удаляются сразу — обычно они хранятся около двух недель, прежде чем будут удалены системой по сборке мусора.Ссылки (refs) удерживают объекты от удаления. Git хранит ссылки в head, тэгах и удаленных репозиториях. Даже если в более позднем коммите файл был удален, старые коммиты все еще содержат его.
Чтобы действительно удалить файл из истории, нужно переписать историю либо с помощью инструментов вроде git filter-branch и git-filter-repo, либо вручную через rebase с последующей сборкой мусора (с prune) — это нужно для удаления «висячих» объектов. Но если репозиторий публичный, файл уже могли скопировать или клонировать, и тогда важно как можно скорее отозвать все API-ключи, токены, сессионные идентификаторы и другие секреты.
Чтобы лучше понять, как Git обрабатывает файлы и каталоги, я напис��л небольшой инструмент, визуализирующий изменения в структуре репозитория: какие объекты создаются, какие удаляются. Это была, конечно, избыточная затея для этого проекта — но в духе «вайб-кодинга» я справился за пять минут, так что почему бы и нет.

Главный вопрос: как нам получить все удаленные файлы?
Можно восстановить удаленные файлы, сравнивая родительские и дочерние коммиты с помощью
git diff.Распаковать все файлы .pack с помощью
git unpack-objects < .git/objects/pack/pack-<SHA>.pack.Найти «висячие» объекты через
git fsck — full — unreachable — dangling.
Чтобы собрать все удаленные файлы, я прошел по каждому коммиту и сравнил его с родительским (git diff). Если в списке были файлы со статусом D (удален), я восстанавливал их через git show и сохранял на диск.
Конечно, это не самый лучший и эффективный способ, но для моих целей он сработал. Вот небольшой proof-of-concept-скрипт, выполняющий эту задачу:
#!/bin/bash
# cd в клонированный репозиторий
mkdir -p "__ANALYSIS/del"
# Извлекаем все коммиты и обрабатываем каждый
git rev-list --all | while read -r commit; do
echo "Processing commit: $commit"
# Получаем родительский коммит
parent_commit=$(git log --pretty=format:"%P" -n 1 "$commit")
if [ -z "$parent_commit" ]; then
continue
fi
parent_commit=$(echo "$parent_commit" | awk '{print $1}')
# Получаем diff коммита
git diff --name-status "$parent_commit" "$commit" | while read -r file_status file; do
# Заменяем / на _ для имен файлов в binary_files_dir
safe_file_name=$(echo "$file" | sed 's/\//_/g')
# Обрабатываем удаленные файлы
if [ "$file_status" = "D" ]; then
# Обрабатываем двоичные файлы
echo "Binary file deleted: $file" | tee -a "__ANALYSIS/del.log"
echo "Saving to __ANALYSIS/del/${commit}___${safe_file_name}"
git show "$parent_commit:$file" > "__ANALYSIS/del/${safe_file_name}"
fi
done
doneА вот однострочник, который я использовал для извлечения всех «висячих» блобов:
mkdir -p unreachable_blobs && git fsck --unreachable --dangling --no-reflogs --full - | grep 'unreachable blob' | awk '{print $3}' | while read h; do git cat-file -p "$h" > "unreachable_blobs/$h.blob"; doneПоиск целей
Теперь, когда PoC-скрипт по восстановлению удаленных файлов был готов, нужно было собрать как можно больше релевантных GitHub-репозиториев. В нашем случае «релевантные» = принадлежащие компаниям, которые участвуют в баг-баунти.
Первым делом я составил список компаний, участвующих в публичных и приватных программах.
Где искать информацию о публичных программах
Кроме того, я решил изучить GitHub-аккаунты компаний, у которых есть хотя бы один репозиторий с 5000+ звезд. Для этого я воспользовался таким однострочником:
for page in {1..100}; do gh api "search/repositories?q=stars:>5000&sort=stars&order=desc&per_page=50&page=$page" --jq '.items[].full_name'; done | cut -d '/' -f 1Упорядочиваем аккаунты
Я собрал огромный список с названиями компаний и сохранил его в файл companies.txt. Теперь нужно было найти их публичные аккаунты на Github. Можно было придумать что-то умное, но я выбрал самый ленивый способ — использовать ИИ. Просто отправлял нейросетям наименования компаний с просьбой найти GitHub-аккаунты, связанные с той или иной организацией. Несколько аккаунтов ИИ выдумал, но в целом сработало неплохо.


Еще я довольно быстро заметил, что у многие компании держат по несколько аккаунтов на GitHub: отдельный для основной разработки, отдельный для QA и так далее. С этого момента я искал аккаунты по ключевым словам вроде: lab, research, test, qa, samples, hq, community.
Идем дальше
Затем я прошерстил собранные аккаунты и репозитории, чтобы найти форки других проектов. Для каждого форка я находил оригинальный репозиторий и добавлял в список мониторинга аккаунты, связанные с этим исходным проектом.
Я рассуждал так: если какая-то компания опубликовала код, который активно форкают другие участники из моего списка, значит, у этой компании могут быть и другие репозитории с утекшими секретами — и эти секреты вполне могут попасть в чужие проекты вместе с кодом.

В итоге я собрал несколько тысяч корпоративных GitHub-аккаунтов. Пора было переходить к технической части.
Собираем систему по поиску секретов
Сама система была довольно простой: я клонировал проекты всех компаний, восстанавливал удаленные файлы и искал в них активные (сохранившие актуальность) секреты.
Упрощенный псевдокод:
- foreach company in companies:
- foreach repo in comapny.repos:
- restore all deleted files
- foreach file in files:
- collect secrets
- foreach secret in secrets:
- is secret active?
- notify via Telegram botВесь процесс состоял из нескольких этапов:
подготовка машин;
клонирование репозиториев;
восстановление удаленных файлов;
поиск секретов;
отправка уведомления о найденных секретах мне в Telegram;
удаление репозиториев.

1. Подготовка машин
Я использовал 10 серверов: часть из них — облачные машины (например, EC2), часть — VPS, и даже пара физических устройств с Raspberry Pi. Я проследил, чтобы на каждом узле было достаточно свободного пространства — не менее 120 ГБ. Затем разбил список компаний на десять блоков и распределил их между серверами.
2. Клонирование репозиториев
Для получения списка репозиториев отдельно взятой компании на GitHub я использовал CLI-инструмент gh:
for REPO_NAME in $(gh repo list $ORG_NAME -L 1000 --json name --jq '.[].name');
do
FULL_REPO_URL="https://github.com/$ORG_NAME/$REPO_NAME.git"
git clone "$FULL_REPO_URL" "$REPO_NAME"
done;3. Восстановление файлов
В рамках этого шага удаленные файлы восстанавливались с помощью методов, которые описал выше.
4. Поиск секретов
Теперь нужно было просканировать восстановленные файлы на наличие активных секретов. Здесь мне помог TruffleHog — мощный инструмент для поиска секретов, который глубоко проверяет содержимое репозитория.
TruffleHog поддерживает более 800 типов ключей и умеет верифицировать найденные секреты, чтобы отсеять ложные срабатывания. Помимо этого он способен обнаруживать данные в base64 и некоторых архивных форматах.
Я запускал Trufflehog с флагом only-verified, чтобы сохранять только те секреты, которые прошли проверку и с высокой вероятностью сохранили актуальность. Еще использовал аргумент filesystem, чтобы просканировать диск и найти восстановленные файлы:
trufflehog filesystem --only-verified --print-avg-detector-time --include-detectors="all" ./ > secrets.txtОдним из ключевых преимуществ использования TruffleHog для локальных клонов было то, что он сканирует директорию .git. Благодаря тому, что он умеет распаковывать и анализировать потоки, сжатые через zlib, большинство несвязанных объектов автоматически оказывались в области видимости без лишней возни. TruffleHog также анализировал .pack-файлы — и они несколько раз приятно меня удивили.
Вы спросите: если TruffleHog умеет распаковывать и сканировать объекты Git, зачем тогда вручную восстанавливать удаленные файлы? Ответ прост — это значительно повышало эффективность нахождения секретов. В некоторых случаях .pack-файлы и потоки были слишком большими для корректной обработки, а иногда — перемешаны и упакованы с использованием разных форматов, из-за чего инструменты не справлялись со сканированием в сыром виде. Извлечение максимально возможного количества файлов значительно повышало мои шансы обнаружить утекшие секреты.
5. Уведомления о верифицированных секретах
Как только TruffleHog находил активный секрет, я получал уведомление в Telegram:
curl -F chat_id="XXXXXXXXXXXXX" \
-F document=@"$ORG_NAME.$REPO_NAME.secrets.txt" \
-F caption="New secerts - $ORG_NAME - $REPO_NAME" \
'https://api.telegram.org/botXXXXXXXXXXXXX:XXXXXXXXXXXXX/sendDocument'Почему Telegram? Просто привычный мне способ получения уведомлений. Конечно, под эту задачу можно было сделать отдельный бэкенд с базой данных, но к чему эти напряги? :)
6. Очистка
После обработки репозиторий удалялся, чтобы освободить место, и скрипт переходил к следующему.
Находки и вознаграждение
Что же мне удалось найти? Сотни секретов, которые утекли — и при этом оставались актуальными. Впрочем, помимо секретов, которые реально использовались в production-среде, попадалась и всякая ерунда — например, тестовые аккаунты и canary-токены.
Самые «вкусные» секреты
Ниже — список самых ценных токенов и ключей, которые мне удалось обнаружить, а также примерные суммы вознаграждений.
Токены GCP/AWS — $5000–$15000 🔥🔥🔥
Токены Slack — $3000–$10000 🔥🔥
Токены Github — $5000–$10000 🔥🔥
Токены OpenAPI — $500–$2000 🔥
Токены HuggingFace — $500–$2000 🔥
Токены Algolia Admin — $300–$1000 🔥
Учетные данные SMTP — $500–$1000 🔥
Токены и сессии разработчиков конкретных платформ — $500–$2000 🔥

Менее интересные находки
Несмотря на большое количество действительно ценных токенов, многие из них оказались бесполезными: они принадлежали тестовым аккаунтам или были canary-токенами (ловушками для злоумышленников). Такие ловушки генерируются с помощью сервисов вроде CanaryTokens: при срабатывании токена владельцу даже приходит уведомление на почту. Это удобный и эффективный способ отслеживания утечек.
Другие виды секретов-пустышек:
Тестовые приватные ключи для Github. Многие проекты содержали приватные ключи, связанные с тестовыми пользователями — например, aaron1234567890123. Такие ключи встречались в сотнях репозиториев.
Одноразовые аккаунты. Аккаунты, используемые для read-only или для обхода ограничений частоты запросов.
Открытые токены API Web3. Сотни проектов Web3 содержали открытые ключи, предназначенные для обращения к блокчейну — чтобы получить, например, информацию о транзакциях или курсах криптовалют. Чаще всего встречались токены Infura и Alchemy.
Фронтенд-ключи API: некоторые сервисы (например, Algolia) предполагают, что ключ будет использоваться во фронтенде. Обычно такие ключи имеют только права на чтение. Проблема возникает, когда разработчик случайно использует токен с правами администратора вместо безопасного ключа для поиска.


Почему секреты вообще утекали?
Проанализировав десятки критических случаев, я пришел к выводу, что секреты чаще всего утекали по одной из трех причин: непонимание работы Git, незнание состава файлов, попадающих в коммит, и чрезмерная вера в инструменты переписывания истории.
Нехватка знаний о Git
Некоторые разработчики просто не знали, как Git на самом деле сохраняет данные. Они могли ��лучайно закоммитить незашифрованные токены, секреты или учетные данные. Осознав ошибку, они удаляли файл или вырезали секрет вручную — но не отзывали его. Git сохраняет всю историю, и если восстановить старые состояния, эти секреты по-прежнему можно найти.
Непонимание состава коммита
В ряде случаев разработчики не использовали .gitignore и случайно коммитили двоичные файлы — например, .pyc (скомпилированные Python-файлы), которые содержали секреты. Эти файлы позже удалялись, но в истории они оставались.
Были и случаи, когда в коммит попадали скрытые файлы (например, .env) или архивы .zip, содержащие такие файлы. Даже после удаления этих артефактов восстановление было возможно, и секреты оказывались уязвимыми.
Слепое доверие инструментам переписывания истории
В одном примере команда случайно закоммитила секреты в свой репозиторий. Позже они применили инструменты для переписывания истории, чтобы удалить следы, но в .pack-файле всё еще сохранялась ссылка на объект. Я смог восстановить этот секрет и уведомил разработчиков, чтобы они устранили проблему до её эксплуатации.



Большинство утекших секретов было найдено в двоичных файлах, которые закоммитили в репозиторий, а позже удалили. Эти файлы обычно были сгенерированы компиляторами или автоматизированными процессами. Самый частый пример — .pyc-файлы, в которых Python-интерпретатор сохраняет байт-код. Эти файлы часто попадают в коммит случайно.
Другой распространенный случай — файлы отладки .pdb, создаваемые компиляторами. Они тоже содержат чувствительные данные и нередко оказываются в истории репозитория.
Подводим итог
Проект выдался не просто полезным, а чертовски увлекательным. Массовое локальное клонирование репозиториев GitHub и восстановление удаленных файлов оказалось эффективной (и весьма прибыльной) тактикой для охоты на утекшие секреты.
Заодно я основательно прокачался в анатомии Git и даже создал в процессе несколько собственных инструментов. Один из них — HexShare — открыт для всех желающих.
А дальше — больше. Отправил отчеты, получил фидбек, помог улучшить безопасность нескольким крупным компаниям. И в довесок заработал $64 350 на баг-баунти ;)
