Cosign и Harbor против неподписанных образов: внедряем supply chain security в CI
Внедряем подпись контейнерных образов через Cosign/Sigstore в CI-конвейере. Harbor теперь отклоняет неподписанные образы на этапе admission control.
Sigstore и SLSA framework набирают популярность как ответ на угрозы supply chain после MOVEit и 3CX, 2023
После всей этой истории с MOVEit и 3CX мы стали значительно внимательнее смотреть не только на периметр, но и на то, что именно деплоится в продакшн. Один из клиентов на управляемой инфраструктуре задал прямой вопрос: «Как вы можете гарантировать, что образ, который запущен в кластере, - это именно то, что собрала наша CI-система, а не что-то подменённое?» Прямого ответа тогда не было. Теперь есть.
Откуда проблема
Атака на цепочку поставок - не абстракция. 3CX скомпрометировали через зависимость в билд-процессе, и итогом стал подписанный вендором установщик с вредоносным кодом. В случае контейнеров вектор чуть другой, но принцип похожий: кто-то может подменить образ в реестре между сборкой и деплоем, или собрать «правильный» образ из скомпрометированного базового.
Классический ответ - «мы доверяем нашему Harbor» - не работает: реестр не защищает от собственной компрометации, от человека с доступом к push, и не даёт никакой verifiable provenance - доказательства происхождения образа. Образ может выглядеть правильно и быть совершенно не тем.
Что такое Sigstore и Cosign
Sigstore - проект Linux Foundation, запущенный Google, Red Hat и Purdue University. Идея: сделать подпись артефактов настолько удобной, чтобы «не подписывать» стало исключением. В основе - несколько компонентов:
- Cosign - утилита для подписи и верификации OCI-образов и других артефактов.
- Fulcio - certificate authority, который выдаёт краткосрочные сертификаты под OIDC-идентификацию (GitHub Actions, Google, Microsoft и т.д.).
- Rekor - публичный append-only лог подписей, аналог Certificate Transparency для артефактов.
Ключевая особенность - keyless signing через ephemeral-ключи и OIDC. Не нужно хранить долгоживущий приватный ключ: CI-среда получает OIDC-токен (например, из GitHub Actions), Fulcio выдаёт краткосрочный сертификат, Cosign подписывает образ, запись уходит в Rekor. Верификатор потом может проверить, что подпись принадлежит конкретному GitHub-репозиторию и конкретному workflow.
Как мы это внедряли
Клиент - небольшая команда разработки, GitLab CI, Harbor как внутренний реестр, Kubernetes-кластер на продакшне. Цель: каждый образ, который уходит в продакшн, должен быть подписан CI-системой, и Kubernetes должен это проверять.
Шаг первый - подпись в CI. В GitLab CI нет из коробки OIDC-интеграции с Fulcio такого же уровня, как в GitHub Actions. Мы остановились на keyless-подходе с собственным Fulcio-инстансом (можно поднять локально) или на более простом варианте - подпись с долгоживущим ключом, который хранится в GitLab CI Variables. Для начала выбрали второе: проще аудировать, и на этом этапе нам важна была сама механика, а не максимальная автоматизация ключевого материала.
В .gitlab-ci.yml добавился шаг после docker push:
sign:
stage: sign
image: cgr.dev/chainguard/cosign:latest
script:
- cosign sign --key env://COSIGN_PRIVATE_KEY
${HARBOR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}
variables:
COSIGN_PASSWORD: ${COSIGN_PASSWORD}
COSIGN_PRIVATE_KEY: ${COSIGN_PRIVATE_KEY}
Публичный ключ лежит в репозитории - это нормально, он нужен для верификации.
Шаг второй - policy enforcement в Harbor. Harbor с версии 2.x поддерживает Notary (старый стандарт) и, с 2.5+, верификацию через cosign напрямую. У клиента стоял Harbor 2.7, что нам подошло. В настройках проекта включили Content Trust и настроили webhook на policy violation.
Но одного реестра мало: образ можно обойти, указав digest напрямую из скомпрометированного источника. Поэтому второй рубеж - admission control в Kubernetes.
Шаг третий - admission control. Для верификации на уровне кластера существует несколько вариантов. Мы попробовали Kyverno с ClusterPolicy:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signature
spec:
validationFailureAction: Enforce
rules:
- name: check-image-signature
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences:
- "harbor.internal.example.com/*"
attestors:
- count: 1
entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
С этого момента любой Pod, у которого образ из нашего Harbor не подписан соответствующим ключом, отклоняется на этапе admission. Kubernetes просто не даёт его запустить.
Где споткнулись
Первое - init-контейнеры и sidecar'ы. Policy verifyImages применяется ко всем контейнерам в Pod-спеке, включая init-контейнеры. Несколько стандартных образов (busybox, distroless для отладки) у клиента не были подписаны нашим ключом. Первый deploy после включения Enforce-режима упал в Pending - и несколько минут было непонятно почему, потому что сообщение об ошибке admission webhook не самое дружелюбное.
Решение: whitelist для конкретных образов из публичных реестров через отдельное правило с mutate или через imageReferences с исключениями. Это немного усложняет политику, но зато явно.
Второе - теги против дайджестов. Cosign подписывает конкретный digest, а не тег. Если CI пушит образ с тегом latest, потом его кто-то перезатирает - подпись привязана к старому digest. Kyverno при верификации использует digest из подписи и сверяет с тем, что реально запущено. Это поведение правильное, но сначала сбивает с толку.
Вывод, который мы сделали: в CI надо пушить по digest, а тег - это просто alias. В imagePullPolicy: Always без pining digest нет никаких гарантий воспроизводимости.
Что получилось
Сейчас любой образ, который не прошёл через нашу CI-систему и не подписан её ключом, не попадёт в продакшн-кластер. Это не закрывает все векторы supply chain - скомпрометированная CI-система подпишет что угодно - но убирает целый класс рисков: случайный push не того образа, атаку на реестр без компрометации CI, человеческую ошибку при деплое «потестировать вручную».
SBOM как следующий шаг - Cosign умеет аттестовать произвольные artефакты, в том числе SBOM в формате SPDX или CycloneDX, привязанные к образу. Мы с этим пока работаем отдельно, внедряем syft в пайплайн. Но это уже отдельная история.
Вся работа заняла около двух недель с учётом отладки политик Kyverno и обновления Harbor. По меркам «это надо было сделать три года назад» - небыстро, но лучше сейчас, чем объяснять клиенту после инцидента, что мы «доверяли реестру».