Всем привет, меня зовут Сергей Прощаев и в этой статье расскажу, как настроить в GitLab автоматический поиск уязвимостей в зависимостях Spring‑приложения так, чтобы дыры всплывали в merge request до прода, а не на проде, — и при этом пайплайн не падал на каждой устаревшей библиотеке.

Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.

Сразу обозначу рамку. Это не обзор «что такое SCA» и не пересказ документации GitLab. Это рабочий маршрут: берём типовой Spring Boot сервис, подключаем dependency scanning на новом движке и настраиваем так, чтобы security‑гейт реально защищал, а не превращался в красный крестик, который все привыкли игнорировать.

Рис. 1. Уязвимость, спрятанная в глубине дерева зависимостей, и контрольная точка в пайплайне, которая ловит её до продакшена
Рис. 1. Уязвимость, спрятанная в глубине дерева зависимостей, и контрольная точка в пайплайне, которая ловит её до продакшена

С чего всё начинается: «у нас же ничего такого не подключено»

Помню историю, которая до сих пор для меня как эталон. Декабрь 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 — как изменение проходит путь от коммита до решения «пускать или нет» и где включается сканирование.

Рис. 2. Путь изменения через пайплайн: сканирование зависимостей встроено в проверку merge request, а гейт реагирует на новую уязвимость, а не на общее состояние проекта
Рис. 2. Путь изменения через пайплайн: сканирование зависимостей встроено в проверку merge request, а гейт реагирует на новую уязвимость, а не на общее состояние проекта

Главная мысль из схемы: проверка стоит не в конце, перед деплоем, а на входе — в 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 надёжные пайплайны, которые не просто запускают сборку, а помогают выстроить предсказуемый процесс доставки.

Больше полезных материалов по инфраструктуре смотрите в дайджесте.