Перейти к содержимому
Hogin Hogin
Назад

Trivy в CI: ловим уязвимости и собираем SBOM до прода

8 мин чтения

Подпись и provenance отвечают на вопрос «откуда взялся артефакт». Они не говорят ни слова о том, что у образа внутри и насколько оно дырявое. А внутри — сотни пакетов из базового образа, транзитивные зависимости приложения и старый openssl, который никто не обновлял с прошлого квартала. Trivy закрывает ровно этот зазор: один шаг в CI смотрит, что за пакеты уехали в образ, сверяет их с базами уязвимостей и роняет сборку, если внутри лежит что-то, чему уже есть исправление. Разберёмся, как встроить это так, чтобы гейт защищал, а не бесил.

Содержание

Открыть содержание

Почему «просто пересобрать образ» не спасает

Типичный образ приложения — это не ваш код. Это ваш код плюс базовый образ, плюс системные библиотеки, плюс всё дерево зависимостей рантайма. Уязвимость в любом из этих слоёв — ваша уязвимость, даже если вы её строчки в глаза не видели. Log4Shell прилетел людям, которые про log4j знали только то, что он где-то в зависимостях Spring.

Проблема не в том, что дыры появляются, — они появляются постоянно, базы CVE пополняются каждый день. Проблема в том, что между «вышел патч» и «мы это заметили» обычно лежит месяцы, а то и «никогда». Пересборка образа сама по себе не лечит: если базовый образ запинен на старый тег, вы аккуратно пересобираете одну и ту же дыру раз за разом.

Нужен автоматический наблюдатель, который на каждой сборке заново сверяет содержимое образа с актуальными базами и говорит: вот это — известно, вот на это есть фикс, вот это блокирует выкатку. Trivy — де-факто открытый стандарт для этой работы: один бинарник, одна база, покрывает большинство языков и дистрибутивов.

Что вообще сканирует Trivy

Ошибка — думать про Trivy как про «сканер образов». Образы — только одна из целей. Один и тот же CLI с одной и той же базой умеет смотреть на пять разных поверхностей:

Один сканер — пять поверхностей: образы, файловая система, IaC, секреты, лицензии

Для старта важно только одно: не нужно пять разных инструментов. Один шаг в пайплайне, меняется только цель — image, fs, config. Дальше по тексту сосредоточимся на образах, потому что это самый частый и самый «дорогой при промахе» случай.

Гейт: как скан начинает что-то решать

Скан, который просто печатает список в лог, бесполезен — его никто не читает. Ценность появляется, когда скан блокирует выкатку. Управляют этим два флага:

# отчёт, ничего не блокирует (для наблюдения)
trivy image --severity HIGH,CRITICAL registry.example.com/app:sha-abc123

# гейт: пайплайн падает, если есть HIGH/CRITICAL с известным фиксом
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
  registry.example.com/app:sha-abc123

Ключевой флаг для здравомыслия — --ignore-unfixed. Он выкидывает из блокировки всё, для чего апстрим ещё не выпустил исправление. Логика простая: если фикса нет, падение сборки ничего не меняет — обновиться-то некуда, вы просто заблокировали релиз из-за того, что чинить нельзя. Такие находки должны быть видны в отчёте, но не должны держать выкатку.

Гейт в пайплайне: чистый образ проходит, дырявый роняет сборку и обрастает SBOM

SBOM: список того, что внутри

Пока Trivy разбирает образ, он и так строит полный инвентарь пакетов. Выгрузить его в виде SBOM (Software Bill of Materials) — почти бесплатно, а пользы много.

SBOM — это машиночитаемый список: какие компоненты, каких версий, с какими лицензиями попали в артефакт. Два стандарта: CycloneDX (заточен под безопасность) и SPDX (шире, про лицензии и происхождение). Зачем он нужен:

# CycloneDX JSON рядом с образом
trivy image --format cyclonedx --output sbom.cdx.json \
  registry.example.com/app:sha-abc123

Приятный момент: SBOM можно скормить обратно в Trivy — trivy sbom sbom.cdx.json пересканит по нему уязвимости, не трогая сам образ. То есть однажды собранный SBOM живёт своей жизнью и переоценивается против свежей базы CVE в любой момент.

Как приглушить шум, чтобы гейт не отключили

Первый запуск Trivy на реальном образе почти всегда возвращает пугающую простыню. Естественная реакция команды — «тут невозможно работать» и allow_failure: true. После этого гейт мёртв: он есть, но ничего не блокирует.

Правильный ход — не ослаблять гейт, а сужать его до того, что реально важно и что реально можно починить:

# .trivyignore
# openssl CVE в libcrypto — функция не вызывается из нашего кода.
# Пересмотреть после обновления базового образа. Отв.: security, до 2026-09-01.
CVE-2026-1234

От сырого отчёта на сотни CVE к тому, что реально блокирует выкатку

Разница между «приглушить» и «замолчать» — в следе. .trivyignore и VEX лежат в репозитории, проходят ревью и имеют срок пересмотра. allow_failure: true не оставляет ничего, кроме зелёной галочки на дырявом образе.

Сравнение в одной таблице

Без сканированияTrivy-гейт в CI
Когда узнаём о дыреиз новостей / инцидентана сборке, до выкатки
Что блокирует продничегоHIGH/CRITICAL с фиксом
Ответ «затронуты ли мы»ручной переразбор образовгреп по сохранённым SBOM
Шумрежется --ignore-unfixed + .trivyignore + VEX
Артефакт поставкитолько образобраз + SBOM (+ подпись)
Стоимость входа0один job + кэш БД

Что нужно, чтобы завести это в CI

Минимум — один job, который сканирует собранный образ по digest, роняет сборку на HIGH/CRITICAL с фиксом, выгружает SBOM и подписывает его keyless (связка со статьёй про cosign). Вот рабочий .gitlab-ci.yml:

stages: [build, scan]

variables:
  IMAGE: $CI_REGISTRY_IMAGE
  TRIVY_CACHE_DIR: .trivycache        # кэш БД между сборками

build:
  stage: build
  image: docker:27
  services: [docker:27-dind]
  script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
    - docker build -t "$IMAGE:$CI_COMMIT_SHA" .
    - docker push "$IMAGE:$CI_COMMIT_SHA"
    - DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE:$CI_COMMIT_SHA")
    - echo "DIGEST=$DIGEST" >> build.env
  artifacts:
    reports:
      dotenv: build.env

scan:
  stage: scan
  image: aquasec/trivy:latest
  id_tokens:
    SIGSTORE_ID_TOKEN:
      aud: sigstore
  cache:                              # чтобы не качать базу CVE каждый раз
    key: trivy-db
    paths: [.trivycache]
  script:
    # 1. гейт: падаем на HIGH/CRITICAL, у которых есть исправление
    - trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed "$DIGEST"
    # 2. SBOM в CycloneDX
    - trivy image --format cyclonedx --output sbom.cdx.json "$DIGEST"
    # 3. keyless-подпись SBOM и привязка к образу
    - cosign attest --yes --predicate sbom.cdx.json --type cyclonedx "$DIGEST"
  artifacts:
    paths: [sbom.cdx.json]

Три детали, которые экономят нервы:

Как убедиться, что работает

Проверьте обе стороны гейта — что он пропускает чистое и что реально роняет дырявое. Быстрый способ увидеть блокировку — взять заведомо старый образ:

# заведомо уязвимый образ — ожидаем ненулевой код возврата
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed python:3.4-slim
echo "exit code: $?"        # не 0 — гейт сработал

Если код возврата 0 на старом образе — гейт не кусается: перепроверьте, что стоит --exit-code 1, а в CI на job нет allow_failure: true. И загляните в собранный SBOM — число компонентов должно совпадать с реальностью:

jq '.components | length' sbom.cdx.json

Итог

Trivy-гейт закрывает одну конкретную дыру в процессе — «выкатили образ с известной уязвимостью, для которой был патч». Один job, который сверяет содержимое образа с базой CVE и роняет сборку на HIGH/CRITICAL с фиксом. Цена — минуты на первичную настройку кэша и дисциплина держать .trivyignore осмысленным, а не растущей свалкой. Payoff — вы узнаёте о дыре на сборке, а не из инцидента, и на любой новый громкий CVE отвечаете грепом по SBOM за секунды.

А в связке с подписью и provenance из соседних статей получается замкнутый supply-chain контур: подпись говорит «откуда артефакт», provenance — «как он собран», Trivy — «что внутри и насколько оно дырявое». Три ответа, которые вместе делают выкатку проверяемой от коммита до кластера.


Поделиться:

Следующая статья
Sentry: ловим ошибки в проде раньше пользователей