Перейти к содержимому

Supply-chain security: SBOM и подпись образов

В предыдущей главе ты закрыл периметр одного образа: чистый скан, подпись, non-root, capabilities drop. Но образ — это только кончик. Чтобы app:1.4.2 появился в реестре, сквозь твою инфраструктуру прошли тысячи чужих рук: авторы npm-пакетов и базовых образов, maintainer’ы GitHub Action’ов, сама CI-система, которая собирала бинарник, и сеть, по которой он ехал до registry. Supply-chain security — это защита всей цепочки, а не конечного звена.

Контекст стоит понимать всерьёз. Эпизод с пакетом event-stream в npm: maintainer передал пакет незнакомцу, тот добавил украденный код, который год собирал биткоин-кошельки у пользователей. Взлом SolarWinds: злоумышленники встроили бэкдор в билд-систему, и обновления с подписью компании стали доставлять вредоносный код в правительственные сети. Секреты, утёкшие через логи CI, — это третий типичный путь. Общий знаменатель: атакуют не твой код, а всё, от чего он зависит.

В краткой версии ты видел сканирование Trivy и подпись Cosign как два изолированных приёма. Здесь соберём полный контур доверия: манифест состава образа (SBOM), глубокое сканирование, криптографическую подпись с привязкой к идентичности CI, provenance-аттестации по модели SLSA и правила гигиены для самого пайплайна. Побочный эффект, который недооценивают: когда SBOM и подпись есть в каждом релизе, расследование инцидентов ускоряется на порядок — вместо «а что у нас вообще стоит на проде» у тебя машиночитаемый список всего.

Прежде чем ставить инструменты, разложи поверхности атаки по этапам жизни артефакта:

Этап Атака Пример
Зависимости Захват пакета, typosquatting event-stream, поддельный reqeusts
Базовый образ Подмена тега в публичном registry перезапись latest вредоносным слоем
CI-сборка Вредоносный action, утечка секрета скомпрометированный actions/checkout
Реестр Пуш по украденному токену подмена образа в ghcr.io
Деплой Pull без верификации неподписанный docker run на проде

Заметь закономерность: ни одну из этих атак не остановит антивирус или firewall — они бьют по доверию, а не по эксплойтам. Поэтому ответ тоже криптографический: подписи, дайджесты, аттестации. Каждая строка таблицы закрывается конкретной практикой из этой главы: пиннинг зависимостей (две ниже), SBOM, keyless-подпись, верификация перед docker pull.

SBOM (Software Bill of Materials) — машиночитаемый манифест всего, что вошло в артефакт: OS-пакеты, npm-зависимости, их версии и лицензии. Когда выходит критический CVE (типа Log4Shell), вопрос «уязвимы ли мы» превращается из недели раскопок в один запрос по SBOM-ам всех образов.

Два стандарта формата: SPDX (Linux Foundation, старше, богаче лицензионными полями) и CycloneDX (OWASP, компактнее, изначально для уязвимостей). Практически все сканеры едят оба; выбирай CycloneDX для контура безопасности, SPDX — если юристы требуют лицензионную отчётность.

Генерировать SBOM из образа умеет Syft — он смотрит в слои и распознаёт пакетные менеджеры:

Окно терминала
# SBOM из образа в CycloneDX (json)
syft scan myapp:1.4.2 -o cyclonedx-json > sbom.json
# SPDX — для лицензионной отчётности
syft scan myapp:1.4.2 -o spdx-json > sbom-spdx.json
# Посмотреть сводку состава
syft scan myapp:1.4.2 | head -30

SBOM — не бумажка для галочки, а рабочий артефакт: Trivy умеет сканировать его напрямую (trivy sbom sbom.json), dependency-track сервер накапливает SBOM всех релизов и шлёт алерты при выходе CVE для любого компонента. Артефакт кладётся рядом с образом в реестр или CI-хранилище и привязывается к дайджесту.

Вся криптография главы опирается на три понятия из OCI-устройства образа, и важно различать их до конца.

Дайджест — SHA-256 по содержимому манифеста образа: sha256:9f3b.... Два образа с одинаковым дайджестом побитово идентичны. Дайджест неизменяем, тег — подвижный указатель на него.

Манифест — JSON со списком слоёв и их дайджестами. Подписывается именно он.

Подпись (Sigstore bundle) — в keyless-режиме это короткоживущий сертификат X.509 от Fulcio (CA Sigstore), утверждающий «GitHub Actions-раннер репозитория X на коммите Y в рамках workflow Z», плюс записи в прозрачных логах Rekor и, по желанию, timestamp-авторитет. При верификации cosign проверяет: подпись манифеста сходится, сертификат валиден и подписан корнем Sigstore, запись есть в Rekor, а идентичность (SAN сертификата) совпадает с ожидаемой (https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main).

Именно это отличает keyless от статического ключа: вместо «кто-то, владеющий ключом» проверяется идентичность пайплайна. Украсть у CI нечего — сертификат живёт минуты.

Окно терминала
# Статический ключ: подпись ключа, живущего в CI-секретах
cosign sign --key env://COSIGN_PRIVATE_KEY myapp@sha256:9f3b...
# Keyless (в CI с OIDC): ключей нет вообще
cosign sign --yes myapp@sha256:9f3b...
# Верификация keyless: проверяем идентичность пайплайна, а не ключ
cosign verify myapp@sha256:9f3b \
--certificate-identity-regexp 'https://github.com/myorg/pet-app/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com

Условие keyless — OIDC-токен: в GitHub Actions он выдаётся job’е с разрешением id-token: write, временный, содержит имя репозитория, workflow, ref и SHA коммита. Cosign обменивает его на сертификат.

SLSA (Supply-chain Levels for Software Artifacts) — фреймворк уровней зрелости сборки: от «SLSA 0 — всё как попало» до «SLSA 3 — выстроенный контур доверия». Практически для нас важно:

  • SLSA 1 — provenance существует (кто, когда, из чего собрал).
  • SLSA 2 — сборка на hosted-раннере (GitHub Actions), provenance подписан CI.
  • SLSA 3 — изолированный, эфемерный, двусторонне аутентифицированный контур (GitHub + OIDC, без self-hosted раннеров с левым софтом).

Полный SLSA 3 вручную — работа на месяцы. Но базовый provenance GitHub Actions генерирует за одну строку: action actions/attest-build-provenance пишет аттестацию о том, что конкретный дайджест образа собран из конкретного коммита конкретным workflow.

# .github/workflows/release.yml — контур: build -> SBOM -> scan -> sign -> attest
name: release
on:
push:
tags: ["v*"]
permissions:
contents: read
id-token: write # нужен для keyless и attestations
packages: write # push в ghcr.io
env:
IMAGE: ghcr.io/${{ github.repository }}
jobs:
build-and-publish:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4
- uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
run: |
TAG=${GITHUB_REF_NAME#v}
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
docker build -t "$IMAGE:$TAG" .
- name: SBOM
uses: anchore/sbom-action@f325429c0006ca79253593501c0785206100150a # v0.17
with:
image: ${{ env.IMAGE }}:${{ steps.meta.outputs.tag }}
format: cyclonedx-json
output-file: sbom.json
- name: Scan (gate: критические блокируют)
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f793ddc92915 # 0.28
with:
image-ref: ${{ env.IMAGE }}:${{ steps.meta.outputs.tag }}
severity: CRITICAL
ignore-unfixed: true
exit-code: "1"
- name: Push и подпись (дайджест!)
id: push
run: |
docker push "$IMAGE:${{ steps.meta.outputs.tag }}"
DIGEST=$(docker inspect "$IMAGE:${{ steps.meta.outputs.tag }}" \
--format='{{index .RepoDigests 0}}' | cut -d@ -f2)
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
cosign sign --yes "$IMAGE@$DIGEST"
- name: Provenance (SLSA)
uses: actions/attest-build-provenance@1c608e817a0a3e61ed3b81d55a3f8f45cbd996a8 # v2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
deploy:
needs: build-and-publish
runs-on: ubuntu-latest
environment: production # protection rules: только после ревью
steps:
- name: Верификация подписи перед деплоем
run: |
cosign verify "$IMAGE@${{ needs.build-and-publish.outputs.digest }}" \
--certificate-identity-regexp 'https://github.com/${{ github.repository }}/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
- name: Деплой по дайджесту
run: |
ssh deploy@prod "docker pull $IMAGE@${{ needs.build-and-publish.outputs.digest }} && \
docker compose up -d --no-deps app"

Три узла контура: сборка подписывает только по дайджесту (тег недоверен), деплой — отдельный job, который обязан пройти верификацию, иначе шаг упадёт и rollout не случится; environment production добавляет ручное одобрение поверх. Так deploy становится функцией от дайджеста, а не от тега.

uses: actions/checkout@v4 — это подвижный тег. Владелец action’а (или взломавший его аккаунт) может перезаписать v4 вредоносным кодом, и твой CI его исполнит. Та же уязвимость, что и image: node:20 в Dockerfile: теги mutable.

Правило: пинь всё по SHA коммита (полный 40-символьный), комментарием с версией для читаемости. Это делает supply-chain атаку на action/образ невозможной без коммита в твой репозиторий — а коммит виден в ревью и в provenance.

# Плохо: тег mutable, завтра это может быть другой образ
FROM node:20 AS build
# Хорошо: дайджест — побитовая фиксация; обновление — осознанный PR
FROM node:20.18.1-bookworm-slim@sha256:9e8fadf8f3c243e2f0f183b41d2c8c08af55282e88b12ea31b3d0d0a1abc42b2 AS build

SHA-обновления автоматизирует Dependabot (github/dependabot.yml с экосистемами github-actions и docker) — присылает PR на каждый новый дайджест, который проходит ревью и CI-гейт. Ты получаешь и иммутабельность, и регулярные обновления.

Защита самого пайплайна — три правила: GITHUB_TOKEN с минимальными permissions (дефолт read), environment protection rules для прода (обязательный ревью), CODEOWNERS на workflow-файлы (изменение пайплайна — отдельное ревью, не «попутно в фиче»). Внешний action от неизвестного автора — потенциальный curl | sh с доступом к твоим секретам: читай его код так же, как читал бы чужой скрипт в проде.

Даже идеальный контур ломается через утёкший секрет. Практики, которые реально работают:

OIDC вместо долгоживущих ключей. Вместо AWS_ACCESS_KEY_ID в секретах на год — aws-actions/configure-aws-credentials с ролью, которой доверяет только конкретный репозиторий, ветка и workflow. Утечка OIDC-токена невозможна вне контекста запущенной job’ы; долгоживущий ключ утекает один раз — и крутится годами.

Секреты — в environment, не на весь репозиторий. Секрет уровня production-environment доступен только job’ам с environment: production, которые, в свою очередь, могут требовать ревью. Секрет на весь репозиторий доступен любому workflow, включая PR из форка (с pull_request_target — вообще любому коду из PR, классическая катастрофа).

Не печатай секреты и не скармливай их printf. run: echo ${{ secrets.MY_TOKEN }} положит значение в лог. То же с set -x. GitHub маскирует точное совпадение, но echo "${TOKEN:0:4}" маскировку обходит.

Ротация как ритуал. Каждый секрет должен иметь владельца и календарную ротацию. Если секрет попал в лог — считай скомпрометированным сразу, не «ну мы его замаскировали».

# Антипаттерн: секрет доступен любому workflow репозитория
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # плохо — на весь репозиторий
# Правильно: environment + OIDC, без долгоживущих ключей
deploy:
environment: production
steps:
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343501 # v4
with:
role-to-assume: arn:aws:iam::123456789012:role/pet-app-deploy
aws-region: eu-central-1
  1. Подпись по тегу. cosign sign app:1.4.2 подтверждает лишь, что кто-то подписал нечто под этим именем; завтра тег перезапушат, и подпись будет «валидна» для другого содержимого. Правильно: резолвишь тег в дайджест (docker inspect --format '{{index .RepoDigests 0}}') и подписываешь image@sha256:....
  2. Безусловный cosign verify без проверки идентичности. cosign verify image@digest в keyless-режиме проверяет любую валидную Sigstore-подпись. Атакующий, подписавший свой образ своим пайплайном, пройдёт. Обязательны --certificate-identity-regexp и --certificate-oidc-issuer — верификация это проверка чьей подписи.
  3. SBOM генерируется не из того артефакта. SBOM из npm ls или из исходников до сборки отражает lockfile, а не реальное содержимое слоёв (мультистейдж может отбросить половину зависимостей, alpine-дистрибутив добавить свои). Правильно: syft scan финального образа по дайджесту, после push.
  4. Self-hosted раннеры без изоляции. Постоянный раннер, где деплой-скрипты оставляют мусор, а job’ы PR и main идут под одним пользователем, — это SLSA 0 независимо от подписей. Правильно: эфемерные раннеры (VM на job), изоляция деплой-job’ов, никаких pull_request_target с секретами.
  5. Гейт сканера с --severity HIGH без --ignore-unfixed. На первой же сборке пайплайн красный от пятисот unfixed-CVE в debian, команда добавляет continue-on-error: true, и гейт мёртв. Правильно: начать с CRITICAL + --ignore-unfixed, завести базовые образы (см. предыдущую главу) и постепенно поднимать планку.
  6. Секреты в env для всех job. env: TOKEN: ${{ secrets.X }} на уровне workflow отдаёт токен каждому шагу, включая внешние action’ы. Правильно: минимальные permissions, секреты в environment, OIDC-роли вместо статических ключей.
  7. «Подпись есть — значит безопасно». Подпись доказывает происхождение и неизменность, но не качество: подписанный образ с критической уязвимостью — валидно подписанный опасный образ. SBOM + скан + подпись + provenance — это один контур, элементы не заменяют друг друга.

Что такое SBOM и зачем он, если есть сканер образов? Машиночитаемый манифест состава артефакта (пакеты, версии, лицензии) в форматах SPDX или CycloneDX. Сканер отвечает «есть ли известные CVE сейчас», SBOM позволяет ответить на вопрос задним числом — «были ли мы уязвимы в марте, когда работал релиз X» — и обеспечивает алертинг при выходе новых CVE без пересборки. Сканы эфемерны, SBOM — актив инвентаризации.

Объясни keyless-подпись Cosign: где ключи? Ключей нет. CI-раннер с id-token: write получает OIDC-токен (репозиторий, workflow, ref, commit), Cosign обменивает его в Fulcio на короткоживущий X.509-сертификат, подпись и сертификат публикуются в Rekor. Верификация — это проверка цепочки до корня Sigstore плюс совпадение идентичности (issuer + SAN). Утечка невозможна: сертификат живёт минуты и не вынимается из пайплайна.

Почему верификацию перед деплоем делают отдельным job’ом? Разделение полномочий: если job, который подписывает, тот же, что деплоит, скомпрометированный раннер подпишет подменённый образ и «проверит» его сам. Отдельный job получает только дайджест и не может влиять на содержимое; идеал — отдельный раннер с другой identity.

Что такое SLSA и какие уровни реалистично достижимы в GitHub Actions? Фреймворк зрелости сборки: SLSA 1 — provenance есть, SLSA 2 — hosted-раннер и подписанный provenance, SLSA 3 — изолированный эфемерный контур без self-hosted раннеров. В GitHub Actions SLSA 2 достигается практически из коробки (attest-build-provenance + OIDC), SLSA 3 — с эфемерными раннерами и жёстким разделением job’ов.

Как правильно пиннить GitHub Action’ы и почему не хватает версионного тега? Полным SHA коммита с комментарием-версией: actions/checkout@08c6903cd... # v4. Тег mutable — владелец action’а может перезаписать v4, и вредоносный код попадёт в твой CI без единого изменения в твоём репозитории. SHA фиксирует код; обновления присылает Dependabot ревьюируемыми PR.

Твой OIDC-токен GitHub Actions утёк в логах. Что делать? Ничего страшного, если настроено правильно: токен короткоживущий (минуты), привязан к конкретному запуску job, и доверие на стороне облака ограничено репозиторием, веткой и workflow. Действия: пересмотреть, почему он вообще попал в лог (устранить печать/маскировку), убедиться, что cloud-роль требует точных условий доверия. С долгоживущим ключом ситуация противоположная — ротация и аудит доступов.

Сканер пропустил образ, он уехал в прод, через неделю вышел критический CVE. Виноват сканер? Нет — сканер ищет известные CVE на момент скана, zero-day’и не ловит никто. Виноват процесс, если нет: SBOM для быстрых запросов по всем релизам, dependency-track для алертинга, регулярного пересканирования уже выкаченных дайджестов (CVE-база растёт, образ — нет) и плана реагирования. Ссылка на /10-data/postgres-backups/ уместна тут же: откат на предыдущий подписанный дайджест — часть плана.

Мини-проект: доверенный пайплайн поставки для pet-проекта. Собери в своём репозитории контур build → SBOM → scan → sign → verify → deploy, где деплой физически невозможен без валидной подписи.

  1. Контур сборки. Перепиши release-workflow: checkout по SHA, base-образ по дайджесту, push в GHCR, резолв тега в дайджест. Критерий: на выходе job’а — переменная digest, передаваемая дальше, и ни одного упоминания тега в деплой-шагах.
  2. SBOM. Подключи anchore/sbom-action (или syft вручную) с cyclonedx-json, артефакт прикрепи к релизу. Критерий: trivy sbom sbom.json находит те же пакеты, что и trivy image по дайджесту.
  3. Гейт сканирования. Trivy с CRITICAL, --ignore-unfixed, exit-code: 1 между сборкой и push. Проверь: собери образ со старым openssl — CI красный; обнови базу — зелёный. Критерий: вредоносный образ не доходит до registry.
  4. Keyless-подпись и provenance. cosign sign --yes по дайджесту с id-token: write, плюс attest-build-provenance. Критерий: cosign verify локально проходит с правильными --certificate-identity-regexp и issuer; с подменённым образом падает.
  5. Отказ при невалидной подписи. В отдельном job’е верификация + условный деплой. Испытание: в отдельной ветке убери/сломай проверку и попробуй задеплоить подписанный другим репозиторием образ — job должен упасть на верификации и не выполнить ни одной деплой-команды. Критерий: в логах видно, что rollout не состоялся.
  6. Аудит пайплайна. Прогони все action’и и базовые образы через пиннинг по SHA + Dependabot. Критерий: grep -r '@v[0-9]' .github/workflows/ пустой, dependabot.yml покрывает github-actions и docker.