Воспроизводимые сборки: SOURCE_DATE_EPOCH, базы запиненные по дайджесту и почему пляшущий дайджест слоя ломает подпись
Воспроизводимая сборка даёт байт-идентичные дайджесты слоёв из тех же исходников на любой машине. Встроенные таймстампы, незапиненные теги баз и mtime чекаута её ломают — пляшут дайджесты и подрывается подпись. Рычаги: SOURCE_DATE_EPOCH и базы по дайджесту.
Аудит цепочки поставок задал простой вопрос, а команда не смогла ответить: «Пересоберите релиз v2.4.1 из тегированного коммита и покажите тот же дайджест образа, который вы подписали». Они выкатили точный тег, запустили точную команду сборки — и получили совершенно другой дайджест образа. Каждый хеш слоя отличался. Подписанный образ в реестре и пересобранный не делили ни единого слоя, так что не было способа доказать, что развёрнутый артефакт соответствует проаудированным исходникам. Причина была обыденной: Dockerfile использовал FROM node:22 (незапиненный тег, сдвинувшийся с релиза), COPY . . записал mtime файлов чекаута в tarball слоя, а сборка штамповала BUILD_TIME=$(date) в env-переменную. Три источника недетерминизма, каждого достаточно, чтобы сменить дайджест. Образ был совершенно функционален оба раза — тот же код, то же поведение, — но не воспроизводим, а воспроизводимость — это свойство, которое подпись должна была гарантировать. Подпись над дайджестом, который не воспроизвести, не доказывает ничего об исходниках.
Что значит «воспроизводимая» и почему дайджесты пляшут
Если команда подписывает релизные образы, задай себе вопрос: можешь ли ты прямо сейчас пересобрать тот же дайджест из тегированного коммита? Если нет — подпись доказывает меньше, чем кажется. Воспроизводимая значит: те же исходники, собранные где угодно и в любой день, дают образ с теми же дайджестами слоёв до байта. Поскольку всё в OCI контентно-адресуемо, единственный отличающийся байт где угодно в tarball-е слоя меняет sha256 этого слоя и каждый дайджест выше. Три обычных подозреваемых впрыскивают эти отличающиеся байты:
- Встроенные таймстампы.
BUILD_TIME=$(date), build-info-файл с текущим временем или компилятор, штампующий дату сборки — любое пишет значение, меняющееся каждую секунду, так что слой никогда не воспроизводится. - Mtime файлов в архиве. Когда
COPY . .пишет файлы в слой, tarball слоя записывает время модификации каждого файла. Свежийgit checkoutставит mtime в сейчас, так что те же исходники, скопированные в два дня, дают два разных tarball-а с идентичным содержимым, но разными таймстампами — разный дайджест. - Незапиненные базовые образы.
FROM node:22резолвится в тот дайджест, на который тег указывает сегодня; тег двигается на каждом минорном патче, так что пересборка спустя месяцы тянет другую базу и наследует её другие слои.
Лекарство от каждого прямое. Пиньте базу по дайджесту: FROM node:22@sha256:abc..., чтобы база не двигалась. Управляйте таймстампами через SOURCE_DATE_EPOCH — стандартизированную env-переменную с фиксированным Unix-временем (обычно дата автора коммита), которую инструменты сборки чтут вместо настенных часов. И заставьте BuildKit нормализовать mtime, которые он пишет.
# syntax=docker/dockerfile:1
FROM node:22@sha256:1a2b3c... # запинено: база молча не сдвинется
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build# Подаём фиксированное время и просим BuildKit переписать таймстампы слоёв на него:
SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) \
docker buildx build \
--output type=image,name=app:v2.4.1,rewrite-timestamp=true .rewrite-timestamp=true велит BuildKit выставить mtime каждого файла в производимых слоях в SOURCE_DATE_EPOCH, стирая разброс времени чекаута. С запиненной базой, запиненными часами и нормализованными mtime две сборки того же коммита на двух машинах дают те же дайджесты.
Две сборки одного и того же коммита на двух машинах дают образы с разными дайджестами слоёв. Код и поведение идентичны. Что из этого НЕ является вероятной причиной?
▸Почему это работает
Почему подпись заботится именно о воспроизводимости? Подпись (cosign, Notary) вычисляется над дайджестом образа. Обещание, которого хочет потребитель: «этот работающий образ соответствует тем проаудированным исходникам». Если вы не можете пересобрать проаудированные исходники в тот же дайджест, подпись вырождается в «кто-то с ключом подписал какие-то байты» — она больше не привязывает артефакт к инспектируемым исходникам. Воспроизводимость — это то, что делает подпись проверяемой: аудитор (или SLSA-проверка provenance) пересобирает из исходников, получает идентичный дайджест и подтверждает, что подписанный артефакт — ровно эти исходники без впрыснутого. Невоспроизводимые сборки также тихо бьют межстадийный и реестровый кеш — пляшущий дайджест значит, что COPY —from и запушенные слои промахиваются каждый раз, — так что воспроизводимость окупается и в безопасности, и в скорости сборки.
Детерминизм за пределами трёх больших рычагов
Пиньте базу, часы и mtime — и это покрывает большинство случаев, но несколько более тонких недетерминизмов стоят знания. Установки пакетов без пина версий (apt-get install curl без =version, pip install без lockfile) тянут что текуще, так что фиксируйте версии или используйте lockfile. Шаги сборки, выдающие вывод в порядке итерации ФС, могут варьироваться; инструменты всё чаще сортируют детерминированно, но кастомный скрипт может потребовать явной сортировки. Встроенные пути сборки (компилятор, записывающий /home/runner/...) различаются между машинами; многие тулчейны принимают remap префикса пути. А сетевой доступ во время сборки по природе невоспроизводим — тот же curl https://... может вернуть другие байты завтра, — поэтому герметичные сборки тянут лишь запиненные, контрольно-суммированные входы. Senior-позиция: трактуйте каждый вход сборки как нечто, что должно быть запинено или нормализовано, и считайте, что любой незапиненный вход в итоге сдвинется и сломает ваш дайджест, когда вы меньше всего хотите.
Ваша команда безопасности подписывает релизные образы через cosign. Почему невоспроизводимая сборка подрывает ценность этой подписи, даже когда ключ подписи безопасен?
- 01Назови три классических источника недетерминизма сборки и рычаг, чинящий каждый.
- 02Объясни, почему воспроизводимость — это то, что делает подпись образа осмысленной, и что ещё ломает пляшущий дайджест.
Воспроизводимая значит: те же исходники, собранные на любой машине в любой день, дают образ с байт-идентичными дайджестами слоёв. OCI контентно-адресуемо, так что один отличающийся байт в tarball-е слоя меняет sha256 этого слоя и каждый дайджест, сложенный выше, и три обычных подозреваемых впрыскивают этот байт. Встроенные таймстампы — BUILD_TIME=$(date), build-info-файл, компилятор, штампующий дату — меняются каждую секунду. Mtime файлов, записанные в слой через COPY . ., различаются, потому что свежий git checkout ставит их в сейчас, так что идентичное содержимое воспроизводится как разные tarball-ы. А незапиненные теги баз вроде FROM node:22 резолвятся в то, на что тег указывает сегодня, и двигаются под вами между сборками. Рычаги прямые: пиньте базу по дайджесту (FROM node:22@sha256:…), управляйте каждым таймстампом сборки через SOURCE_DATE_EPOCH (SOURCE_DATE_EPOCH — стандартизированная env-переменная с фиксированным Unix-временем, которую инструменты сборки чтут вместо настенных часов), выставленный в дату автора коммита, и запускайте BuildKit с rewrite-timestamp=true, чтобы он нормализовал mtime каждого файла в производимых слоях в этот epoch; за пределами большой тройки пиньте версии пакетов или используйте lockfile, сортируйте вывод в порядке итерации, ремапьте встроенные пути сборки и избегайте сетевых шагов сборки, тянущих незапиненные байты. Это важнее всего для цепочки поставок: подпись (cosign) вычисляется над дайджестом образа, так что если вы не можете пересобрать проаудированный коммит в тот же дайджест, подпись больше не доказывает, что развёрнутый артефакт соответствует инспектируемым исходникам — ровно тот аудит, что команда провалила, когда v2.4.1 пересобрался в другой хеш. Воспроизводимость — это то, что делает подпись проверяемой, позволяет SLSA-проверкам provenance подтвердить артефакт пересборкой и бонусом держит межстадийный COPY —from и реестровые кеши слоёв в попаданиях вместо пляски свежего слоя на каждую функционально идентичную сборку. Теперь, когда выстраиваешь релизный пайплайн, сделай «можем ли мы воспроизвести этот дайджест?» обязательным гейтом: запини базу, передай SOURCE_DATE_EPOCH, добавь rewrite-timestamp=true — и верифицируй пересборкой тегированного коммита, прежде чем доверять подписи.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.