Всем привет, меня зовут Сергей Прощаев и в этой статье расскажу, как настроить в GitLab автоматический поиск уязвимостей в зависимостях Spring‑приложения так, чтобы дыры всплывали в merge request до прода, а не на проде, — и при этом пайплайн не падал на каждой устаревшей библиотеке.
Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.
Сразу обозначу рамку. Это не обзор «что такое SCA» и не пересказ документации GitLab. Это рабочий маршрут: берём типовой Spring Boot сервис, подключаем dependency scanning на новом движке и настраиваем так, чтобы security‑гейт реально защищал, а не превращался в красный крестик, который все привыкли игнорировать.

С чего всё начинается: «у нас же ничего такого не подключено»
Помню историю, которая до сих пор для меня как эталон. Декабрь 2021-го, Log4Shell. Полночь, дежурный канал разрывается, и первый вопрос, который задаёт половина команд по всему рынку, звучит не «как пропатчить», а «а у нас вообще есть этот Log4j?».
И вот это самое страшное. В pom.xml его нет. Никто его не добавлял. Он приехал транзитивно — через стартер, через библиотеку логирования, через зависимость зависимости. Команды тратили дни, чтобы просто понять, затронуты они или нет: не было полной карты того, что собирается в JAR.
Мне как‑то попалась оценка по экосистеме Maven за 2025 год (её приводили в аналитике по supply‑chain рискам): уязвимости затрагивали порядка трети последних релизов библиотек через прямые зависимости и заметно больше — через транзитивные. Точные проценты гуляют от источника к источнику, но порядок один: основной канал риска — не то, что вы написали в pom.xml, а то, что подтянулось следом, на два‑три уровня глубже.
Я бы сформулировал так: если ваш инструмент проверки смотрит только на прямые зависимости, он показывает вам верхушку и молчит про корни. А именно в корнях обычно и сидит то, что вас положит.
Что мы будем делать и в каких условиях
Берём типовой сервис: Spring Boot 4.1.0, Spring Framework 7.0.8 под капотом, Java 21, сборка через Maven. Пример протестирован на Spring Boot 4.1 и проверен на GitLab 19.1. Используемые возможности относятся к GitLab, поэтому для Spring Boot 3.x конфигурация остаётся такой же — меняются только версии зависимостей. Тариф — Ultimate, потому что SBOM‑based dependency scanning живёт именно там; на нём же завязан Security Dashboard и гейт в merge request.
Чтобы было что ловить, добавим в сервис «мину» — зависимость с известной уязвимостью. Возьмём commons‑text 1.9. В ней живёт CVE-2022-42889, она же Text4Shell: при определённых сценариях использования StringSubstitutor с дефолтными интерполяторами она позволяет добиться удалённого выполнения кода (CVSS v3.1 Base Score 9.8). Починили её в 1.10, но 1.9 до сих пор регулярно встречается в проектах — иногда напрямую, чаще транзитивно, приехав через утилитную библиотеку, которую подключили ради одного метода.
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Наша "мина": уязвимая версия commons-text (CVE-2022-42889, Text4Shell). В реальном проекте такая чаще приезжает транзитивно, а не строкой в pom. Здесь объявили явно, чтобы пример был воспроизводимым. --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-text</artifactId> <version>1.9</version> </dependency> </dependencies>
Важная оговорка про честность примера. Я объявил уязвимую версию явной строкой, чтобы вы воспроизвели всё один в один. В жизни так бывает реже: обычно commons‑text 1.9 не написан в pom.xml, а подтянут на втором‑третьем уровне через библиотеку, которую подключили для другого. Руками такую мину вы не увидите — поэтому дальше я упираюсь в транзитивные зависимости.
Цель — три вещи:
Видеть полный список зависимостей, включая транзитивные, и понимать, какая откуда пришла.
Получать новые уязвимости прямо в merge request — до того, как изменение попало в основную ветку.
Не ронять пайплайн на каждой найденной CVE, а отделять то, что реально угрожает, от шума.
Важная деталь по версиям. Внутри GitLab сейчас два движка dependency scanning. Старый, на Gemnasium, помечен устаревшим ещё в 17.9 и пойдёт под нож в 20.0. Новый движок использует SBOM в формате CycloneDX, стал generally available в GitLab 19.0 и подключается через V2-шаблон. Делаем сразу на новом: переезжать с Gemnasium через год — то ещё удовольствие, я предпочитаю не плодить технический долг.
Маршрут решения
Дальше — по шагам. Каждый шаг закрывает один конкретный риск, и после каждого я показываю, как проверить, что он сработал.
Шаг 1. Подключаем новый движок сканирования
Базовое подключение — одна строка include в.gitlab‑ci.yml. Отдельных джоб руками писать не нужно, шаблон сам разворачивает всё необходимое.
stages: - build - test include: - template: Jobs/Dependency-Scanning.v2.gitlab-ci.yml
Ключевое слово здесь — v2. Если по старому гайду с какого‑нибудь Medium подключить Security/Dependency‑Scanning.gitlab‑ci.yml, вы получите старый Gemnasium‑движок. Внешне всё работает, отчёт появляется — но это уже легаси, и части фишек, ради которых мы всё затеваем, там не будет.
Как проверить: после пуша зайдите в Build → Pipelines, откройте последний пайплайн. В стадии.pre должна появиться джоба разрешения зависимостей, а дальше — dependency‑scanning. Если их нет — скорее всего не включена фича в Settings → Security или у проекта не тот тариф.
Шаг 2. Даём сканеру увидеть транзитивные зависимости
Здесь же — главная ловушка, на которую я сам когда‑то наступил.
Анализатор dependency scanning строит SBOM на основе графа зависимостей и метаданных сборки, и для Maven источников несколько: разрешение зависимостей во время сборки, экспортированный граф (maven.graph.json) или, в некоторых случаях, анализ самого pom.xml. Проблема в том, что у Maven, в отличие от условного npm, нет привычного «lock‑файла» в репозитории по умолчанию. И если нет ни графа, ни результатов resolution, анализатор может перейти в fallback‑режим анализа pom.xml. А этот режим, по документации GitLab на момент написания статьи, ограничивается явно объявленными зависимостями и неполной моделью транзитивного графа. Это поведение реализации GitLab, а не свойство Maven, и со временем может измениться.
То есть формально джоба зелёная, отчёт есть, галочка стоит. А часть зависимостей могла не попасть в анализ. Худший вид безопасности — тот, что создаёт ложное ощущение, будто всё проверено.
В GitLab 19.0 это во многом закрыли автоматическим разрешением зависимостей: если коммитнутого графа нет, движок сам пытается сгенерировать его в стадии.pre, поднимая лёгкий образ с JDK и прогоняя Maven. На многих проектах хватает из коробки.
Но для воспроизводимости примера я предпочитаю генерировать maven.graph.json явно. Экспортированный граф зависимостей может использоваться анализатором GitLab как один из источников информации о составе зависимостей, и явная генерация упрощает диагностику и делает пайплайн менее зависимым от внутренних механизмов анализатора. Если GitLab у вас и так строит граф корректно — оставайтесь на автоматике, это полноценный рабочий вариант.
stages: - build - test include: - template: Jobs/Dependency-Scanning.v2.gitlab-ci.yml # Явно строим граф зависимостей в контролируемой сборочной стадии. # Формат именно JSON (maven.graph.json) — его умеет читать анализатор. build: stage: build image: maven:3.9-eclipse-temurin-21 script: - mvn dependency:tree -DoutputType=json -DoutputFile=maven.graph.json - mvn package -DskipTests artifacts: paths: - maven.graph.json - target/*.jar expire_in: 1 hour
Как проверить: откройте dependency‑scanning, скачайте артефакт gl‑dependency‑scanning‑report.json или сам SBOM в формате CycloneDX. Найдите в нём нашу commons‑text 1.9 — она должна попасть в отчёт как уязвимая (Text4Shell). Заодно гляньте на любую заведомо транзитивную библиотеку — что‑нибудь из tomcat‑embed или jackson, которое вы руками не подключали. Если и мина, и транзитивные на месте — анализатор увидел полный граф. Если в отчёте только явно объявленное в pom.xml, — похоже, сработал fallback, и стоит разобраться с графом зависимостей.
Шаг 3. Включаем reachability — отделяем опасное от шумного
Теперь решаем проблему, из‑за которой большинство команд в итоге забивают на сканеры. Имя ей — шум.
Если просто включить сканирование на большом легаси‑проекте, вы утром получите десятки, а то и сотни уязвимостей. Critical, High, всё красное. И дальше одно из двух: либо команда героически неделю разгребает, либо — что бывает чаще — все молча договариваются красный гейт игнорировать. И инструмент фактически перестаёт использоваться.
Здесь помогает фича, которая в новом движке мне особенно нравится, — static reachability analysis. Далеко не каждая зависимость из дерева реально используется в коде: что‑то подтянулось транзитивно, но ни одна строчка вашего кода её не импортирует. Анализатор использует статический анализ проекта и определяет, есть ли в коде фактическое использование уязвимых пакетов — их импорты и обращения к символам. Если использование найдено, уязвимости в этой зависимости присваивается значение reachable. Важно понимать рамку: это статическая модель использования, а не гарантия эксплуатации. Reachability сужает список того, на что смотреть в первую очередь, но не выносит вердикт «это точно эксплойтабельно».
Для Java, JavaScript/TypeScript и Python это работает. Включается одной переменной:
include: - template: Jobs/Dependency-Scanning.v2.gitlab-ci.yml variables: DS_STATIC_REACHABILITY_ENABLED: true
Я когда первый раз прогнал это на реальном сервисе, выдохнул. Список «на что смотреть сейчас» сократился в разы. Большая часть красноты оказалась библиотеками, до которых приложение не дотягивается.
Как проверить: в карточке уязвимости появляется поле Reachable. Отфильтруйте дашборд по нему. Уязвимости со значением Yes — ваш реальный backlog на сегодня. Остальное — в очередь по плановому обновлению, без ночных подвигов.
Шаг 4. Ставим гейт в merge request
Сканер, который пишет отчёт, который никто не открывает, бесполезен. Смысл всей затеи — поймать проблему на входе, в merge request, пока изменение ещё не в main.
Тут принципиальная тонкость. Гейт должен реагировать не на «в проекте вообще есть уязвимости» (они есть всегда, это нормальное состояние живого проекта), а на «это конкретное изменение приносит новую уязвимость». GitLab Security Policies позволяют реализовать именно такой сценарий: политика сравнивает находки между исходной и целевой веткой и показывает в merge request дельту — что нового притащил этот MR.
Задаётся это политикой на уровне группы, а не правкой .gitlab‑ci.yml в каждом репозитории. Важный сдвиг: безопасность настраивается один раз и применяется ко всем проектам автоматически, и рядовой разработчик не может её случайно выпилить из своего .gitlab‑ci.yml.
Сам гейт описывается политикой в отдельном security‑проекте. Вот минимальный пример scan result policy, который блокирует мерж, если изменение приносит новую уязвимость уровня Critical:
approval_policy: - name: Блок мержа на новые Critical в зависимостях description: > Если MR приносит новую уязвимость Critical из dependency scanning — требуется аппрув security-команды. Состояние проекта в целом не трогаем, смотрим только дельту, которую вносит это изменение. enabled: true rules: - type: scan_finding scanners: - dependency_scanning vulnerabilities_allowed: 0 severity_levels: - critical vulnerability_states: - newly_detected actions: - type: require_approval approvals_required: 1 role_approvers: - maintainer
Разберём важное. vulnerability_states: newly_detected — это и есть дельта: политика срабатывает только на то, что добавил MR, а не на весь ворох существующих находок. vulnerabilities_allowed: 0 по critical — «ни одной новой критичной без аппрува». А require_approval вместо жёсткой блокировки оставляет легальную дверь: security‑команда может осознанно пропустить изменение, приняв риск. Это честнее наглухо закрытого шлагбаума, который начинают обходить.
Я бы предостерёг от частой ошибки — не делайте первый гейт блокирующим по любой High. Получите бунт команды на второй день. Мой подход: на старте гейт реагирует только на новые Critical, а потом, когда команда привыкла, добавляем High и фильтр по Reachable. Это узкий, обороняемый порог — против него тяжело возразить, потому что это буквально «ты прямо сейчас тащишь в прод критичную дыру». А дальше планомерно закручиваете гайки.
Как проверить: заведите тестовый MR, добавляющий в pom.xml ту самую commons‑text версии 1.9. Виджет в merge request должен показать новую уязвимость Critical именно как привнесённую этим MR, а сам мерж — потребовать аппрува по политике. Если показал и потребовал — петля замкнулась, гейт работает.
Почему транзитивные зависимости — это не только про CVE
Тут хочу показать кейс, после которого я стал относиться к глубине дерева зависимостей всерьёз, а не как к формальности.
Есть исследование под названием Java‑Class‑Hijack (его представляли на воркшопе SCORED в 2025-м). Суть атаки красивая и от этого жутковатая. В Java, когда классы с одинаковым полным именем оказываются в разных зависимостях, при определённом порядке разрешения classpath в дело идёт тот класс, что встретился раньше. И вот исследователи показали: можно подсунуть вредоносный класс с тем же именем, что у легитимного, спрятав его глубоко в транзитивной зависимости. При сборке он встаёт в итоговый артефакт раньше настоящего — и подменяет поведение, не трогая ни основной код, ни имена библиотек.
Самое показательное — они воспроизвели это на реальном серверном приложении Corona‑Warn‑App, немецком ковид‑трекере. Через крошечную библиотеку валидации JSON, сидящую где‑то в глубине дерева, удалось дотянуться до логики подключения к базе. Маленькая безобидная зависимость на третьем уровне вложенности — а на выходе контроль над коннектом к БД. Оговорюсь: это не «любой Maven‑проект обречён» — эксплуатация требует ряда условий, связанных с разрешением зависимостей и порядком формирования classpath. Но сам класс атак показывает, почему глубину дерева нельзя игнорировать.
К чему я это. «Видеть транзитивные зависимости и их происхождение» — это не про галочку в комплаенсе. Атакующие всё чаще используют не прямую зависимость, которую вы хоть как‑то смотрите, а ту, что на три уровня глубже и которую вы в глаза не видели. Новый анализатор умеет трассировать транзитивную зависимость до источника: показывает цепочку, через какую библиотеку приехал уязвимый пакет. А зная цепочку, понимаешь, где бить — обновлять родителя или форсить версию.
И это перестало быть теорией ещё и потому, что атаки идут валом. В ноябре 2025-го червь Shai‑Hulud во второй итерации доехал и до Maven Central — компрометировал опубликованные пакеты отдельных разработчиков, тащил секреты, расползался дальше. Важная оговорка: компрометация шла через заражённые пакеты, а не через взлом самой инфраструктуры Maven Central. По следам той кампании счёт утёкших уникальных секретов шёл на десятки тысяч. Так что «у нас закрытый периметр, нам это не грозит» — позиция, которую я больше не принимаю всерьёз.
Как всё это связано в единый поток
Ниже на рис. 2 — как изменение проходит путь от коммита до решения «пускать или нет» и где включается сканирование.

Главная мысль из схемы: проверка стоит не в конце, перед деплоем, а на входе — в merge request. Чем левее в пайплайне вы ловите проблему, тем дешевле она обходится. Поймать уязвимую зависимость на ревью — это правка одной строки версии. На проде после инцидента — ночной созвон, хотфикс и объяснительная.
Где это решение не сработает (и что делать)
Я не люблю гайды, которые заканчиваются на «теперь всё работает». Так не бывает. Честно про ограничения.
Если у вас не Ultimate. SBOM‑based dependency scanning и гейт в merge request — это Ultimate. На младших тарифах вы соберёте SBOM и список зависимостей, но полноценного сравнения находок в MR и Security Dashboard не получите. Тогда смотрите в сторону внешних SCA (OWASP Dependency‑Check, Trivy) — их можно прикрутить отдельной джобой, но петлю «дельта в MR» придётся собирать вручную.
Reachability и Spring — отдельная осторожность. Важный нюанс, прямо прописанный в документации GitLab. Анализ достижимости поддерживает Java, JavaScript/TypeScript и Python — но для Java ловит явное использование (прямые импорты, рефлексию, строки подключения JDBC) и не видит зависимости, подгружаемые динамически в рантайме. А это ровно то, что делают DI‑фреймворки вроде Spring Boot. На практике — повышенный риск ложноотрицательных результатов: пакет может быть помечен как Not Found, хотя Spring поднимает его через инъекцию. Поэтому на Spring‑проекте я не отношусь к Not Found как к индульгенции и критичные находки перепроверяю руками. Для Go или Rust reachability не работает вовсе.
И общая оговорка. Как и любой SCA‑инструмент, GitLab dependency scanning не гарантирует стопроцентную полноту покрытия — особенно в проектах с динамической загрузкой зависимостей. Это не «включил настройки и система видит всё», а смещение вероятностей в вашу пользу: ловите кратно больше, чем без него, но абсолютной гарантии не даёт ни один сканер.
Не путайте с container scanning. Dependency scanning смотрит на зависимости приложения по манифестам и графу. Уязвимости в базовом Docker‑образе и системных пакетах ОС — зона container scanning, отдельного сканера. Два разных среза риска, закрывать надо оба.
Reachability — помощник, а не индульгенция. То, что код сегодня не дотягивается до уязвимого пакета, не значит, что не дотянется завтра после рефакторинга. Я использую Reachable для приоритизации, но не как разрешение навсегда забить на остальное. Мёртвая сегодня зависимость завтра может ожить.
Что в итоге
Если убрать всё лишнее, рабочая связка такая. Подключаем V2-шаблон dependency scanning, а не легаси. Скармливаем анализатору полный граф зависимостей (maven.graph.json), чтобы он видел транзитивные, а не только прямые из pom.xml. Включаем static reachability, чтобы отделить опасное от фонового шума и не убить доверие команды к гейту. И ставим гейт в merge request на дельту — на новые Critical, — задавая его политикой на уровне группы, чтобы его нельзя было тихо обойти.
Самый частый провал тут не технический. Команда включает сканер «чтобы был», тонет в шуме и через неделю учится не замечать красный крестик. Поэтому я настаиваю: узкий честный гейт, который ловит реальную дыру на входе, работает кратно лучше тотального гейта, который все игнорируют!
Продолжить знакомство с этими практиками можно уже 30 июня в 20:00 на бесплатном открытом уроке «GitLab CI как конструктор workflow». На нём покажем, как собирать в GitLab CI надёжные пайплайны, которые не просто запускают сборку, а помогают выстроить предсказуемый процесс доставки.
Больше полезных материалов по инфраструктуре смотрите в дайджесте.
