Подпись артефактов долго оставалась той задачей, которую все согласны делать «потом». Причина почти всегда одна: куда деть приватный ключ. Sigstore переворачивает вопрос — ключа нет вообще. CI-задача доказывает свою личность по OIDC, получает сертификат на десять минут, подписывает образ, и факт подписи навсегда уходит в публичный лог. Разберёмся, как это устроено и что нужно, чтобы включить такое у себя.
Содержание
Открыть содержание
Почему подпись с ключами не взлетает
Классическая схема: генерируете пару ключей, приватный кладёте в секреты CI, публичным проверяете. На бумаге всё хорошо, на практике начинаются вопросы, на которые ни у кого нет хорошего ответа.
Ключ живёт годами. Это значит, что он рано или поздно протечёт — в лог, в форк, в скомпрометированный раннер. Утёк ключ — нужно перевыпустить его и переподписать всё, что им когда-либо подписывалось. Ротация делается руками, а значит не делается. И главное: по подписи нельзя сказать, кто и в каком пайплайне её поставил. Подпись подтверждает «у кого-то был этот ключ», а не «образ собран вот этим релизным workflow из вот этого репозитория».
В итоге команда либо не подписывает образы совсем, либо заводит один общий ключ «на всё» — что хуже, чем не подписывать, потому что создаёт иллюзию контроля.
Что такое keyless
Keyless — это подпись без долгоживущего ключа. Вместо «у меня есть секрет» подписант доказывает «я — вот этот workflow», а инфраструктура Sigstore превращает это доказательство в проверяемую подпись. Работают три компонента:
- cosign — CLI, который собственно подписывает и проверяет. Запускается в вашем пайплайне.
- Fulcio — удостоверяющий центр. Принимает OIDC-токен от CI и выдаёт короткоживущий (≈10 минут) сертификат, в котором зашита identity подписанта: например,
https://gitlab.com/acme/app//.gitlab-ci.yml@refs/tags/v1.2.0. - Rekor — публичный transparency log. В него попадает запись о подписи: что подписали, какой identity, когда. Лог append-only — задним числом ничего не подменить.
Поток на каждый запуск выглядит так: CI получает OIDC-токен → Fulcio проверяет токен и выдаёт сертификат → cosign подписывает образ этим сертификатом → запись уходит в Rekor → сертификат истекает. Красть нечего: к моменту, когда атакующий доберётся до раннера, сертификата уже не существует.
Подписать образ — это не одно и то же
Когда говорят «подписать», часто смешивают три разные вещи. Полезно держать их раздельно:
- Подпись образа — самое простое: «этот digest пришёл от меня». Отвечает на вопрос «не подменили ли образ по дороге».
- Подпись SBOM — прикрепляете к образу заверенный список того, что внутри (пакеты, версии). Отвечает на «из чего собран образ» и кормит сканеры уязвимостей.
- Подпись provenance — заверенное описание, как образ собран: какой коммит, какой workflow, какие входные данные. Это база для SLSA. Подробнее про provenance — в отдельной статье про SLSA Level 2.
cosign умеет всё три через cosign sign, cosign attest --type spdx и cosign attest --type slsaprovenance. Начать стоит с подписи образа — она даёт максимум пользы при минимуме усилий. SBOM и provenance добавляются тем же keyless-механизмом, без отдельной инфраструктуры: тот же блок id_tokens, тот же Fulcio, та же запись в Rekor — меняется только тип вложения. Поэтому правильный порядок внедрения — сначала включить подпись образа во всех релизных пайплайнах, убедиться, что проверка работает на admission, и лишь потом добавлять attestation-ы. Если начать со «всего сразу», легко застрять на генерации SBOM и так и не дойти до самого важного — факта, что в кластер не попадёт неподписанный образ.
Сравнение в одной таблице
| Ключ в секретах CI | Keyless (Sigstore) | |
|---|---|---|
| Что хранится | приватный ключ годами | ничего |
| Срок жизни доверия | до ротации (≈никогда) | ~10 минут |
| Что при утечке | переподписать всё | красть нечего |
| Кто подписант | неизвестно | OIDC identity workflow |
| Аудит | свой реестр подписей | публичный Rekor |
| Настройка | секрет + ротация | один блок id_tokens |
Что нужно, чтобы подписывать образы в CI
Минимально нужно три вещи: право на OIDC-токен, шаг подписи и шаг проверки. Вот рабочий .gitlab-ci.yml, который собирает образ, пушит в GitLab Container Registry и подписывает его keyless.
stages: [build, sign]
variables:
IMAGE: $CI_REGISTRY_IMAGE # Container Registry проекта
build:
stage: build
image: docker:27
services: [docker:27-dind]
rules:
- if: $CI_COMMIT_TAG
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build -t "$IMAGE:$CI_COMMIT_TAG" .
- docker push "$IMAGE:$CI_COMMIT_TAG"
- DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE:$CI_COMMIT_TAG")
- echo "DIGEST=$DIGEST" >> build.env
artifacts:
reports:
dotenv: build.env # передаём digest в следующий job
sign:
stage: sign
image: bitnami/cosign:latest
rules:
- if: $CI_COMMIT_TAG
id_tokens:
SIGSTORE_ID_TOKEN: # без этого keyless не заработает
aud: sigstore
script:
- cosign sign --yes "$DIGEST"
Ключевой блок — id_tokens. Он просит GitLab выпустить OIDC-токен с audience sigstore и кладёт его в переменную SIGSTORE_ID_TOKEN — cosign подхватывает её автоматически и отдаёт Fulcio. Флаг --yes подтверждает запись в публичный Rekor без интерактивного вопроса. Подписываем по digest, а не по тегу: тег можно переставить на другой образ, digest — нет.
На стороне получателя (другой пайплайн, сервер, кластер) проверка выглядит так — нужно указать, кому мы доверяем:
cosign verify \
--certificate-identity-regexp '^https://gitlab.com/acme/app//' \
--certificate-oidc-issuer https://gitlab.com \
registry.gitlab.com/acme/app@sha256:...
Без двух --certificate-* флагов проверка бессмысленна: она подтвердит лишь «подписано чем-то валидным из Sigstore», а не «подписано нашим релизным пайплайном». Identity — это и есть политика доверия. (Для self-managed GitLab issuer — это URL вашего инстанса, а не gitlab.com.)
Блокируем неподписанное на admission
Проверять руками никто не будет — поэтому правило вешают на admission в кластере. Kyverno умеет это политикой verifyImages:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
spec:
validationFailureAction: Enforce
rules:
- name: verify-gitlab
match:
any:
- resources:
kinds: ["Pod"]
verifyImages:
- imageReferences:
- "registry.gitlab.com/acme/*"
attestors:
- entries:
- keyless:
issuer: https://gitlab.com
subject: "https://gitlab.com/acme/app//*"
Теперь под с неподписанным или «чужим» образом просто не запустится: Kyverno сверяет подпись и identity до того, как kube-scheduler увидит под.
Как проверить, что всё работает
Три быстрые проверки. Первая — что подпись вообще есть и проходит по нужной identity (команда cosign verify выше; на успехе печатает JSON с проверенной записью). Вторая — что попало в transparency log:
cosign tree registry.gitlab.com/acme/app@sha256:...
Команда покажет привязанные к образу подписи и attestation-ы. Третья — негативный тест: задеплойте заведомо неподписанный образ в кластер с политикой и убедитесь, что admission его отклонил. Политика, которую ни разу не видели срабатывающей, обычно настроена в режиме Audit, а не Enforce.
Ловушка: Rekor публичен
Главное, что нужно держать в голове: Rekor — публичный лог. В него уходит ваш identity подписанта и метаданные — имя репозитория, ветка/тег, имя workflow. Сам образ туда не попадает, но факт «репозиторий acme/secret-project собрал релиз» становится виден всем. Для большинства это нормально и даже полезно. Если же само существование артефакта — секрет, нужен приватный инстанс Sigstore (Rekor + Fulcio в своём контуре). И отдельно: не суйте в SBOM или provenance внутренние хостнеймы и пути — заверенный attestation тоже уезжает в лог.
Частые грабли
Несколько ошибок встречаются почти у всех, кто включает keyless впервые.
- Забыли блок
id_tokens. Самая частая. Без него GitLab не выпускает OIDC-токен и переменнуюSIGSTORE_ID_TOKEN, иcosign signпадает с невнятной ошибкой про отсутствие токена. Блок задаётся на уровне конкретной job, а audience обязан бытьsigstore— иначе Fulcio отвергнет токен. - Проверяют без identity.
cosign verifyбез--certificate-identity*и--certificate-oidc-issuerтехнически проходит, но не проверяет ничего полезного. Это легко принять за «у нас всё подписано и проверяется», хотя на деле пройдёт любой образ, подписанный кем угодно через публичный Sigstore. - Подписывают по тегу. Подпись по
:latestили:v1бессмысленна: тег — это указатель, его можно переставить на другой образ уже после подписи. Подписывайте и проверяйте только по@sha256:-digest. - Политика в режиме
Audit. Kyverno сvalidationFailureAction: Auditлишь пишет в лог, но пропускает неподписанные образы. Пока не переключили наEnforceи не проверили негативным тестом — защиты нет, есть только отчёт о её отсутствии. - Считают, что Rekor можно отключить «для приватности». Можно подписывать без публичного лога (
--tlog-upload=false), но тогда вы теряете главное доказательство и возможность проверки по записи. Если публичность мешает — поднимайте приватный инстанс, а не выключайте лог.
Итог
Keyless снимает почти все операционные причины не подписывать образы: нет ключа — нет хранения, нет ротации, нет «что делать при утечке». Взамен подпись становится привязана к личности пайплайна, а не к секрету, и проверяется по публичному логу. Цена входа — один блок id_tokens в job и пара секунд в сборке. Для команды, которая до сих пор «соберётся подписывать потом», это самый дешёвый способ закрыть целый класс атак на цепочку поставок уже на этой неделе.