Cosign в 2026: keyless-подпись контейнеров через GitHub Actions и проверка в Kubernetes

Как подписывать контейнеры без приватных ключей: Cosign v2.4 keyless в GitHub Actions, проверка через Kyverno в Kubernetes, SLSA-provenance и SBOM-attestations. Практический пример workflow и типичные ошибки.

Cosign Keyless: Подпись в GitHub Actions

Обновлено: 30 июля 2026

Cosign keyless — это способ подписать OCI-образ контейнера без хранения приватных ключей. Конвейер получает короткоживущий сертификат от Fulcio на основе OIDC-токена GitHub Actions, факт подписи фиксируется в прозрачном логе Rekor, а Kubernetes через Kyverno проверяет цепочку до допуска пода. В 2026 году это стандартный шаблон supply-chain-защиты для CNCF-стека, и Cosign v2.4 работает keyless по умолчанию. Ниже разберём рабочий конвейер от docker build до admission-политики, без размахивания приватными cosign.key.

  • Cosign v2 подписывает keyless через OIDC-токен GitHub Actions, Fulcio выдаёт сертификат на 10 минут, приватный ключ никуда не сохраняется.
  • Каждая подпись публикуется в Rekor (прозрачном append-only логе), что даёт верифицируемую историю без доверия к моей CI-машине.
  • Проверка в Kubernetes реализуется через Kyverno verifyImages, где политика фиксирует ожидаемого OIDC-issuer и subject workflow.
  • Cosign умеет прикреплять SBOM и SLSA-attestations как отдельные OCI-артефакты, привязанные к digest, а не к тегу.
  • Подписи по тегу небезопасны: всегда проверяю образ по sha256:-digest, чтобы исключить mutable-tag атаки.
  • Notation от CNCF решает похожую задачу, но требует X.509-цепочку и удобнее там, где уже развёрнута корпоративная PKI.

Что такое Cosign и Sigstore

Cosign — это CLI-утилита из проекта Sigstore, которая подписывает и проверяет OCI-артефакты: контейнерные образы, Helm-чарты, SBOM, WASM-модули и произвольные blob'ы. Сам Sigstore при этом не одна утилита, а связка из трёх компонентов. Fulcio выдаёт короткоживущие сертификаты X.509 по OIDC-идентити, Rekor публикует прозрачный append-only лог всех подписей, а Cosign склеивает всё это в один cosign sign. Такой подход убирает главную боль классических PKI, то есть управление приватными ключами в CI.

В 2026 году Sigstore это graduated-проект CNCF, а keyless-подпись поддерживают GitHub Actions, GitLab CI, Buildkite и любые OIDC-совместимые провайдеры. У меня в командах Cosign чаще всего живёт рядом с Trivy для сканирования уязвимостей и генерации SBOM. Схема простая: сначала сканируем, потом подписываем, потом прикрепляем SBOM как attestation. На выходе получаем образ с проверяемой историей от коммита до реестра.

Ключевая метафора: Cosign — это ssh-keygen для контейнеров, только без необходимости беречь id_ed25519. Всё, что вам нужно, это доказать провайдеру идентити (GitHub, Google, что угодно с OIDC), а Fulcio сам выпишет сертификат на пару минут.

Как работает keyless-подпись через OIDC

Механика keyless-подписи выглядит так. Ваш CI-раннер запрашивает у OIDC-провайдера JWT-токен с claim'ами вроде iss=https://token.actions.githubusercontent.com и sub=repo:acme/api:ref:refs/heads/main. Cosign генерирует одноразовую пару ключей ephemeral в памяти, отправляет публичную часть плюс OIDC-токен в Fulcio, а Fulcio возвращает X.509-сертификат, где расширения содержат issuer и subject из токена. Сертификат живёт около 10 минут. Как раз достаточно, чтобы подписать артефакт.

Подпись, публичный ключ и сертификат пушатся в OCI-реестр рядом с образом (обычно как объекты вида sha256-...sig), а хеш подписи регистрируется в Rekor, публичном логе, работающем через Merkle tree. Это даёт то, ради чего всё затевалось: даже если завтра кто-то скомпрометирует раннер, вы сможете доказать, что подпись уже была в Rekor на конкретный момент времени, потому что лог неизменяем.

Приватный ключ уничтожается сразу после подписи, и это принципиально. Нет ключа, нет KMS, нет секрета в CI, нет ротации. Есть только identity вашего workflow. Именно поэтому проверяющая сторона (Kyverno, admission controller, локальный cosign verify) должна валидировать не «принадлежит ли подпись такому-то ключу», а «выписан ли сертификат ожидаемому issuer'у на ожидаемый subject».

# Concept flow (человекочитаемый псевдокод, не запускать)
Runner  --(OIDC JWT)-->  Fulcio  --(X.509 cert, 10min TTL)--> Runner
Runner  --(sign digest, upload cert+sig)-->  OCI Registry
Runner  --(log entry)-->  Rekor (transparency log)

Установка Cosign v2.4 и проверка версии

Cosign распространяется как статический бинарь для Linux, macOS и Windows. На Ubuntu 24.04, Debian 12 и Rocky 9 я обычно ставлю фиксированную версию из GitHub Releases, потому что подгружать «latest» в CI, честно говоря, плохая идея. Любой релиз завтра может сломать пайплайн. Актуальная стабильная линия на середину 2026 это v2.4.x, где keyless включён по умолчанию и убраны предупреждения --yes для non-interactive окружений.

# Установка Cosign v2.4.1 на Linux x86_64
COSIGN_VERSION="v2.4.1"
curl -sSLo /usr/local/bin/cosign \
  "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64"
chmod +x /usr/local/bin/cosign

# Проверяем подпись самого cosign. Рекурсия, но по делу
curl -sSLo cosign.sig \
  "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64.sig"
curl -sSLo cosign.pub \
  "https://raw.githubusercontent.com/sigstore/cosign/main/release/release-cosign.pub"
cosign verify-blob --key cosign.pub --signature cosign.sig /usr/local/bin/cosign

cosign version --json | jq '.GitVersion'
# ожидаем: "v2.4.1"

Для локальной разработки этого достаточно. Для запуска в контейнере CI используйте официальный образ ghcr.io/sigstore/cosign/cosign:v2.4.1. Он весит около 40 МБ и не тащит с собой Docker-daemon. Я держу его закреплённым по digest, а не по тегу, ровно по тем же причинам, по которым проверяю production-образы по digest.

Подпись образов в GitHub Actions без ключей

Это самый частый сценарий и та часть, ради которой обычно и приходят к Sigstore. Ниже приведён полный workflow, который собирает образ, пушит в GHCR, подписывает keyless и прикрепляет SBOM. Обратите внимание на блок permissions. Без id-token: write раннер не сможет запросить OIDC-токен, и cosign sign упадёт с непонятной ошибкой. Я на этом залипал часа полтора в первый раз.

# .github/workflows/build-sign.yml
name: build-sign-image

on:
  push:
    branches: [main]
    tags: ['v*']

permissions:
  contents: read
  packages: write
  id-token: write   # обязательно для keyless

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-24.04
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/setup-buildx-action@v3

      - id: build
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          # Buildx возвращает digest, используем его для подписи
          provenance: mode=max
          sbom: true

      - uses: sigstore/cosign-installer@v3
        with:
          cosign-release: 'v2.4.1'

      - name: Cosign keyless sign by digest
        env:
          COSIGN_EXPERIMENTAL: "false"    # v2 не требует experimental
        run: |
          IMAGE="${REGISTRY}/${IMAGE_NAME}@${{ steps.build.outputs.digest }}"
          cosign sign --yes "$IMAGE"

      - name: Generate & attach SBOM as attestation
        run: |
          IMAGE="${REGISTRY}/${IMAGE_NAME}@${{ steps.build.outputs.digest }}"
          # Trivy пишет SBOM в SPDX-JSON
          trivy image --format spdx-json --output sbom.spdx.json "$IMAGE"
          cosign attest --yes \
            --predicate sbom.spdx.json \
            --type spdxjson \
            "$IMAGE"

Пара вещей, которые я на своей шкуре понял. Первое: всегда подписывайте образ по digest, а не по тегу. Тег в OCI мутируемый, кто-то с правами push может переклеить его на другой образ, и ваша подпись «поедет» вместе с тегом на новый digest, где вас не спрашивали. Второе: --yes нужен именно потому, что в CI никто не подтвердит интерактивный prompt. Третье: attestations прикрепляются к digest, поэтому они автоматически «следуют» за правильным образом.

Как проверить подпись keyless-образа

Проверка это половина работы. Подпись без проверки бесполезна, потому что злоумышленник может залить свой образ и «подписать» его от своего репозитория. Если вы не проверяете issuer и subject, вы примете такую подпись за легитимную. Правильная команда cosign verify явно фиксирует ожидаемый OIDC-issuer и regexp по subject.

# Локальная проверка: подпись выпущена именно нашим workflow из main
IMAGE="ghcr.io/acme/api@sha256:9a8b7c...deadbeef"

cosign verify \
  --certificate-identity-regexp "^https://github.com/acme/api/\.github/workflows/build-sign\.yml@refs/heads/main$" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "$IMAGE" | jq '.[0].optional'

# Проверка attestation (SBOM)
cosign verify-attestation \
  --type spdxjson \
  --certificate-identity-regexp "^https://github.com/acme/api/\.github/workflows/.*@refs/heads/main$" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "$IMAGE"

Из практики: если вы промахнулись с regexp в certificate-identity-regexp, Cosign вернёт скупое «no matching signatures». Это не значит, что подписи нет. Просто ни одна из существующих не удовлетворяет вашей политике. Полезный дебаг-приём: сначала запустить cosign download signature IMAGE, распарсить сертификат через openssl x509 -in cert.pem -text и посмотреть, что реально стоит в SAN. Честно говоря, у меня половина инцидентов «не работает подпись» решается сравнением реального subject с regexp'ом.

# Быстрый CI-шаг: fail-fast если образ не подписан из ожидаемого репозитория
- name: Verify image before deploy
  run: |
    cosign verify \
      --certificate-identity-regexp "^https://github.com/${{ github.repository }}/.+" \
      --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
      "${REGISTRY}/${IMAGE_NAME}@${DIGEST}" \
      || { echo "::error::image is not signed by trusted workflow"; exit 1; }

Проверка в Kubernetes через Kyverno

Проверять на этапе CI полезно, но недостаточно. Рано или поздно кто-то запустит образ мимо вашего пайплайна. Правильный ответ, это admission-контроллер, который отбрасывает поды без валидной подписи. В 2026 году канонический выбор это Kyverno с политикой verifyImages. Она поддерживает Cosign keyless из коробки, работает с Rekor, а правила пишутся в чистом YAML без Rego.

# kyverno/verify-cosign-keyless.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-acme-images
  annotations:
    policies.kyverno.io/title: Verify Sigstore keyless signatures
spec:
  validationFailureAction: Enforce
  webhookTimeoutSeconds: 30
  rules:
    - name: verify-signature
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces: [production, staging]
      verifyImages:
        - imageReferences:
            - "ghcr.io/acme/*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/acme/*/.github/workflows/*@refs/heads/main"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: "https://rekor.sigstore.dev"
          mutateDigest: true   # заменяет тег на sha256-digest в спеке пода
          required: true

Флаг mutateDigest: true, честно говоря, моя любимая деталь. Kyverno при допуске переписывает image: ghcr.io/acme/api:v1.2.3 в image: ghcr.io/acme/api@sha256:..., фиксируя ровно тот digest, который был проверен. Это ловит классическую атаку «подменили тег между admission и pull». Без mutateDigest kubelet мог бы скачать уже другой образ.

Тест политики без риска для production это validationFailureAction: Audit. Kyverno будет писать нарушения в события, но не блокировать. Я оставляю Audit на 1–2 недели, вычищаю все не подписанные образы (обычно это утилиты в kube-system и пара забытых side-car'ов), потом переключаю в Enforce.

Cosign против Notation: сравнительная таблица

Cosign не единственный игрок. CNCF также развивает Notation (бывший Notary v2), который решает похожую задачу, но с другими компромиссами. Ниже сравнение по параметрам, которые обычно интересуют DevSecOps-команду при выборе.

КритерийCosign (Sigstore)Notation (CNCF)
Модель ключейKeyless через OIDC + Fulcio, либо статический ключТолько X.509-цепочка, требует управляемой PKI
Прозрачный логRekor (публичный или self-hosted)Опционально, через OCI 1.1 referrers
Attestations (SBOM, SLSA)Первоклассная поддержка, cosign attestЧерез отдельные проекты (например, ORAS)
Проверка в KubernetesKyverno, Sigstore Policy Controller, ConnaisseurRatify (CNCF), Notation Verifier
Кривая обученияНизкая для keyless, высокая для offline-KMSСредняя, но требует PKI-экспертизы
Соответствие complianceFIPS-режим с v2.2+, но keyless сложнее аудироватьЛучше для регулируемых сред с внутренней PKI
Экосистема 2026Доминирует в open-source, поддержка в GitHub, GitLab, ArgoCDАктивно в enterprise, Azure Container Registry, Docker Scout

Прагматичная рекомендация. Если вы в CNCF-стеке, используете GitHub Actions или GitLab, и у вас нет корпоративной PKI, берите Cosign. Если у вас Enterprise-инфраструктура с HSM, внутренним CA и требованиями регуляторов, где keyless-модель звучит подозрительно, смотрите Notation. Смешивать в одном реестре можно, но админить два discovery-механизма, честно, так себе удовольствие.

SLSA-provenance и attestations

SLSA (Supply-chain Levels for Software Artifacts) это уровневая модель supply-chain-зрелости от OpenSSF. Cosign выступает в ней как транспорт для двух вещей: SBOM-attestation (что внутри образа) и provenance-attestation (как именно образ был собран, какой коммит, какой билдер, какие входы). SLSA v1.0 требует, чтобы provenance-документ был подписан билдером, и Sigstore keyless как раз это и даёт бесплатно.

# В GitHub Actions есть готовый reusable-workflow для SLSA Level 3
# .github/workflows/build-sign.yml (продолжение)
  provenance:
    needs: build
    permissions:
      actions: read
      id-token: write
      packages: write
    uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
    with:
      image: ghcr.io/${{ github.repository }}
      digest: ${{ needs.build.outputs.digest }}
      registry-username: ${{ github.actor }}
    secrets:
      registry-password: ${{ secrets.GITHUB_TOKEN }}

После этого рядом с образом лежат три вещи: *.sig (подпись), *.att с типом spdxjson (SBOM) и *.att с типом slsaprovenance (кто собрал). Kyverno-политика может требовать одновременно и подпись, и SBOM, и SLSA v1.0 provenance. Тогда в production не попадёт ни один образ, о происхождении которого мы не знаем всё.

# Дополнительный attestor в Kyverno-политике: SLSA provenance обязателен
verifyImages:
  - imageReferences: ["ghcr.io/acme/*"]
    attestations:
      - type: https://slsa.dev/provenance/v1
        attestors:
          - entries:
              - keyless:
                  subject: "https://github.com/slsa-framework/*"
                  issuer: "https://token.actions.githubusercontent.com"
        conditions:
          - all:
              - key: "{{ buildDefinition.buildType }}"
                operator: Equals
                value: "https://github.com/actions/runner"

Типичные ошибки и как их избегать

За несколько десятков внедрений Cosign я собрал коллекцию грабель, на которых стабильно спотыкается новая команда. Разложу по частотности.

1. Забытый id-token: write в workflow

Симптом: cosign sign падает с Error: getting signer: getting key from Fulcio: no OIDC token. Причина: GitHub не выдаёт OIDC-токен джобу, где не разрешён id-token. Лечится добавлением permissions на уровне job или workflow.

2. Подпись по тегу вместо digest

Симптом: сегодня cosign verify ghcr.io/acme/api:latest работает, завтра нет. Причина: тег переписан на новый push. Решение: всегда подписывайте и проверяйте по image@sha256:.... Buildx возвращает digest в steps.build.outputs.digest, оттуда и берите.

3. Слишком широкий regexp по subject

Регулярка .* в certificate-identity-regexp это фактически «принять подпись от кого угодно, кто прошёл через Fulcio». А через Fulcio может пройти кто угодно с валидным GitHub-аккаунтом. Всегда фиксируйте организацию и репозиторий, минимум до ^https://github.com/acme/.+.

4. Игнорирование шума от сканеров

Trivy и Grype любят подсвечивать CVE-шум в базовом образе, где реально ничего не эксплуатируется. Не смешивайте это с политикой подписей: подпись говорит «это наш образ», а не «в нём нет уязвимостей». Держите две ортогональные политики, одну для сканирования, вторую для подписи, иначе PR-ы будут блокироваться по несвязанным причинам.

5. Отсутствие break-glass процедуры

Kyverno в Enforce может заблокировать hotfix, если Fulcio или Rekor лежат (такое бывает раз в год). Держите заранее подписанные emergency-образы или namespace-исключение, документированное в runbook.

# pre-commit хук: не даём коммитить workflow без id-token: write в jobs, использующих cosign
# .git/hooks/pre-commit (упрощённо)
#!/usr/bin/env bash
if git diff --cached --name-only | grep -q '\.github/workflows/.*\.yml$'; then
  for f in $(git diff --cached --name-only | grep '\.github/workflows/.*\.yml$'); do
    if grep -q 'cosign sign' "$f" && ! grep -q 'id-token: write' "$f"; then
      echo "ERROR: $f uses cosign sign but lacks 'id-token: write' permission" >&2
      exit 1
    fi
  done
fi

Часто задаваемые вопросы

Насколько безопасен keyless-подпись без приватных ключей?

Безопаснее, чем хранить приватный ключ в CI-секретах, при условии, что вы жёстко проверяете issuer и subject сертификата. Модель угроз смещается с «не потерять ключ» на «не пустить чужую identity в свою политику», что аудитить проще. Ephemeral-сертификат живёт 10 минут, и подделать OIDC-токен GitHub на нужный sub без компрометации репозитория невозможно.

Можно ли использовать Cosign без публичной Sigstore-инфраструктуры?

Да. Sigstore-стек (Fulcio, Rekor, TUF-root) можно развернуть self-hosted. Для air-gapped-сред это обязательно. Cosign поддерживает флаги --fulcio-url, --rekor-url и SIGSTORE_ROOT_FILE, чтобы работать с приватной копией.

В чём разница между cosign sign и cosign attest?

cosign sign подписывает digest образа как таковой, это «я разрешаю запуск этого образа». cosign attest подписывает произвольное утверждение о образе (SBOM, результат сканирования, SLSA-provenance), это «я утверждаю, что об этом образе известно то-то». Проверяются они разными командами: verify и verify-attestation.

Как проверить подписанный образ в Kubernetes без Kyverno?

Альтернативы Kyverno: Sigstore Policy Controller (родной от Sigstore, работает через ClusterImagePolicy) и Connaisseur (популярен в Docker-мире). Оба поддерживают Cosign keyless. Если у вас уже есть OPA Gatekeeper, для Cosign там нужен внешний data-provider, путь неудобный, лучше поставить рядом Kyverno.

Работает ли Cosign с приватными реестрами вроде Harbor или ECR?

Да, Cosign работает с любым OCI-совместимым реестром, включая Harbor, ECR, Artifact Registry и JFrog. Единственное требование: реестр должен поддерживать OCI 1.1 referrers или fallback-схему с тегами sha256-...sig. Все крупные вендоры это поддерживают с 2024 года.

Raj Patel
Об авторе Raj Patel

DevSecOps engineer who's gradually turning every CI pipeline he sees into a security-checking machine.