Секреты и supply chain: как держать секреты вне слоёв и подписывать образы, чтобы их нельзя было подменить
Секрет в ENV или ARG утекает через docker inspect и запекается в слой; build secret mounts держат его вне образа. Неподписанный образ можно подменить в реестре; cosign подписывает digest, пишет в Rekor, а admission-гейт отвергает неподписанное — цепочка SLSA от сборки до допуска.
Два провала, одна supply chain. Первый был тихим: Dockerfile передал токен npm-реестра как build-ARG, чтобы установить приватный пакет, и год спустя security-исследователь стянул публичный образ, прогнал docker history и docker inspect и прочитал токен прямо из метаданных образа — он был запечён в слой и в inspect-вывод, неизменяемый и поставленный любому, кто может стянуть. Второй был громче: атакующий, скомпрометировавший CI-сервис-аккаунт, запушил подделанный образ в тот же репозиторий под тегом latest, подменив легитимный бинарь на бэкдоренный. Кластер стянул latest на следующем деплое и запустил его без задней мысли, потому что ничто на пути от реестра до работающего контейнера ни разу не спросило: «это тот образ, что мы реально собрали?». Обе дыры закрываются одинаково — секреты, что никогда не входят в слой, и образы, что несут криптографическую подпись, которую admission-гейт проверяет до запуска.
Секрет в слое — это опубликованный секрет
Когда ты стягиваешь любой публичный образ, ты можешь прочитать его полную историю и метаданные — именно это делает атакующий. Если твоя команда хоть раз передавала кредиал через сборку, это меняет взгляд на каждый образ, что вы когда-либо поставляли.
Образ — это append-only стек слоёв, и его метаданные читаемы всем, кто может стянуть. Это делает каждый ходовой способ передать секрет в сборку утечкой. Строка ENV API_KEY=... видна в docker inspect и docker history и наследуется каждым процессом в контейнере — и docker inspect-ом от любого с доступом к реестру. Build-ARG SECRET не лучше: хоть он и не в финальном окружении, build-args записываются в историю образа, и любой файл, в который сборка пишет секрет, становится слоем, что персистует даже если позднее слой удалит его, потому что слои аддитивны — rm-нуть файл в слое 5 не убирает его из слоя 3, лишь прячет, и docker save или инструмент извлечения слоёв читает его обратно. Скопировать файл кредов, использовать его и удалить — каноническая ошибка: секрет в образе навсегда, в одном tar-извлечении.
Фикс — дать сборке секрет не записывая его ни в какой слой. BuildKit secret mounts делают ровно это: RUN --mount=type=secret,id=npmtoken открывает секрет как файл под /run/secrets на время лишь этого одного RUN-шага, смонтированный в tmpfs, что никогда не коммитится в слой и никогда не появляется в истории или inspect. Секрет присутствует, пока команда работает, и исчезает в момент её завершения, не оставляя следа в образе. Тот же принцип правит рантаймом: вообще никогда не запекай креды в образ — внедряй их в рантайме из secret-store оркестратора (Docker/Kubernetes secrets, vault-сайдкар или примонтированные файлы), чтобы образ был идентичен во всех средах и не нёс ничего чувствительного. Правило прямое: если секрет хоть раз был в слое, ENV или build-ARG, считай его опубликованным и ротируй.
Dockerfile делает COPY creds.json ., использует его при npm install, затем RUN rm creds.json следующим шагом. Безопасен ли секрет в опубликованном образе?
▸Почему это работает
Почему build secret mount решает то, что ENV и ARG не могут? Потому что он меняет, где секрет живёт во время сборки. ENV и ARG делают секрет частью декларативного определения образа — записанной, наследуемой, инспектируемой — и любой файл, написанный из них, коммитится в аддитивный слой. Secret mount делает секрет транзитным tmpfs-файлом, ограниченным одним RUN, видимым процессам той команды и отмонтированным до коммита слоя, так что он никогда не часть содержимого слоя или метаданных образа. Образ, что ты публикуешь, побайтово идентичен независимо от того, использовался ли секрет для сборки. Это и есть нужное свойство: артефакт не несёт ничего чувствительного, так что утечка артефакта не утекает ничего.
Подпись digest: остановка подмены
Держание секретов вне образа защищает то, что в образе; подпись защищает, какой образ работает. Тег вроде latest — изменяемый указатель: любой, кто может пушить в репозиторий, может перенаправить его на другой digest, и подделанный образ, подменённый под тем же тегом, тянется и запускается ровно как оригинал. Криптографический якорь — digest образа (SHA-256 по манифесту): он content-addressed и неизменяем, так что у другого образа другой digest. Cosign (из проекта sigstore) подписывает этот digest и хранит подпись как артефакт рядом с образом в реестре. Критично, он может делать это безключево: получает короткоживущий сертификат, привязанный к OIDC-личности (workload identity твоего CI), и записывает событие подписи в Rekor, публичный append-only transparency-лог — так что есть tamper-evident, запрашиваемая запись, что «этот digest был подписан этой личностью в это время», без долгоживущего приватного ключа, что можно украсть.
Подпись важна, лишь если что-то её проверяет до запуска образа, и это работа admission-контроллера (Kubernetes admission webhook, OPA/Gatekeeper или cosign policy controller): на деплое он резолвит образ в его digest, проверяет валидную cosign-подпись от разрешённой личности и отказывается допускать что-либо неподписанное или подписанное не тем ключом. Этот единственный гейт побеждает tag-swap: у бэкдоренного образа нет валидной подписи от доверенной CI-личности, так что он отвергается до старта пода. Это хребет SLSA (Supply-chain Levels for Software Artifacts — уровни безопасности цепочки поставок): проверяемая цепочка происхождения от доверенной сборки, через записанную подпись и запись в transparency-логе, до admission-гейта, что запускает лишь артефакты, чьё происхождение может доказать. Пинься к digest’ам, подписывай на сборке, проверяй на допуске — и реестр перестаёт быть местом, где атакующий может тихо подменить твой бинарь.
Команда подписывает каждый образ через cosign и записывает каждую подпись в Rekor, но деплои всё ещё тянут по изменяемому тегу без admission-проверки. Атакующий пушит бэкдоренный образ в тот же тег. Что происходит?
- 01Почему COPY-затем-rm или build-ARG утекает секрет, и что BuildKit secret mount делает иначе?
- 02Проведи через то, как cosign, Rekor и admission-контроллер останавливают tag-swap-атаку, и почему каждая часть нужна.
Образ контейнера — append-only стек неизменяемых слоёв, чьи метаданные читаемы любым, кто может стянуть, так что каждый небрежный способ передать секрет в сборку — утечка. ENV наследуется и инспектируется; build-ARG записывается в историю; и любой файл, в который сборка пишет секрет, становится слоем, что переживает даже позднее rm, потому что слои аддитивны — удаление это whiteout, что прячет файл в слитом виде, пока docker save восстанавливает его целиком, что делает COPY-затем-rm учебниковой ошибкой. Фикс — держать секрет вне каждого слоя: BuildKit secret mount открывает его как транзитный tmpfs-файл, ограниченный одним RUN, никогда не закоммиченный и никогда в истории, оставляя опубликованный образ побайтово идентичным независимо от того, использовался ли секрет; в рантайме внедряй креды из secret-store оркестратора, а не запекай их. Если секрет хоть раз коснулся слоя, ENV или ARG, считай его опубликованным и ротируй. Держание секретов вне защищает то, что в образе; подпись защищает, какой образ работает. Изменяемый тег вроде latest позволяет любому с push-доступом перенаправить его на подделанный digest, так что якорь — неизменяемый content-addressed digest: cosign подписывает этот digest безключево через короткоживущий OIDC-привязанный сертификат и записывает событие в transparency-лог Rekor, а admission-контроллер проверяет подпись против разрешённой личности до допуска образа, отвергая всё неподписанное или подписанное не так — побеждая tag-swap до старта пода. Вместе — пинься к digest’ам, подписывай на сборке и проверяй на допуске — это цепочка SLSA: проверяемый путь от доверенной сборки через записанную подпись до гейта, что запускает лишь артефакты, чьё происхождение может доказать. Теперь, когда встретишь Dockerfile с токеном в ARG или Kubernetes-деплой, тянущий по изменяемому тегу без admission-проверки, ты знаешь точно, что чинить и почему каждый шаг цепочки обязателен.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.