{ "index": 165, "slug": "editorial-2023-06-practice-secrets-supply-chain", "title": "Секрет в CI и digest образа: как проверить цепочку поставки", "excerpt": "Разбираем выпуск, в котором pipeline зелёный, но непонятно, какой commit и образ попали в deploy. Показываем границу секрета, проверку provenance и отрицательный путь.", "contentHtml": "
Симптом выглядит так: зелёный pipeline не отвечает на главный вопрос расследования: что именно сейчас запущено. В записи о релизе может быть только тег build-123, хотя тег допускает переназначение. При этом команде нужно связать четыре факта: commit исходников, identity сборщика, digest образа и вход deploy. Если на шаге сборки использовали токен, добавляется пятый вопрос: где его значение могло сохраниться.
Разберём типовой выпуск как цепочку evidence — проверяемых свидетельств, а не как список названий инструментов. Секрет должен быть доступен только нужной команде и не попасть в результат. Attestation должна относиться к тому же digest, который запускает deploy. В конце получится короткий контрольный маршрут, который можно повторить на CI без доступа к значениям секретов.
\nУ выпуска должен быть один неизменяемый ключ — digest образа, а рядом с ним хранятся происхождение и решение о выкладке. Ветка и тег удобны для поиска, но не заменяют commit и digest: ветка движется, тег можно переиспользовать. Commit отвечает на вопрос «какие исходники взяли», digest — «какой результат собрали», а provenance — «какой builder заявил, как этот результат получил».
\n{\n "revision": "abc123...",\n "builderRun": "https://ci.example.invalid/runs/8472",\n "imageDigest": "registry.example.invalid/payments/api@sha256:7f...",\n "attestationSubject": "registry.example.invalid/payments/api@sha256:7f...",\n "deployInput": "registry.example.invalid/payments/api@sha256:7f...",\n "secretUse": "mounted for npm ci; value is not evidence"\n}\nИдентификаторы в примере условные. В настоящем CI запись должна ссылаться на конкретный run, registry и commit, а не на текстовое поле, которое можно исправить вручную. Поле secretUse фиксирует способ доступа, но само по себе не доказывает отсутствие значения в логах, кэше или артефактах. Для этого нужны отдельные проверки.
Закрытая зависимость иногда требует credential во время npm ci, pip install или скачивания приватного репозитория. BuildKit secret mount делает значение доступным конкретной инструкции и не записывает его в финальный слой автоматически. Это отличается от ARG TOKEN и ENV TOKEN: Docker предупреждает, что build arguments и environment variables не подходят для передачи секретов, а аргументы могут оказаться в history или provenance.
# syntax=docker/dockerfile:1\nFROM node:22-alpine AS build\nWORKDIR /app\nCOPY package*.json ./\n\nRUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true \\\n npm ci --ignore-scripts\n\nCOPY . .\nRUN npm run build\n\nFROM nginx:alpine\nCOPY --from=build /app/dist /usr/share/nginx/html\nВызов сборки передаёт путь к файлу секрета, а не само значение в аргументе командной строки:
\ndocker buildx build \\\n --secret type=file,id=npmrc,src=/runner/secrets/npmrc \\\n --tag registry.example.invalid/payments/web:abc123 \\\n --push .\nПуть /runner/secrets/npmrc — контракт с вашим secret manager или runner, а не команда создания настоящего credential. В CI запрещаем печать файла, добавление его в build context и копирование в /app. Даже корректный mount не спасает команду, которая делает cat /run/secrets/npmrc, пишет ответ приватного сервера в артефакт или оставляет credential в debug-логе.
Digest — криптографический идентификатор содержимого образа. Тег можно переназначить, поэтому его оставляют человекочитаемым алиасом, а для передачи между registry, attestation и deploy используют ссылку вида image@sha256:.... Сразу после push сохраните digest из registry и передайте именно его следующему этапу.
# Посмотреть digest опубликованного тега\ndocker buildx imagetools inspect registry.example.invalid/payments/web:abc123\n\n# Проверить, что registry отдаёт именно зафиксированный объект\ndocker pull registry.example.invalid/payments/web@sha256:7f00000000000000000000000000000000000000000000000000000000000000\nКоманды требуют доступного registry и подставленного реального digest; значение sha256:7f... в статье — не существующий артефакт. Для multi-platform образа нужно заранее решить, что именно является входом deploy: digest manifest list или digest конкретного варианта для linux/amd64 либо linux/arm64. Сравнивать их как одну строку без этого решения нельзя.
Provenance описывает, где, когда и каким процессом получен артефакт. Это полезное заявление, но не автоматический сертификат безопасности. Проверяющий сначала удостоверяется в подписи по настроенному root of trust, затем сопоставляет subject с digest, проверяет ожидаемый predicateType и identity builder. После криптографической проверки остаётся ещё политический вопрос: разрешены ли этот репозиторий, workflow, commit и окружение.
Для SLSA-подобной проверки порядок важен. Если subject относится к sha256:91..., а deploy запускает sha256:7f..., валидная подпись не исправляет расхождение. Если builder неизвестен политике, запись о provenance нельзя считать достаточным основанием для выпуска. Если проверка относится к тегу, зафиксируйте разрешённый digest рядом с результатом, иначе между проверкой и deploy возможна подмена тега.
# Пример для GitHub Container Registry и GitHub CLI.\n# ORG, REPO и IMAGE заменяются значениями проекта.\ngh attestation verify \\\n oci://ghcr.io/ORG/IMAGE:release-123 \\\n -R ORG/REPO\nКоманда проверяет доступную GitHub attestation для указанного образа, но не знает вашу политику автоматически. После неё отдельно сверяем repository, workflow, commit, builder identity и digest с deploy manifest. В другой CI-платформе остаётся тот же порядок, меняются формат attestation и инструмент проверки.
\n| Симптом | Гипотеза | Проверка | Решение |
|---|---|---|---|
| Deploy содержит только тег | Тег переназначили после сборки | Получить фактический digest из runtime и сравнить с registry | Перевести manifest на digest и сохранить его в release evidence |
| Attestation есть, subject другой | Проверяли один output, запускают другой | Сравнить полные строки subject и deploy input | Остановить выпуск, выбрать проверенный digest и найти место расхождения |
В Dockerfile есть ARG TOKEN | Credential попал в history или metadata | Проверить docker history, metadata и историю CI | Отозвать токен, заменить передачу на secret mount, собрать новый образ |
| В логе виден фрагмент токена | Команда или debug напечатали секрет | Проверить весь run, артефакты, кэш и системы логирования | Немедленно отозвать credential и повторить выпуск с новым digest |
| Builder не входит в root of trust | Provenance подписана неизвестным исполнителем | Сверить builder identity и ключ с политикой проекта | Не принимать выпуск; сначала зарегистрировать доверенный путь или изменить builder |
Проверка должна завершаться сравнением значений, а не только просмотром зелёного статуса. Следующий фрагмент не публикует секрет и не меняет кластер: он моделирует последний decision gate перед deploy.
\nset -eu\nATTESTED_DIGEST="registry.example.invalid/payments/api@sha256:7f..."\nDEPLOY_DIGEST="registry.example.invalid/payments/api@sha256:7f..."\n\ntest "$ATTESTED_DIGEST" = "$DEPLOY_DIGEST"\nprintf 'attestation and deploy refer to the same digest\\n'\n\n# Дальше запускается только заранее разрешённый deploy job.\n# В manifest сохраняем полный image@sha256:... без mutable tag.\nВ реальном job переменные должны приходить из проверенных outputs, а не из ручного ввода. Добавьте отрицательный тест: намеренно подставьте другой digest и убедитесь, что test возвращает ненулевой код, job останавливается, а deploy не вызывается. Отдельно проверяйте commit и builder, потому что совпадение двух строк digest не доказывает происхождение образа.
predicateType, subject digest и заявленные входы provenance.Представим, что тесты прошли, образ опубликован, но attestation относится к sha256:91..., а deploy manifest содержит sha256:7f.... Сохраняем логи и metadata, блокируем promotion и выясняем, где возник разрыв: push создал другой output, тег разрешился иначе, attestation выпустили для соседнего артефакта или manifest собрали из старого значения.
Если credential попал в лог, слой или артефакт, удаление строки не возвращает его безопасность. Отзовите и замените credential по правилам вашей платформы, ограничьте доступ к копиям, проверьте retention и кэши, затем выпустите новый образ с новым digest. Результат расследования должен говорить, какие поверхности проверены; фраза «секрет не утёк» без охвата CI, registry и артефактов слишком сильна.
\nПримеры используют Docker BuildKit, registry, GitHub CLI и SLSA-термины. В Jenkins, GitLab, Yandex CI или закрытом registry будут другими команды, форматы attestation и политика доверия. Перед внедрением сверяйте версию Dockerfile frontend, возможности runner, режим кэширования, права registry и способ, которым runtime разрешает multi-platform image.
\nSecret mount уменьшает вероятность записи значения в финальный слой, но не делает процесс невосприимчивым к вредоносной команде, debug-выводу или компрометации builder. Digest защищает от подмены содержимого по этому адресу, но не доказывает, что исходный код безопасен. Attestation связывает заявление с артефактом и builder; SLSA отдельно оговаривает доверие к самой build-платформе. Поэтому модель не заменяет threat model, ротацию credential, контроль прав и независимую проверку runner.
\nВыпуск можно принять, когда без устных пояснений доступны commit, ссылка на CI run и builder identity; секрет получен через разрешённую границу и не найден в логах, слоях или артефактах; подпись и provenance проверены; subject совпадает с digest; тот же digest записан в deploy input. Для несовпадения есть автоматический fail-closed тест и понятный владелец расследования. Если поле недоступно или проверка охватывает только одну поверхность, статус выпуска остаётся неподтверждённым.
\n--secret, типы file/env и RUN --mount=type=secret.