Подпись и provenance отвечают на вопрос «откуда взялся артефакт». Они не говорят ни слова о том, что у образа внутри и насколько оно дырявое. А внутри — сотни пакетов из базового образа, транзитивные зависимости приложения и старый openssl, который никто не обновлял с прошлого квартала. Trivy закрывает ровно этот зазор: один шаг в CI смотрит, что за пакеты уехали в образ, сверяет их с базами уязвимостей и роняет сборку, если внутри лежит что-то, чему уже есть исправление. Разберёмся, как встроить это так, чтобы гейт защищал, а не бесил.
Содержание
Открыть содержание
Почему «просто пересобрать образ» не спасает
Типичный образ приложения — это не ваш код. Это ваш код плюс базовый образ, плюс системные библиотеки, плюс всё дерево зависимостей рантайма. Уязвимость в любом из этих слоёв — ваша уязвимость, даже если вы её строчки в глаза не видели. Log4Shell прилетел людям, которые про log4j знали только то, что он где-то в зависимостях Spring.
Проблема не в том, что дыры появляются, — они появляются постоянно, базы CVE пополняются каждый день. Проблема в том, что между «вышел патч» и «мы это заметили» обычно лежит месяцы, а то и «никогда». Пересборка образа сама по себе не лечит: если базовый образ запинен на старый тег, вы аккуратно пересобираете одну и ту же дыру раз за разом.
Нужен автоматический наблюдатель, который на каждой сборке заново сверяет содержимое образа с актуальными базами и говорит: вот это — известно, вот на это есть фикс, вот это блокирует выкатку. Trivy — де-факто открытый стандарт для этой работы: один бинарник, одна база, покрывает большинство языков и дистрибутивов.
Что вообще сканирует Trivy
Ошибка — думать про Trivy как про «сканер образов». Образы — только одна из целей. Один и тот же CLI с одной и той же базой умеет смотреть на пять разных поверхностей:
- Контейнерные образы — послойно разбирает пакеты ОС (
apk,apt,apk) и зависимости приложения (npm,pip,go.mod,gem…) и сверяет с CVE. - Файловая система и репозиторий — то же самое, но по исходникам:
trivy fs .находит уязвимые зависимости ещё до сборки образа. - IaC-мисконфиги — Terraform, Kubernetes-манифесты, Dockerfile, Helm: открытый
securityContext, привилегированный контейнер, публичный S3-бакет. - Секреты — захардкоженные токены, приватные ключи,
AWS_SECRET_ACCESS_KEYпрямо в слое образа. - Лицензии — какие лицензии тащат зависимости; полезно там, где GPL в проприетарном продукте — это юридический риск.
Для старта важно только одно: не нужно пять разных инструментов. Один шаг в пайплайне, меняется только цель — image, fs, config. Дальше по тексту сосредоточимся на образах, потому что это самый частый и самый «дорогой при промахе» случай.
Гейт: как скан начинает что-то решать
Скан, который просто печатает список в лог, бесполезен — его никто не читает. Ценность появляется, когда скан блокирует выкатку. Управляют этим два флага:
--severity— какие уровни нас волнуют. На гейт обычно вешаютHIGH,CRITICAL, аLOW,MEDIUMоставляют в отчёте.--exit-code 1— вернуть ненулевой код, если нашлось что-то из указанной severity. Именно ненулевой код роняет job в CI и останавливает пайплайн.
# отчёт, ничего не блокирует (для наблюдения)
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: список того, что внутри
Пока Trivy разбирает образ, он и так строит полный инвентарь пакетов. Выгрузить его в виде SBOM (Software Bill of Materials) — почти бесплатно, а пользы много.
SBOM — это машиночитаемый список: какие компоненты, каких версий, с какими лицензиями попали в артефакт. Два стандарта: CycloneDX (заточен под безопасность) и SPDX (шире, про лицензии и происхождение). Зачем он нужен:
- Когда завтра выйдет новый громкий CVE, вы за секунду ответите «затронуты ли мы» — грепом по сохранённым SBOM, а не пересканом всех образов.
- Аудит и требования регуляторов (US EO 14028, EU CRA) всё чаще просят SBOM как артефакт поставки.
- Его можно подписать и приложить к образу как аттестацию — тогда получатель доверяет не только образу, но и его составу.
# 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. После этого гейт мёртв: он есть, но ничего не блокирует.
Правильный ход — не ослаблять гейт, а сужать его до того, что реально важно и что реально можно починить:
--ignore-unfixed— уже обсудили: без фикса нет смысла блокировать..trivyignore— файл со списком конкретных CVE, которые вы осознанно приняли (с комментарием и, в идеале, датой пересмотра). Не «замолчали», а «разобрались и решили».- VEX (Vulnerability Exploitability eXchange) — заявление «этот CVE в нашем контексте не эксплуатируется» (уязвимый код есть, но путь до него недостижим). В отличие от голого игнора, VEX несёт обоснование и машиночитаем.
# .trivyignore
# openssl CVE в libcrypto — функция не вызывается из нашего кода.
# Пересмотреть после обновления базового образа. Отв.: security, до 2026-09-01.
CVE-2026-1234
Разница между «приглушить» и «замолчать» — в следе. .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]
Три детали, которые экономят нервы:
- Сканируем по digest, а не по тегу. Тег можно переставить на другой образ между сборкой и сканом; digest — нет. Сканируем ровно то, что уехало в реестр.
- Кэш базы уязвимостей. Без кэша Trivy качает ~несколько сотен мегабайт БД на каждом запуске и CI тормозит.
TRIVY_CACHE_DIR+cache:в GitLab решают это; в приватных сетях базу можно зеркалить в свой реестр (trivy image --db-repository ...). - Порядок шагов. Сначала гейт (падаем рано и дёшево), потом SBOM и подпись — их нет смысла делать для образа, который всё равно не поедет.
Как убедиться, что работает
Проверьте обе стороны гейта — что он пропускает чистое и что реально роняет дырявое. Быстрый способ увидеть блокировку — взять заведомо старый образ:
# заведомо уязвимый образ — ожидаем ненулевой код возврата
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 — «что внутри и насколько оно дырявое». Три ответа, которые вместе делают выкатку проверяемой от коммита до кластера.