Ты собрал образ из пяти строчек Dockerfile. Внутри - сотни системных пакетов из базового слоя, десятки прямых зависимостей приложения и сотни транзитивных, про которые ты вообще не в курсе. Любая из них может тащить дыру, бэкдор или просто протухшую версию OpenSSL с громким CVE. Когда в проде взрывается очередной Log4Shell или XZ-бэкдор, у тебя ровно два варианта: либо за минуты ответить "да, у нас это есть вот в этих 14 сервисах, патчим", либо сутки вручную перебирать образы и молиться.
Цепочка поставки ПО (software supply chain) - это весь путь от чужого кода до твоего контейнера в проде. И каждое звено - точка атаки: подменили базовый образ, протащили зловредный пакет через typosquatting, скомпрометировали CI и подсунули чужой слой. История 2024 года с бэкдором в xz-utils и серия атак на сам инструментарий (в марте 2026 дважды за две недели компрометировали цепочку поставки Trivy, и волна докатилась до зависимых проектов) - это не страшилки, а ровно то, от чего защищаемся в этом уроке. Защита держится на трёх китах: сканирование (что внутри и насколько дырявое), SBOM (опись содержимого) и подпись (доказательство, что образ тот самый и собран тем, кем надо).

Сканирование уязвимостей: trivy docker, grype и docker scout
Сканер берёт образ, разбирает его на слои, вытаскивает список установленных пакетов (по метаданным менеджеров: apk, dpkg, rpm, а также lock-файлам npm/pip/go.sum) и сверяет версии с базами CVE (NVD, GHSA, базы дистрибутивов). Ключевое: сканер не запускает образ и не "нюхает" байты - он читает декларативные метаданные. Поэтому пакет, поставленный мимо менеджера (распакованный tar вручную), сканер не увидит. Это первый краевой случай, который ловит новичков.
Trivy (Aqua Security) - самый ходовой опенсорсный сканер. Запрос "trivy docker" в Яндексе ведёт именно к нему. Базовый прогон по образу:
Код: Выделить всё
trivy image --severity HIGH,CRITICAL nginx:1.27
Код: Выделить всё
nginx:1.27 (debian 12.5)
Total: 2 (HIGH: 2, CRITICAL: 0)
Library Vulnerability Severity Status Installed Fixed
libxml2 CVE-2024-25062 HIGH fixed 2.9.14 2.9.14+dfsg-1.3+deb12u1
zlib1g CVE-2023-45853 HIGH affected 1.2.13 -
- Library - уязвимый пакет. Severity - класс по CVSS. Самое важное поле - Status: fixed значит фикс есть, надо обновить базовый образ; affected или will_not_fix - мейнтейнер пока (или вообще) не выпустил патч, бесполезно дёргать. Fixed показывает, до какой версии обновляться.
- Флаг --ignore-unfixed прячет то, что всё равно нельзя пофиксить - снижает шум в CI. --exit-code 1 валит пайплайн при находках. --vuln-type os,library разделяет дыры базового образа и зависимостей приложения.
Grype (Anchore) - ближайшая альтернатива, особенно сильна в паре со своим генератором SBOM - syft. Умеет принимать на вход не только образ, но и готовый SBOM, что в разы быстрее в пайплайне:
Код: Выделить всё
grype registry.cyberlake.ru/app:1.4.0 -o table
Код: Выделить всё
docker scout cves local://app:1.4.0
docker scout recommendations local://app:1.4.0
SBOM docker: опись содержимого образа (syft, docker sbom)
SBOM (Software Bill of Materials) - это машиночитаемая опись всего, что внутри образа: пакеты, версии, лицензии, хеши. Аналогия: состав на упаковке продукта. Сканер отвечает "дырявое ли это сейчас", SBOM отвечает "из чего это вообще состоит" - и хранится навсегда. Разница принципиальна: когда завтра выйдет новый CVE на пакет, который сегодня считался чистым, ты не пересобираешь образ - ты прогоняешь свежую базу по уже сохранённому SBOM и за секунды находишь все затронутые сервисы.
Два формата-стандарта: SPDX (под крылом Linux Foundation) и CycloneDX (OWASP). SPDX чаще требуют в комплаенсе и госзакупках, CycloneDX удобнее для security-конвейеров. Оба - валидный выбор, многие генерят сразу оба.
Инструмент де-факто - syft (Anchore):
Код: Выделить всё
syft registry.cyberlake.ru/app:1.4.0 -o spdx-json=app.spdx.json
syft app:1.4.0 -o cyclonedx-json=app.cdx.json
Код: Выделить всё
docker sbom app:1.4.0
Код: Выделить всё
NAME VERSION TYPE
libssl3 3.0.13-1 deb
ca-certificates 20230311 deb
express 4.19.2 npm
lodash 4.17.21 npm
Подпись образа docker: cosign, Sigstore и provenance
Сканирование говорит "образ чистый". Но как доказать, что в прод приедет именно этот образ, собранный твоим пайплайном, а не подменённый? Тег - не идентичность: nginx:1.27 завтра может указывать на другой digest. Цифровая подпись привязывает доверие к неизменяемому sha256.
cosign (проект Sigstore) - современный стандарт. Запрос "cosign" в поиске ведёт сюда. Классическая схема с ключами:
Код: Выделить всё
cosign generate-key-pair
cosign sign --key cosign.key registry.cyberlake.ru/app@sha256:abc123...
cosign verify --key cosign.pub registry.cyberlake.ru/app@sha256:abc123...
Главный сдвиг 2026 года - keyless-подпись. Приватных ключей нет вообще (а значит, нечего украсть и нечего ротировать). Вместо ключа CI получает короткоживущий OIDC-токен (от GitHub Actions, GitLab CI и т.п.), Sigstore-сервис Fulcio выдаёт под него эфемерный сертификат на пару минут, а факт подписи навсегда фиксируется в публичном прозрачном логе Rekor. Личность подписавшего - это identity из токена, например конкретный workflow:
Код: Выделить всё
COSIGN_EXPERIMENTAL=1 cosign sign registry.cyberlake.ru/app@sha256:abc123...
cosign verify \
--certificate-identity-regexp "https://github.com/cyberlake/app/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
registry.cyberlake.ru/app@sha256:abc123...
Provenance и attestations. BuildKit/buildx умеет на этапе сборки выпускать SLSA-provenance - подписанную справку "кто, из какого исходника, какой командой собрал":
Код: Выделить всё
docker buildx build \
--provenance=mode=max \
--sbom=true \
--push -t registry.cyberlake.ru/app:1.4.0 .
Docker Content Trust (DCT) - предшественник на базе Notary v1. Это legacy: его уже сворачивают (для Docker Official Images - с августа 2025), с 31 мая 2026 DCT нельзя включить на новых реестрах, а полное удаление намечено на 2028. В новых проектах DCT не бери - только cosign/Sigstore или Notation.
Доверенные базовые образы, distroless и pinning по digest
Лучшая дыра - та, которой нет. Меньше пакетов в образе - меньше CVE и меньше поверхность атаки. Отсюда distroless-образы (gcr.io/distroless): только рантайм и приложение, без shell, без apt, без пакетного менеджера. Атакующему, пробившему процесс, не на чем закрепиться - нет даже /bin/sh. Минус - отлаживать тяжелее (есть :debug-варианты с busybox).
Pinning по digest - намертво фиксируй базовый образ не по тегу, а по хешу:
Код: Выделить всё
FROM debian:12.5-slim@sha256:4f7e3...
Защита от typosquatting. Атака проста: публикуют пакет reqeusts вместо requests или expressjs вместо express, и одна опечатка в Dockerfile или package.json тащит в образ зловред. Контрмеры: тяни базовые образы только из доверенных пространств имён (официальные, проверенные вендоры), для RU-реалий - зеркала Docker Hub и приватные реестры Yandex Container Registry или VK Cloud Container Registry, куда образы попадают через карантин и скан. Не подключай реестр в Dockerfile по случайному хосту.
Политики в реестре (admission). Финальный рубеж - не пускать неподписанное в прод. Admission-контроллер в Kubernetes (Kyverno, Connaisseur, sigstore-policy-controller) на лету проверяет cosign-подпись каждого образа перед запуском пода: нет валидной подписи от твоего CI - под не стартует. Так все предыдущие шаги превращаются из "хорошо бы" в железное правило, которое нельзя обойти, забыв вручную проверить.
Типичные грабли и антипаттерны
- Скан как галочка. Сканировать раз в релиз и не отслеживать новые CVE по сохранённому SBOM. Дыры появляются после сборки, образ при этом не меняется.
- Подпись по тегу вместо digest. Подписал app:latest - подпись отвяжется при следующем пуше. Всегда @sha256:....
- verify без проверки identity. В keyless-режиме cosign verify без --certificate-identity и --certificate-oidc-issuer примет любую валидную подпись из Rekor, в том числе чужую. Подпись есть - доверие нулевое.
- Гонять severity вслепую. Фейлить CI на каждом CRITICAL без учёта Status и достижимости - либо тонешь в шуме, либо отключаешь скан вообще.
- Слепое доверие к самому инструментарию. Сканеры и их базы - тоже звено цепочки (история с компрометацией Trivy). Пинуй версии сканеров, тяни их из доверенных источников.
- Тег вместо digest в FROM - невоспроизводимые сборки и тихая подмена базового слоя.
- Собери простой образ и запушь в свой реестр (или локальный registry:2).
- Прогони trivy image --severity HIGH,CRITICAL и docker scout cves по нему. Сравни находки и разбери поле Status.
- Сгенерь SBOM: syft <образ> -o cyclonedx-json=app.cdx.json. Затем просканируй уже SBOM: grype sbom:app.cdx.json. Убедись, что результат совпал с прямым сканом, но быстрее.
- Пересобери через docker buildx build --provenance=mode=max --sbom=true --push и глянь аттестации: docker buildx imagetools inspect <образ>.
- Подпиши по digest через cosign (ключевой или keyless) и проверь подпись. Намеренно поменяй тег и убедись, что верификация по старому digest всё равно проходит, а по тегу - ловит расхождение.
- Почему SBOM нужен отдельно от сканера, если сканер и так видит все пакеты?
- Чем подпись по digest принципиально надёжнее подписи по тегу?
- Что именно проверяют --certificate-identity и --certificate-oidc-issuer в keyless-верификации и почему без них cosign verify почти бесполезен?
- Зачем distroless и pinning по digest, если образ и так прошёл сканирование?
Безопасность цепочки поставки - это не один инструмент, а конвейер из трёх связанных шагов. Сканируй (trivy, grype, docker scout) - знай, что внутри дырявого. Описывай (SBOM через syft/docker sbom в SPDX или CycloneDX) - чтобы отвечать на завтрашние CVE без пересборки. Подписывай (cosign/Sigstore, keyless через OIDC, buildx provenance) - чтобы доказать происхождение. И замыкай всё политикой в реестре, чтобы неподписанное и дырявое физически не доезжало до прода. Distroless, pinning по digest и доверенные реестры сужают саму поверхность атаки. Тогда следующий громкий CVE для тебя - не паника на сутки, а запрос к SBOM на пару минут.