
Привет, Хабр. Я Матвей Лихота, старший Go-разработчик и DevSecOps. Как это часто бывает с инструментами, идея этой утилиты появилась уже после того, как у нас упал прод. Во время хотфикса сотрудник забыл обновить в одном из двух сервисов версию библиотеки с контрактами и в результате в зависимостях осталась предыдущая версия. Перед слиянием код собирался и тесты проходили, но после деплоя сервис упал с ошибкой 500: новое поле в структуре запроса не появилось на сервере.
Пришлось откатить изменения, даунтайм был ощутимый. Снова попадать в такую ситуацию никому, конечно, не хотелось.
Можно было бы добавить пункт в предрелизный чек-лист, но кто будет заполнять его во время хотфикса? Можно было еще раз напомнить разработчикам про версии, но такие напоминания работают от силы пару недель, а потом об этом снова забывают.
Тогда я решил, что теперь проверять актуальность зависимостей будет CI. Так и появился Dependency Validator.
В чем заключалась проблема
Сначала немного контекста. Во многих микросервисных проектах контракты лежат в отдельном репозитории и подключаются как обычная зависимость. Это могут быть структуры событий, модели запросов и ответов, gRPC-контракты или сгенерированные клиенты.
Когда выходит новая версия контрактов, сервисы должны обновить соответствующий пакет. Но никто нам не гарантирует, что версия зависимости в сервисе действительно соответствует последнему релизу репозитория.
В итоге вполне возможна такая ситуация:
версия репозитория контрактов: v1.8.2
версия в сервисе: v1.8.1
Само по себе отставание — не всегда проблема: иногда сервису и правда не нужна последняя версия. Но для некоторых внутренних библиотек актуальность критична, например, если контракты меняются вместе со структурой ответов API.
В нашем случае версия устарела случайно — про нее просто забыли. Это навело меня на мысль: пусть проверяются только те зависимости, где пропуск обновления реально опасен. Например, контракты и ключевые внутренние библиотеки.
Мы не ставили цель обновлять все подряд, заменять Dependabot или управлять всем деревом пакетов. Задача заключалась в том, чтобы взять несколько ключевых репозиториев и проверять, что сервис при релизе использует их последние версии.
Как я хотел, чтобы это работало
Я сформулировал для себя несколько требований к будущему инструменту:
список проверяемых репозиториев задается конфигурацией;
поддерживаются закрытые репозитории;
проверка работает с проектами на разных языках;
перед вливанием в master устаревшая зависимость блокирует пайплайн;
в остальных мердж-реквестах проверка только напоминает об обновлении.
Последний пункт был для меня особенно важен. Если сделать проверку блокирующей везде, она быстро начнет раздражать разработчиков. Представьте: человек только создал черновой MR, а CI уже требует обновить контракты, которые пока вообще не относятся к его текущей задаче.
Поэтому логика получилась двухуровневой:

Чем ближе код к продакшну, тем строже проверка.
Первая версия: парсим все самостоятельно
Начал я самым очевидным путем: программа искала файл зависимостей и пыталась сама понять, какие пакеты используются в проекте.
В первой версии я хотел реализовать поддержку только самых популярных форматов:
go.mod;package.json;requirements.txt;pyproject.toml;Cargo.toml;packages.config;*.csproj;Gemfile.
По моей логике, это было так: валидатор ищет файл с зависимостями, парсит, вытаскивает версии и сравнивает их. Но я быстро понял, что фактически пишу универсальный менеджер пакетов, причем очень плохой.
Все потому, что где-то версия указана напрямую, где-то задан диапазон, в одном месте есть lock-файл, а в другом версия становится известна только после сборки. Плюс транзитивные зависимости, подмены модулей, платформенные условия и прочие радости. И каждый новый язык добавлял бы очередной парсер и новые исключения.
Тогда я вспомнил про SBOM.
Причем здесь SBOM
SBOM, или Software Bill of Materials, — это машиночитаемый компонентный состав программы, который помогает понять, какие библиотеки вошли в проект, какие у них версии и как они связаны между собой. Придумали его безопасники программного обеспечения — те, кого в народе называют DevSecOps, и обычно SBOM используют при поиске уязвимостей или проверке лицензий. Но по сути это просто унифицированный источник информации о компонентах программы. Который также отлично подходит и для проверки актуальности зависимостей. Можно один раз получить SBOM в стандартном формате, а дальше уже работать с одинаковой структурой данных, независимо от языка программирования, используемого в проекте.
Для генерации я выбрал Anchore Syft — он умеет сканировать директории, файловые системы и контейнерные образы, поддерживает множество пакетных экосистем и может выдавать результат в CycloneDX JSON.
Генерация выглядит так:
syft . --output cyclonedx-json=bom.json
На выходе получаем bom.json со списком компонентов:
{ "bomFormat": "CycloneDX", "components": [ { "type": "library", "name": "github.com/company/contracts", "version": "v1.8.2" } ] }
CycloneDX описывает не только названия и версии компонентов, но и связи между ними. Поэтому один и тот же формат можно использовать для проектов на разных языках. Подробнее структура SBOM описана в документации CycloneDX.
Переход на SBOM стал главным концептуальным изменением в ходе работы над проектом. До релиза Dependency Validator самостоятельно разбирал файлы зависимостей, а в версии 1.0.0 уже начал использовать CycloneDX.
Что происходит под капотом
Переход на SBOM упростил алгоритм. Сначала Dependency Validator загружает конфигурацию из файла:
.dependency-validator-config.yaml: repos: - name: github.com/company/contracts repo_url: https://github.com/company/contracts - name: gitlab.example.com/company/private-sdk repo_url: https://gitlab.example.com/company/private-sdk token: ${PRIVATE_REPO_TOKEN}
У каждого репозитория есть:
name— имя зависимости в SBOM;repo_url— адрес Git-репозитория;token— необязательный токен для закрытого репозитория.
Дальше программа ищет SBOM, проверяет, что это CycloneDX, и вытаскивает из него компоненты типа library.
После этого для каждого репозитория выполняется команда:
git ls-remote --tags https://github.com/company/contracts
Клонировать репозиторий не требуется — достаточно запросить список тегов. Dependency Validator сам отфильтровывает корректные SemVer-версии и берет максимальную.
Ну и дальше все довольно прямолинейно:

Если версии совпали, получаем:
Checking github.com/company/contracts...
Up-to-date: v1.9.0
Если нет:
Checking github.com/company/contracts...
Outdated: using v1.8.2, latest is v1.9.0
В случае обнаружения расхождений, программа выводит их и завершается с кодом 1:
The following dependencies are outdated:
- github.com/company/contracts
(current: v1.8.2 → latest: v1.9.0)
Благодаря коду завершения валидатор можно использовать в CI. API GitHub или GitLab для блокировки слияния внутри самой программы не требуются, за это отвечает пайплайн.
Что делать с закрытыми репозиториями
Мне нужно было проверять не только публичные библиотеки, но и наши внутренние репозитории с контрактами, поэтому я добавил в YAML-конфигурацию поддержку токена.
Если токен передан, Dependency Validator подставляет его при вызове git ls-remote. Так алгоритм работает одинаково и для публичных, и для закрытых репозиториев.
Класть настоящий токен прямо в Git, конечно, не стоит. У нас конфигурация создается или дополняется во время CI, а значение берется из защищенной переменной:
token: ${DEPENDENCY_VALIDATOR_TOKEN}
Плейсхолдер заменяется значением из CI secret прямо перед запуском — так разработчики видят структуру конфигурации, но не сам секрет. И я стараюсь выдавать токену минимальные права, ведь для проверки вполне достаточно чтения тегов репозитория.
Как сделать проверку блокирующей только перед master
Сам Dependency Validator не знает, из какой ветки пришел merge request. Он решает только одну задачу: сравнивает версии и возвращает успешный или неуспешный код. А CI уже решает, насколько строго к этому относиться.
Например, в GitLab CI заготовка может выглядеть примерно так:
dependency-validator: stage: test script: - syft . --output cyclonedx-json=bom.json - dependency-validator rules: - if: > $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master" allow_failure: false - if: $CI_PIPELINE_SOURCE == "merge_request_event" allow_failure: true
Если MR направлен в master, ошибка блокирует пайплайн. Для остальных merge request используется allow_failure: true: разработчик увидит проблему, но сможет продолжить работу. Поведение rules и allow_failure подробнее описано в документации GitLab.
В GitHub Actions идея та же:
name: Check dependencies on: pull_request: jobs: dependency-validator: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Syft run: | curl -sSfL https://get.anchore.io/syft | sh -s -- -b /usr/local/bin - name: Generate SBOM run: syft . --output cyclonedx-json=bom.json - name: Run Dependency Validator continue-on-error: ${{ github.base_ref != 'master' }} run: dependency-validator
Получается, что режим работы задается не флагом программы, а конфигурацией CI. На мой взгляд, это проще, поскольку валидатор констатирует факт, а пайплайн уже применяет нужную политику.
Готовые инструкции по установке и интеграции лежат в репозитории проекта.
Что утилита не делает
Dependency Validator не сканирует уязвимости, его задача — проверка актуальности, а не поиск CVE, лицензионный аудит или автоматическое обновление пакетов.
Кроме того, свежая версия — не всегда хорошая. Новый релиз может содержать breaking changes или еще не пройти внутреннее тестирование. Поэтому я и не стал проверять все пакеты из SBOM подряд. В конфигурацию попадают только те репозитории, для которых команда действительно хочет соблюдать правило актуальности.
Есть и несколько технических ограничений:
в репозитории должны использоваться корректные SemVer-теги;
имя компонента в SBOM должно совпадать с именем в YAML;
качество проверки зависит от полноты сгенерированного SBOM;
для получения тегов нужен сетевой доступ к репозиторию;
токены для закрытых репозиториев важно правильно хранить и ограничивать по правам.
Но самое главное, что утилита закрывает тот самый сценарий, из-за которого у нас однажды случился заметный даунтайм.
Что получилось в итоге
Весь проект оказался довольно небольшим: Go-бинарник с конфигурацией через YAML, а внутри — парсинг SBOM и несколько команд для получения Git-тегов.
После инцидента можно было ограничиться очередной инструкцией или напоминанием «будьте внимательнее», но мы взяли и автоматизировали конкретное правило. Теперь перед слиянием в master CI сам проверяет, использует ли сервис последнюю версию критичной зависимости? Если нет — разработчик увидит это до релиза.
Если захотите попробовать инструмент у себя, исходный код и инструкция по установке лежат на GitHub.
