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

Подписываем образы без ключей: Sigstore и cosign в CI

8 мин чтения

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

Содержание

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

Почему подпись с ключами не взлетает

Классическая схема: генерируете пару ключей, приватный кладёте в секреты CI, публичным проверяете. На бумаге всё хорошо, на практике начинаются вопросы, на которые ни у кого нет хорошего ответа.

Ключ живёт годами. Это значит, что он рано или поздно протечёт — в лог, в форк, в скомпрометированный раннер. Утёк ключ — нужно перевыпустить его и переподписать всё, что им когда-либо подписывалось. Ротация делается руками, а значит не делается. И главное: по подписи нельзя сказать, кто и в каком пайплайне её поставил. Подпись подтверждает «у кого-то был этот ключ», а не «образ собран вот этим релизным workflow из вот этого репозитория».

В итоге команда либо не подписывает образы совсем, либо заводит один общий ключ «на всё» — что хуже, чем не подписывать, потому что создаёт иллюзию контроля.

Где живёт доверие: ключ в секретах против keyless

Что такое keyless

Keyless — это подпись без долгоживущего ключа. Вместо «у меня есть секрет» подписант доказывает «я — вот этот workflow», а инфраструктура Sigstore превращает это доказательство в проверяемую подпись. Работают три компонента:

Поток на каждый запуск выглядит так: CI получает OIDC-токен → Fulcio проверяет токен и выдаёт сертификат → cosign подписывает образ этим сертификатом → запись уходит в Rekor → сертификат истекает. Красть нечего: к моменту, когда атакующий доберётся до раннера, сертификата уже не существует.

Keyless-поток: OIDC → Fulcio → cosign → Rekor

Подписать образ — это не одно и то же

Когда говорят «подписать», часто смешивают три разные вещи. Полезно держать их раздельно:

cosign умеет всё три через cosign sign, cosign attest --type spdx и cosign attest --type slsaprovenance. Начать стоит с подписи образа — она даёт максимум пользы при минимуме усилий. SBOM и provenance добавляются тем же keyless-механизмом, без отдельной инфраструктуры: тот же блок id_tokens, тот же Fulcio, та же запись в Rekor — меняется только тип вложения. Поэтому правильный порядок внедрения — сначала включить подпись образа во всех релизных пайплайнах, убедиться, что проверка работает на admission, и лишь потом добавлять attestation-ы. Если начать со «всего сразу», легко застрять на генерации SBOM и так и не дойти до самого важного — факта, что в кластер не попадёт неподписанный образ.

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

Ключ в секретах CIKeyless (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 впервые.

Итог

Keyless снимает почти все операционные причины не подписывать образы: нет ключа — нет хранения, нет ротации, нет «что делать при утечке». Взамен подпись становится привязана к личности пайплайна, а не к секрету, и проверяется по публичному логу. Цена входа — один блок id_tokens в job и пара секунд в сборке. Для команды, которая до сих пор «соберётся подписывать потом», это самый дешёвый способ закрыть целый класс атак на цепочку поставок уже на этой неделе.


Поделиться:

Предыдущая статья
MCP-сервер Intervals.icu в Claude через Pomerium
Следующая статья
Свой Matrix + Element: мессенджер, который принадлежит вам