Git bisect: поиск уязвимого коммита в open-source проекте перед пентестом кода

Вот иллюстративный сценарий, смоделированный по мотивам типичных уязвимостей в open-source Rust-библиотеках: макрос derive(Deserialize) генерирует код десериализации, не проверяющий инвариант длины буфера. Поле data типа Vec<T> должно содержать ровно nrows * ncols элементов, но автоматическая реализация принимает произвольные значения — и допускает чтение памяти за пределами аллокации. Патч уже в мастере, но для формирования advisory нужно установить затронутые версии. Репозиторий — почти две тысячи коммитов. Перебирать руками? git bisect сводит поиск уязвимого коммита к одиннадцати проверкам вместо линейного просмотра всей истории. Конкретные хеши коммитов и детали далее приведены для демонстрации методики и могут не соответствовать реальной истории какого-либо репозитория.
Git для пентестера: зачем восстанавливать историю уязвимости
Первый вопрос после обнаружения уязвимости в зависимости — с какой версии она существует? Ответ определяет окно экспозиции: сколько времени каждый пользователь библиотеки был под ударом. Для формализованных advisory — GHSA (GitHub Security Advisory, стандартизированное уведомление об уязвимости в open-source пакете) или CVE (Common Vulnerabilities and Exposures, публичный идентификатор уязвимости) — нужно заполнить поле «affected versions». Без точного диапазона версий advisory неполон: сканеры зависимостей вроде Dependabot, Snyk или Trivy не смогут корректно предупредить разработчиков.
Теперь про контекст угрозы. В MITRE ATT&CK — открытой базе тактик и техник атак, где каждая техника имеет T-код (T1190, T1195 и т.п.) — атаки через уязвимые зависимости описаны техникой T1195.001 (Compromise Software Dependencies and Development Tools). Если коротко: атакующий находит уязвимую стороннюю зависимость в вашем проекте и эксплуатирует её. Сам процесс поиска уязвимого коммита через git bisect и составления advisory — defensive-активность, но T1195.001 описывает тот вектор атаки, от которого эта работа защищает. Смежная T1195.002 (Compromise Software Supply Chain) — про компрометацию самого процесса сборки или поставки ПО (как в случае SolarWinds), что выходит за рамки нашего сценария. Обе относятся к тактике Initial Access. Параллельно работает T1588.006 (Vulnerabilities, тактика Resource Development) — получение или поиск уязвимостей для использования в ходе атаки.
OWASP (Open Worldwide Application Security Project — открытый проект по безопасности приложений) выделяет проблему уязвимых компонентов в категорию A06:2021 — Vulnerable and Outdated Components: использование библиотек и фреймворков с известными уязвимостями. Рядом — A08:2021 — Software and Data Integrity Failures: код без защиты от нарушений целостности, включая небезопасную десериализацию, как в нашем примере с nalgebra.
Где git bisect встраивается в процесс пентеста кода:
- Получение исходного кода — клонирование репозитория (white box или grey box сценарий)
- Идентификация уязвимости — SAST (статический анализ: semgrep, bandit), ручной review или данные из публичных advisory
- Определение окна экспозиции —
git bisectдля поиска коммита, внёсшего уязвимость - Маппинг на версии — привязка найденного коммита к релизным тегам через
git tag --contains - Отчёт или advisory — затронутые версии, рекомендации по обновлению, оценка импакта
Без третьего шага отчёт выглядит так: «уязвимость есть». Но нет ответа на «как давно?» и «кто ещё затронут?». А это — ровно то, что нужно для incident response.
Как git bisect находит коммит с уязвимостью
git bisect — бинарный поиск по истории коммитов. Алгоритм принимает два ориентира: коммит, где уязвимости ещё нет (в терминах bisect — good), и коммит, где она уже есть (bad). Git выбирает коммит посередине диапазона, переключает рабочую директорию на него (делает checkout — переход к определённому состоянию кода) и ждёт решения. Вы проверяете, присутствует ли уязвимый паттерн, и сообщаете результат: git bisect good или git bisect bad. Git сужает диапазон вдвое и повторяет — пока не останется один коммит.
Сложность — O(log N):
- 100 коммитов — около 7 проверок
- 1 000 коммитов — около 10 проверок
- 2 000 коммитов — около 11 проверок
- 1 000 000 коммитов — около 20 проверок
Линейный просмотр тех же 2 000 коммитов — в среднем 1 000 проверок. Разница ощутимая.
Отличие от разработческого использования: разработчик обычно проверяет «ломается тест или нет». При secure code review проверка звучит иначе: «присутствует ли уязвимый паттерн в коде». Проверкой может быть запуск semgrep (статический анализатор с поддержкой custom-правил на YAML) с правилом на небезопасную десериализацию, поиск строки через rg (ripgrep — быстрый grep, написанный на Rust) или запуск юнит-теста, воспроизводящего уязвимость.
Secure code review: поиск бага в git истории шаг за шагом
Требования к окружению
- Git: версия 2.x и выше. Проверка:
git --version - Терминал: bash, zsh или совместимый shell. На Windows — WSL или Git Bash
- Клонированный репозиторий целевого проекта с полной историей. Shallow clone (
--depth N) не подходит: bisect не увидит старые коммиты - ripgrep для быстрого поиска по кодовой базе:
apt install ripgrep(Debian/Ubuntu) илиbrew install ripgrep(macOS) - Для автоматизации (опционально): semgrep (
pip install semgrep) или bandit (pip install bandit) — анализатор безопасности Python-кода - RAM: сам git bisect оперирует метаданными и не требует много памяти, но проверка на каждом шаге (сборка, тесты) может потребовать значительно больше ресурсов для крупных проектов
- Чистое рабочее дерево: убедитесь, что
git statusпоказываетnothing to commit, working tree clean. Есть незакоммиченные изменения —git stashперед стартом
Делай раз, делай два, делай три
Разберём поиск уязвимого коммита на иллюстративном примере, смоделированном по мотивам публичных advisory для библиотеки nalgebra. Конкретные хеши коммитов и детали ниже приведены для демонстрации методики и могут не соответствовать реальной истории репозитория. Задача: найти коммит, в котором появилась автоматически генерируемая реализация Deserialize без валидации инвариантов структуры данных.
Шаг 1. Определите границы поиска. Начните с коммита, где уязвимость точно есть. Если фикс уже известен — возьмите коммит непосредственно перед ним: символ ^ в git-нотации означает «родительский коммит». Для нашего случая фикс — коммит 5bff536, значит 5bff536^ — последний уязвимый. Для «хорошего» коммита подойдёт самый ранний релизный тег или — если нет точной информации — самый первый коммит репозитория.
Шаг 2. Запустите bisect и задайте границы.
git clone https://github.com/dimforge/nalgebra.git
cd nalgebra/
git bisect start
git bisect bad 5bff536^
git bisect good $(git rev-list --max-parents=0 HEAD | head -1)
Команда git rev-list --max-parents=0 HEAD возвращает хеш(и) корневых коммитов в истории; head -1 берёт один из них. На этом этапе HEAD (указатель на текущую ветку или конкретный коммит в состоянии detached HEAD, как во время bisect) уже указывает на bad-коммит (после git bisect bad), поэтому rev-list от HEAD корректно находит корни всей релевантной истории. Если в репозитории несколько корневых коммитов (например, после merge независимых веток) — убедитесь, что выбран нужный корень. Ожидаемый вывод: Bisecting: 980 revisions left to test after this (roughly 10 steps). Git автоматически переключит рабочую директорию на коммит в середине диапазона.
Шаг 3. На каждом шаге проверяйте наличие уязвимого паттерна. Выполните rg 'struct VecStorage' -B 1 (флаг -B 1 показывает одну строку выше найденного совпадения). Если в выводе видна строка derive(Serialize, Deserialize) над определением структуры — коммит уязвим, выполняйте git bisect bad. Если структуры VecStorage нет в коде или Deserialize отсутствует — git bisect good. После каждой пометки Git обновляет диапазон, показывает Bisecting: N revisions left... и переключает на следующий коммит. Повторяйте до появления сообщения <hash> is the first bad commit.
Шаг 4. Обработайте краевые случаи. Bisect может привести к коммиту переименования вместо реального внесения уязвимости. В нашем случае первый результат — коммит 0f66403 с сообщением Rename MatrixVec to VecStorage. Структура была переименована, но уязвимый derive существовал и раньше, под старым именем. Решение: git bisect reset, затем git checkout 0f66403, новый bisect с паттерном rg 'struct MatrixVec' -B 1. Итог — коммит 086e6e7 от февраля 2017 года.
Шаг 5. Привяжите коммит к версиям. Команда git tag --contains 086e6e7 покажет все теги (релизы), содержащие виновный коммит — это затронутые версии. Обратная операция git tag --no-contains 086e6e7 — версии до внесения уязвимости. Эти данные идут в поле affected / unaffected advisory. Выполните git show 086e6e7 для просмотра полного diff.
Шаг 6. Завершите bisect. Обязательно выполните git bisect reset — команда возвращает HEAD туда, где вы были до старта. Забудете — продолжите работать на промежуточном коммите, и потом будете разбираться, почему код «сломался».
Git bisect для безопасности: автоматизация поиска регрессии
Ручной bisect требует на каждом шаге запускать проверку и интерпретировать результат. git bisect run принимает скрипт и прогоняет весь цикл без вашего участия. Правила для скрипта:
- Код возврата
0— коммит хороший (уязвимости нет) - Код возврата
1–124или126–127— коммит плохой (уязвимость присутствует) - Код возврата
125— коммит невозможно проверить (файл отсутствует, проект не собирается), пропустить
Пример скрипта для автоматического поиска уязвимого паттерна десериализации:
#!/bin/bash
# check_vuln.sh — скрипт для git bisect run
if ! [ -f "src/base/vec_storage.rs" ]; then
exit 125 # файл не существует в этой версии, пропускаем
fi
if rg -q 'derive.*Deserialize' src/base/vec_storage.rs; then
exit 1 # паттерн найден — коммит уязвим
fi
exit 0 # паттерн отсутствует — коммит чист
Сделайте скрипт исполняемым через chmod +x check_vuln.sh, затем после установки границ bisect выполните git bisect run ./check_vuln.sh. Git прогонит весь бинарный поиск автоматически — на ripgrep каждый шаг занимает доли секунды, весь bisect завершается за секунды даже на двух тысячах коммитов.
Для Python-проектов вместо ripgrep подойдёт bandit: скрипт вызывает bandit -r src/ -t B301 (B301 — правило на небезопасный pickle), и ненулевой код возврата bandit автоматически помечает коммит как плохой. Для Node.js аналогичную роль играют ESLint security-плагины. Принцип тот же: любая команда, различающая «уязвимо» и «безопасно» через код возврата, совместима с git bisect run.
Важный момент: если сборка проекта падает на промежуточном коммите, возвращайте 125 (skip), а не 1 (bad). Невозможность собрать код — не признак уязвимости, а техническая проблема конкретного состояния репозитория. При ручной проверке вместо 125 используйте команду git bisect skip — Git попробует соседний коммит.
При ручном подходе — 2 минуты на шаг, 11 шагов — это 22 минуты живого времени. Автоматизированный скрипт с rg отрабатывает тот же сценарий за секунды. На крупных репозиториях с сотнями тысяч коммитов разница ещё драматичнее.
Git blame vs bisect: анализ коммитов open-source
git bisect — не единственный инструмент для работы с историей кода при security review. Три подхода решают разные задачи:
| Инструмент | Что делает | Когда использовать |
|---|---|---|
git bisect |
Находит коммит, внёсший изменение | Знаете «до» и «после», но не знаете «когда именно» |
git blame |
Показывает автора и коммит для каждой строки файла | Уже нашли уязвимый код, нужен контекст авторства |
git log -S / -G |
Ищет коммиты, где строка появилась или исчезла | Отслеживаете историю конкретного паттерна или функции |
git blame выводит построчную аннотацию файла: для каждой строки — хеш коммита, автор и дата последнего изменения. Выполните git blame src/base/vec_storage.rs, чтобы увидеть, кто и когда добавил строку с derive(Deserialize). После bisect это даёт контекст: прошёл ли код ревью, есть ли паттерн — один и тот же разработчик систематически вносит уязвимые конструкции.
git log -S (pickaxe search) находит коммиты, в которых указанная строка была добавлена или удалена. Команда git log -S 'derive(Deserialize)' -- src/ покажет все коммиты, где эта строка появлялась или исчезала в каталоге src/. Флаг -G принимает регулярное выражение: git log -G 'derive.*Deserialize' --oneline найдёт коммиты с любым вариантом derive-макроса, содержащим Deserialize. Pickaxe полезен, когда нужно быстро получить «историю жизни» конкретного опасного паттерна в кодовой базе без полного bisect-прогона.
Комбинированный workflow для security review:
git log -S 'опасный_паттерн'— получить список коммитов, связанных с подозрительным кодомgit bisect— точно определить первый коммит с уязвимостью, когда pickaxe даёт слишком много результатов или паттерн претерпевал рефакторингgit blame— после нахождения коммита изучить авторство и контекст каждой строкиgit show <hash>— просмотреть полный diff виновного коммита, оценить объём изменений
За минуты вы проходите от «где-то в зависимости есть уязвимость» до «вот коммит, вот автор, вот затронутые версии, вот оценка того, прошёл ли изменённый код ревью».
Ограничения git bisect при поиске уязвимого коммита
Переименование и рефакторинг. Если уязвимый код был переименован (как MatrixVec в VecStorage), bisect приведёт к коммиту переименования, а не к реальному внесению проблемы. Автоматизированный скрипт должен учитывать все варианты имени — либо потребуется перезапуск с новым паттерном.
Merge-коммиты. В репозиториях с активным merge workflow бинарный поиск проходит через merge-коммиты, которые сами не содержат изменений кода. Флаг --first-parent при старте (git bisect start --first-parent) ограничивает поиск основной линией истории, игнорируя ответвления.
Нестабильные проверки. Если проверка даёт разный результат на одном и том же коммите (зависимость от внешнего сервиса, race condition в тестах), bisect придёт к ложному результату. Решение: максимально детерминированная проверка — поиск статического паттерна в коде через rg, а не runtime-тест с сетевыми зависимостями.
Несобирающиеся коммиты. Ручной режим — git bisect skip. Автоматизация — код возврата 125. При большом количестве skip подряд Git вместо одного коммита выдаст диапазон: «виновник где-то между commit A и commit B» — и дальше придётся разбираться руками.
Уязвимости без бинарного сигнала. Bisect требует однозначного «уязвим / не уязвим». Для проблем вроде недостаточной энтропии генератора случайных чисел или race condition формализовать проверку в скрипт бывает невозможно. В таких случаях git log -S по имени подозрительной функции часто работает быстрее.
Масштаб. Для монорепозиториев с сотнями тысяч коммитов ограничьте bisect конкретным путём: git bisect start -- src/crypto/. Git будет рассматривать только коммиты, затрагивающие файлы в указанном каталоге, — это существенно сокращает пространство поиска.
Навык работы с git bisect в контексте безопасности разделяет два типа отчётов. Первый: «обнаружена небезопасная десериализация в endpoint /api/data». Второй: «уязвимость внесена коммитом abc123 от 12.03.2023, затронуты версии 1.2–1.5, окно экспозиции — 14 месяцев, code review на PR не проводился». Для заказчика разница принципиальна — второй вариант даёт данные для incident response, первый оставляет с вопросом «и что теперь?».
Большинство пентестеров работают с кодом как с чёрным ящиком: текущая версия, уязвимость, рекомендация. История коммитов остаётся за скобками. В white box и grey box сценариях — а это большинство корпоративных проектов — git-история содержит контекст, который влияет на приоритизацию находки. Кто добавил уязвимый код, прошёл ли он ревью, были ли неудачные попытки исправления — всё это меняет risk rating в итоговом отчёте и оценку зрелости процессов разработки у заказчика.
Я пришёл в безопасность из разработки и первые полгода использовал git только для clone и pull. Bisect, blame, log -S — три команды, которые вместе дают полную картину жизни уязвимости в кодовой базе. Осваивать их лучше до того, как столкнётесь с задачей «найди, когда это сломалось» на реальном проекте с дедлайном. На курсе IB Basics от Codeby Academy такие фундаментальные пробелы закрываются за пару месяцев — без самостоятельного собирания программы из разрозненных туториалов.
Эту тему и смежные навыки разбирают на практике в курсе «Git. Система контроля версий» Codeby Academy.