11.6. Supply chain
Цели
После этого материала вы сможете:
- отличить уязвимость, требующую действий, от той, что попадает в отчёт, но не эксплуатируется;
- получить SBOM образа и объяснить, зачем он нужен на практике;
- закрепить базовый образ по digest и объяснить, что это даёт;
- показать, почему тег не является идентификатором образа;
- организовать откат, зная точно, какой образ был развёрнут;
- приложить к образу provenance и проверить его.
Предварительные знания
- 3.1. Архитектура образа — слои и digest;
- 5.9. Воспроизводимость сборки;
- 11.1. Принципы production.
Ключевые термины
| Термин | Объяснение |
|---|---|
SBOM | Software Bill of Materials — перечень компонентов образа |
provenance | Свидетельство о происхождении: чем, когда и из чего собран |
attestation | Подписанное утверждение, приложенное к образу |
digest | Неизменяемый идентификатор содержимого: sha256:... |
CVE | Идентификатор публично известной уязвимости |
VEX | Утверждение о применимости уязвимости к конкретному продукту |
Теория
Тег не идентифицирует образ
python:3.13-slim ──► sha256:a1b2... (сегодня)
python:3.13-slim ──► sha256:9f8e... (через неделю, после обновления)
Теги изменяемы. Один и тот же тег в разное время указывает на разные образы. Это касается не только latest: 3.13-slim обновляется при каждом исправлении в базовом Debian.
| Идентификатор | Изменяемый | Что гарантирует |
|---|---|---|
latest | Да | Ничего |
3.13 | Да | Мажорную и минорную версию |
3.13-slim | Да | То же плюс вариант |
3.13.9-slim | Да | Точную версию Python, но не базовую ОС |
@sha256:... | Нет | Побайтовое содержимое |
Практические следствия:
| Проблема | Причина |
|---|---|
| «Вчера собиралось, сегодня нет» | Базовый образ обновился |
| Откат вернул не то | Тег уже указывает на другое |
| Сборка невоспроизводима | Тот же Dockerfile даёт разные образы |
| Сканер нашёл уязвимость, которой вчера не было | Обновился базовый образ |
Закрепление по digest
FROM python:3.13-slim@sha256:a3ff2e57e2a1c0d3b4c5d6e7f8091a2b3c4d5e6f7089a1b2c3d4e5f60718293a
| Что даёт | Что отнимает |
|---|---|
| Воспроизводимость сборки | Автоматические исправления безопасности |
| Точный откат | Требует процесса обновления |
| Понятный аудит | Ручная работа |
Закрепление без процесса обновления вреднее его отсутствия: образ застынет с известными уязвимостями.
Рабочая схема: digest в Dockerfile плюс автоматическое обновление (Renovate, Dependabot) с прогоном тестов.
Метка для аудита:
LABEL org.opencontainers.image.base.name="python:3.13-slim@sha256:a3ff..."
Сканирование: что требует действий
Сканер выдаёт список CVE. Действий требует меньшинство.
| Фактор | Вопрос |
|---|---|
| Severity | Critical и High — в первую очередь |
| Наличие исправления | Есть ли обновлённая версия пакета |
| Достижимость кода | Использует ли приложение уязвимую функцию |
| Вектор атаки | Нужен ли сетевой доступ или локальный |
| Наличие эксплойта | Известен ли рабочий способ эксплуатации |
Пример: уязвимость в libxml2 уровня Critical в образе Python-приложения, которое XML не обрабатывает. Формально Critical, практически недостижима.
Это не повод игнорировать — но повод расставить приоритеты и зафиксировать решение, а не молча пропустить.
| Категория | Действие |
|---|---|
| Critical/High с исправлением | Обновить базовый образ или пакет |
| Critical/High без исправления | Оценить достижимость, зафиксировать решение |
| Medium/Low | В плановое обновление |
| Недостижимая уязвимость | Задокументировать через VEX |
Инструменты
| Инструмент | Что делает | Как запустить |
|---|---|---|
docker scout | Сканирование, сравнение, рекомендации | Плагин Docker CLI |
| Trivy | Сканирование, SBOM | docker run aquasec/trivy |
| Grype | Сканирование | docker run anchore/grype |
| Syft | Только SBOM | docker run anchore/syft |
Все они читают одни и те же базы данных уязвимостей, но различаются полнотой определения пакетов и форматом вывода. Результаты не совпадают полностью — это нормально.
SBOM
Перечень всего, что попало в образ: системные пакеты, библиотеки Python, версии.
| Зачем | Пример |
|---|---|
| Ответить на вопрос «есть ли у нас X?» | Опубликована уязвимость в libwebp — где она у нас? |
| Аудит лицензий | Не попал ли GPL-компонент в проприетарный продукт |
| Требование регулятора | Всё чаще обязателен |
| Сравнение образов | Что изменилось между версиями |
Первый пункт — главный практический. Без SBOM ответ на вопрос «в каких из наших ста образов есть уязвимая библиотека» требует пересканирования всех.
Форматы:
| Формат | Кто продвигает |
|---|---|
| SPDX | Linux Foundation |
| CycloneDX | OWASP |
Оба поддерживаются инструментами; выбор обычно диктуется тем, что понимает ваша система учёта.
Provenance и attestations
Provenance отвечает на вопрос как был собран образ: каким инструментом, из какого исходного кода, какими параметрами.
docker buildx build --provenance=true --sbom=true -t myapp:1.0 .
Attestations прикладываются к образу в реестре как отдельные артефакты и доступны через docker buildx imagetools inspect.
| Что даёт | Практическая ценность |
|---|---|
| Известен коммит исходного кода | Можно связать образ с кодом |
| Известны параметры сборки | Воспроизвести можно |
| Известна платформа сборки | Отследить компрометацию |
| Подпись | Проверить, что не подменён |
Для внутренних проектов ценность средняя. Она резко растёт, когда образы публикуются наружу или когда требуется соответствие стандартам вроде SLSA.
Откат
Откат невозможен без ответа на вопрос: какой именно образ работал?
| Что развёрнуто | Можно откатиться |
|---|---|
myapp:latest | Нет — неизвестно, какой это был образ |
myapp:v1.2.3 | Если тег не перезаписывали |
myapp:v1.2.3@sha256:... | Да, гарантированно |
Правило: теги релизов не перезаписывают. Если v1.2.3 собран и опубликован, он больше не меняется. Исправление выходит как v1.2.4.
Практическая схема тегирования:
myapp:git-a3f8c91 ← неизменяемый, по коммиту
myapp:v1.2.3 ← неизменяемый, релизный
myapp:staging ← изменяемый, указатель на текущий staging
myapp:latest ← изменяемый, указатель на последний релиз
Развёртывают по неизменяемому тегу или digest, а изменяемые нужны только для удобства человека.
Аутентификация в реестре
| Способ | Когда |
|---|---|
docker login с паролем | Локально, разово |
| Токен с ограниченной областью | CI |
| Учётные данные облачного провайдера | Managed-реестры |
| Credential helper | Локально, безопаснее пароля в файле |
По умолчанию docker login сохраняет учётные данные в ~/.docker/config.json в кодировке base64 — не зашифрованными. Credential helper хранит их в системном хранилище ключей.
Внутренний механизм
Как получить digest
docker image inspect образ --format '{{index .RepoDigests 0}}'
Поле заполняется после push или pull: digest вычисляется по манифесту в реестре. Локально собранный и не отправленный образ RepoDigests не имеет — идентификатор есть, но он локальный (Id).
Отсюда практическое следствие: закрепить по digest можно только то, что побывало в реестре.
Почему digest не меняется
Digest — хеш манифеста, который содержит хеши всех слоёв и конфигурации. Изменение любого байта меняет digest. Это делает его надёжным идентификатором и одновременно объясняет, почему пересборка того же Dockerfile обычно даёт другой digest: меняются метки времени (урок 5.9).
Команды и примеры
Тег указывает на разное в разное время
mkdir -p /tmp/supply && cd /tmp/supply
echo "═══ текущий digest тега ═══"
docker pull -q python:3.13-slim > /dev/null 2>&1
docker image inspect python:3.13-slim --format ' тег: python:3.13-slim'
docker image inspect python:3.13-slim --format ' digest: {{index .RepoDigests 0}}' 2>/dev/null \
|| echo " digest: (образ не из реестра)"
echo "═══ локальный идентификатор против digest реестра ═══"
docker image inspect python:3.13-slim --format ' Id (локальный): {{.Id}}' | cut -c1-60
docker image inspect python:3.13-slim --format ' RepoDigests: {{json .RepoDigests}}' | cut -c1-90
echo "═══ что произойдёт при обновлении тега ═══"
cat <<'TXT'
Тег python:3.13-slim обновляется при каждом исправлении в Debian.
Через неделю тот же тег даст другой digest — и другой набор пакетов.
Сборка, «работавшая вчера», может перестать работать сегодня.
TXT
Ожидаемый вывод:
═══ текущий digest тега ═══
тег: python:3.13-slim
digest: python@sha256:3f8a91c2e7d45b6a09fe1c3d8b27a4e5f60c9d1e2a3b4c5d6e7f8091a2b3c4d5e
═══ локальный идентификатор против digest реестра ═══
Id (локальный): sha256:9c4e2d1a8b7f6053e4d3c2b1a0bf503bb2243c39
RepoDigests: ["python@sha256:3f8a91c2e7d45b6a09fe1c3d8b27a4e5f60c9d1e2a3b4c5d6e7f8091a2"]
═══ что произойдёт при обновлении тега ═══
Тег python:3.13-slim обновляется при каждом исправлении в Debian.
Через неделю тот же тег даст другой digest — и другой набор пакетов.
Сборка, «работавшая вчера», может перестать работать сегодня.
Два разных идентификатора: Id — локальный хеш конфигурации, RepoDigests — идентификатор в реестре. Закрепляют по второму.
Закрепление базового образа
cd /tmp/supply
DIGEST="$(docker image inspect python:3.13-slim --format '{{index .RepoDigests 0}}' 2>/dev/null | cut -d@ -f2)"
printf ' используем digest: %s\n' "${DIGEST:-не получен}"
cat > Dockerfile.pinned <<EOF
# syntax=docker/dockerfile:1
# Базовый образ закреплён по digest: сборка воспроизводима
FROM python:3.13-slim@${DIGEST}
# Метка для аудита: видно, из чего собран образ
LABEL org.opencontainers.image.base.name="python:3.13-slim@${DIGEST}"
LABEL org.opencontainers.image.title="supply-demo"
LABEL org.opencontainers.image.version="1.0.0"
WORKDIR /app
RUN echo "приложение" > /app/marker.txt
CMD ["cat", "/app/marker.txt"]
EOF
docker build -q -f Dockerfile.pinned -t supply:pinned . > /dev/null
echo "═══ метки образа ═══"
docker image inspect supply:pinned --format '{{range $k, $v := .Config.Labels}} {{$k}}: {{$v}}
{{end}}' | cut -c1-110
echo "═══ повторная сборка даёт тот же базовый слой ═══"
docker build -q -f Dockerfile.pinned -t supply:pinned2 . > /dev/null
base1="$(docker image inspect supply:pinned --format '{{index .RootFS.Layers 0}}')"
base2="$(docker image inspect supply:pinned2 --format '{{index .RootFS.Layers 0}}')"
printf ' базовый слой первой сборки: %s\n' "$(echo "$base1" | cut -c1-32)"
printf ' базовый слой второй сборки: %s\n' "$(echo "$base2" | cut -c1-32)"
[ "$base1" = "$base2" ] && echo " ✓ совпадают" || echo " ✗ различаются"
docker rmi -f supply:pinned2 > /dev/null 2>&1
Ожидаемый вывод:
используем digest: sha256:3f8a91c2e7d45b6a09fe1c3d8b27a4e5f60c9d1e2a3b4c5d6e7f8091a2b3c4d5e
═══ метки образа ═══
org.opencontainers.image.base.name: python:3.13-slim@sha256:3f8a91c2e7d45b6a09fe1c3d8b2
org.opencontainers.image.title: supply-demo
org.opencontainers.image.version: 1.0.0
═══ повторная сборка даёт тот же базовый слой ═══
базовый слой первой сборки: sha256:a1b2c3d4e5f60718293a4b5c
базовый слой второй сборки: sha256:a1b2c3d4e5f60718293a4b5c
✓ совпадают
Метка base.name с digest — то, что позволит через полгода узнать, на чём собран образ, даже если тег давно указывает на другое.
Сканирование образа
cd /tmp/supply
echo "═══ доступные инструменты ═══"
docker scout version > /dev/null 2>&1 && echo " docker scout: есть" || echo " docker scout: не установлен"
echo "═══ сканирование через Trivy (запускается как container) ═══"
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$HOME/.cache/trivy:/root/.cache/trivy" \
aquasec/trivy:latest image --severity HIGH,CRITICAL --format table \
python:3.13-slim 2>/dev/null | head -25 | sed 's/^/ /' \
|| echo " (Trivy недоступен — нужен доступ к сети и сокету Docker)"
Ожидаемый вывод:
═══ доступные инструменты ═══
docker scout: есть
═══ сканирование через Trivy (запускается как container) ═══
python:3.13-slim (debian 13.2)
==============================
Total: 4 (HIGH: 4, CRITICAL: 0)
┌──────────────┬────────────────┬──────────┬──────────────┬───────────────┬─────────┐
│ Library │ Vulnerability │ Severity │ Status │Installed Ver. │ Fixed │
├──────────────┼────────────────┼──────────┼──────────────┼───────────────┼─────────┤
│ libsqlite3-0 │ CVE-2025-29087 │ HIGH │ affected │ 3.46.1-1 │ │
│ perl-base │ CVE-2023-31484 │ HIGH │ affected │ 5.40.1-3 │ │
│ zlib1g │ CVE-2023-45853 │ HIGH │ will_not_fix │ 1:1.3.dev-1 │ │
│ libpam0g │ CVE-2025-6020 │ HIGH │ fixed │ 1.7.0-3 │ 1.7.0-4 │
└──────────────┴────────────────┴──────────┴──────────────┴───────────────┴─────────┘
Важное замечание. Конкретные CVE в выводе зависят от даты и версии базового образа — у вас они будут другими. Смотреть нужно на столбец Status, а не на список.
Разбор столбца Status:
| Значение | Что делать |
|---|---|
fixed | Есть обновление — обновить базовый образ |
affected | Исправления пока нет — оценить достижимость |
will_not_fix | Сопровождающий решил не исправлять — оценить риск |
end_of_life | Пакет не поддерживается — менять базовый образ |
Строка libpam0g со статусом fixed — единственная в примере, требующая простого действия: обновить базовый образ, и она исчезнет.
Строка zlib1g со will_not_fix требует решения: оценить, достижима ли уязвимость в вашем приложении, и записать вывод.
Что даёт обновление базового образа
cd /tmp/supply
echo "═══ сравнение старого и нового базового образа ═══"
cat <<'TXT'
Приём: сравнить число уязвимостей до и после обновления.
docker pull python:3.13-slim
trivy image --severity HIGH,CRITICAL python:3.13-slim
Обычно обновление базового образа закрывает большинство находок,
потому что подавляющая их часть — в системных пакетах, а не в вашем коде.
TXT
echo "═══ откуда берутся уязвимости ═══"
docker run --rm python:3.13-slim sh -c '
printf " системных пакетов Debian: %s\n" "$(dpkg -l 2>/dev/null | grep -c "^ii")"
printf " пакетов Python: %s\n" "$(pip list --format=freeze 2>/dev/null | wc -l)"
'
Ожидаемый вывод:
═══ сравнение старого и нового базового образа ═══
Приём: сравнить число уязвимостей до и после обновления.
docker pull python:3.13-slim
trivy image --severity HIGH,CRITICAL python:3.13-slim
Обычно обновление базового образа закрывает большинство находок,
потому что подавляющая их часть — в системных пакетах, а не в вашем коде.
═══ откуда берутся уязвимости ═══
системных пакетов Debian: 97
пакетов Python: 3
Девяносто семь системных пакетов против трёх Python-пакетов. Это объясняет, почему выбор базового образа влияет на число находок сильнее, чем ваши зависимости (урок 6.1).
Отсюда практический вывод: первое действие при большом числе находок — обновить базовый образ, а не разбирать каждую CVE.
SBOM
cd /tmp/supply
echo "═══ получение SBOM через Syft ═══"
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
anchore/syft:latest python:3.13-slim -o spdx-json 2>/dev/null \
> sbom.json || echo " (Syft недоступен)"
if [ -s sbom.json ]; then
python3 - <<'PY'
import json
from collections import Counter
with open("sbom.json") as f:
sbom = json.load(f)
packages = sbom.get("packages", [])
print(f" формат: {sbom.get('spdxVersion', '?')}")
print(f" компонентов: {len(packages)}")
kinds = Counter()
for p in packages:
refs = p.get("externalRefs", [])
purl = next((r["referenceLocator"] for r in refs
if r.get("referenceType") == "purl"), "")
kind = purl.split("/")[0].replace("pkg:", "") if purl else "прочее"
kinds[kind] += 1
for kind, n in kinds.most_common(5):
print(f" {kind:<12} {n}")
PY
else
cat <<'TXT'
SBOM не получен. Команда для справки:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
anchore/syft:latest ОБРАЗ -o spdx-json > sbom.json
TXT
fi
Ожидаемый вывод:
═══ получение SBOM через Syft ═══
формат: SPDX-2.3
компонентов: 104
deb 97
pypi 4
binary 3
Сто четыре компонента — это и есть ответ на вопрос «что внутри образа».
Практическая ценность видна при инциденте: когда объявлена уязвимость в конкретной библиотеке, поиск по сохранённым SBOM даёт ответ за секунды, а не за часы пересканирования.
Поиск компонента по SBOM
cd /tmp/supply
if [ -s sbom.json ]; then
cat > find-component.py <<'PY'
"""Поиск компонента в SBOM — то, ради чего SBOM и хранят."""
import json
import sys
target = sys.argv[1] if len(sys.argv) > 1 else "zlib"
with open("sbom.json") as f:
sbom = json.load(f)
found = [p for p in sbom.get("packages", [])
if target.lower() in p.get("name", "").lower()]
if not found:
print(f" '{target}' не найден в образе")
else:
print(f" найдено совпадений: {len(found)}")
for p in found[:5]:
print(f" {p.get('name')} {p.get('versionInfo', '?')}")
PY
for comp in zlib openssl requests; do
echo " поиск '$comp':"
python3 find-component.py "$comp" | sed 's/^/ /'
done
else
echo " (SBOM отсутствует, пример пропущен)"
fi
Ожидаемый вывод:
поиск 'zlib':
найдено совпадений: 1
zlib1g 1:1.3.dev-1
поиск 'openssl':
найдено совпадений: 2
libssl3 3.5.1-1
openssl 3.5.1-1
поиск 'requests':
'requests' не найден в образе
Три запроса, три однозначных ответа. Без SBOM на каждый пришлось бы запускать container и проверять вручную.
При сотне образов в реестре разница между «за секунду» и «за день» решает, успеете ли вы отреагировать на объявленную уязвимость.
Provenance и attestations
cd /tmp/supply
echo "═══ сборка с provenance и SBOM ═══"
docker buildx build --provenance=true --sbom=true \
-f Dockerfile.pinned -t supply:attested --load . > /dev/null 2>&1 \
&& echo " собрано с attestations" \
|| echo " (--load не поддерживает attestations; нужен push в реестр)"
cat <<'TXT'
Замечание: attestations прикладываются к образу в РЕЕСТРЕ.
При сборке с --load они теряются — нужен push:
docker buildx build --provenance=true --sbom=true \
-t registry.example.com/myapp:1.0 --push .
Просмотр после публикации:
docker buildx imagetools inspect registry.example.com/myapp:1.0 \
--format '{{ json .Provenance }}'
TXT
echo "═══ что содержит provenance ═══"
cat <<'TXT'
buildType чем собрано (BuildKit и версия)
invocation параметры сборки, включая build args
materials из чего собрано: базовый образ по digest, источники
metadata когда начата и завершена, воспроизводима ли
TXT
Ожидаемый вывод:
═══ сборка с provenance и SBOM ═══
(--load не поддерживает attestations; нужен push в реестр)
Замечание: attestations прикладываются к образу в РЕЕСТРЕ.
При сборке с --load они теряются — нужен push:
docker buildx build --provenance=true --sbom=true \
-t registry.example.com/myapp:1.0 --push .
Просмотр после публикации:
docker buildx imagetools inspect registry.example.com/myapp:1.0 \
--format '{{ json .Provenance }}'
═══ что содержит provenance ═══
buildType чем собрано (BuildKit и версия)
invocation параметры сборки, включая build args
materials из чего собрано: базовый образ по digest, источники
metadata когда начата и завершена, воспроизводима ли
Строка про --load — важное практическое ограничение: attestations существуют как отдельные артефакты в реестре и не переносятся в локальное хранилище образов.
Обратите внимание на invocation: он содержит build args. Секрет, переданный через --build-arg, попадёт и туда — ещё одна причина не делать этого (урок 11.5).
Тегирование для отката
cd /tmp/supply
mkdir -p registry-sim && cd registry-sim
cat > tag-scheme.sh <<'SH'
#!/usr/bin/env bash
# Схема тегирования: неизменяемые теги плюс изменяемые указатели.
set -uo pipefail
COMMIT="${1:-a3f8c91}"
VERSION="${2:-1.2.3}"
IMAGE="supply-demo"
cat > /tmp/supply/registry-sim/Dockerfile <<EOF
FROM alpine:3.21
RUN echo "версия $VERSION, коммит $COMMIT" > /VERSION
CMD ["cat", "/VERSION"]
EOF
docker build -q -t "$IMAGE:git-$COMMIT" /tmp/supply/registry-sim > /dev/null
# Неизменяемые теги — оба указывают на один образ
docker tag "$IMAGE:git-$COMMIT" "$IMAGE:v$VERSION"
# Изменяемые указатели — для удобства человека
docker tag "$IMAGE:git-$COMMIT" "$IMAGE:latest"
printf ' собрано и помечено:\n'
docker images --format ' {{.Repository}}:{{.Tag}} → {{.ID}}' | grep "^ $IMAGE:" | sort
SH
chmod +x tag-scheme.sh
echo "═══ выпуск 1.2.3 ═══"
./tag-scheme.sh a3f8c91 1.2.3
printf ' что отвечает latest: %s\n' "$(docker run --rm supply-demo:latest)"
echo "═══ выпуск 1.2.4 ═══"
./tag-scheme.sh b7e2f14 1.2.4
printf ' что отвечает latest: %s\n' "$(docker run --rm supply-demo:latest)"
echo "═══ откат ═══"
printf ' по latest — куда откатываться, неизвестно\n'
printf ' по неизменяемому тегу v1.2.3: %s\n' "$(docker run --rm supply-demo:v1.2.3)"
printf ' по коммиту git-a3f8c91: %s\n' "$(docker run --rm supply-demo:git-a3f8c91)"
echo "═══ проверка неизменяемости ═══"
id_123="$(docker image inspect supply-demo:v1.2.3 --format '{{.Id}}')"
id_124="$(docker image inspect supply-demo:v1.2.4 --format '{{.Id}}')"
id_latest="$(docker image inspect supply-demo:latest --format '{{.Id}}')"
printf ' v1.2.3: %s\n' "$(echo "$id_123" | cut -c8-24)"
printf ' v1.2.4: %s\n' "$(echo "$id_124" | cut -c8-24)"
printf ' latest: %s (совпадает с %s)\n' "$(echo "$id_latest" | cut -c8-24)" \
"$([ "$id_latest" = "$id_124" ] && echo v1.2.4 || echo v1.2.3)"
docker rmi -f supply-demo:latest supply-demo:v1.2.3 supply-demo:v1.2.4 \
supply-demo:git-a3f8c91 supply-demo:git-b7e2f14 > /dev/null 2>&1
cd /tmp/supply
Ожидаемый вывод:
═══ выпуск 1.2.3 ═══
собрано и помечено:
supply-demo:git-a3f8c91 → sha256:8f3a2c1e
supply-demo:latest → sha256:8f3a2c1e
supply-demo:v1.2.3 → sha256:8f3a2c1e
что отвечает latest: версия 1.2.3, коммит a3f8c91
═══ выпуск 1.2.4 ═══
собрано и помечено:
supply-demo:git-b7e2f14 → sha256:2d9e4f71
supply-demo:latest → sha256:2d9e4f71
supply-demo:v1.2.4 → sha256:2d9e4f71
что отвечает latest: версия 1.2.4, коммит b7e2f14
═══ откат ═══
по latest — куда откатываться, неизвестно
по неизменяемому тегу v1.2.3: версия 1.2.3, коммит a3f8c91
по коммиту git-a3f8c91: версия 1.2.3, коммит a3f8c91
═══ проверка неизменяемости ═══
v1.2.3: 8f3a2c1e
v1.2.4: 2d9e4f71
latest: 2d9e4f71 (совпадает с v1.2.4)
Тег latest переехал на новый образ — и информация о том, что было развёрнуто раньше, исчезла бы, не будь неизменяемых тегов.
Теги v1.2.3 и git-a3f8c91 остались на прежнем образе: откат возможен и точен.
Проверка политики образов
cd /tmp/supply
cat > image-policy.sh <<'SH'
#!/usr/bin/env bash
# Проверка политики: закрепление, метки, отсутствие изменяемых тегов в FROM.
set -uo pipefail
DOCKERFILE="${1:-Dockerfile}"
IMAGE="${2:-}"
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
no() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\nПроверка %s\n' "$DOCKERFILE"
# 1. Базовые образы закреплены по digest
unpinned=0
while read -r line; do
case "$line" in
*@sha256:*) ;;
*scratch*) ;;
*) unpinned=$((unpinned + 1)); printf ' не закреплён: %s\n' "$line" ;;
esac
done < <(grep -iE '^\s*FROM ' "$DOCKERFILE" 2>/dev/null)
[ "$unpinned" -eq 0 ] && ok "все FROM закреплены по digest" \
|| no "не закреплено образов: $unpinned"
# 2. Нет тега latest
grep -iqE '^\s*FROM .*:latest' "$DOCKERFILE" 2>/dev/null \
&& no "используется тег latest" || ok "тег latest не используется"
# 3. Метки OCI
for label in "org.opencontainers.image.base.name" "org.opencontainers.image.version"; do
grep -q "$label" "$DOCKERFILE" 2>/dev/null \
&& ok "метка $label задана" || no "метка $label отсутствует"
done
# 4. Секреты не через ARG
grep -iqE '^\s*ARG .*(TOKEN|SECRET|PASSWORD|KEY)' "$DOCKERFILE" 2>/dev/null \
&& no "секрет через ARG — попадёт в историю" || ok "секретов в ARG нет"
# 5. Если образ указан — проверить метки в собранном
if [ -n "$IMAGE" ] && docker image inspect "$IMAGE" > /dev/null 2>&1; then
base="$(docker image inspect "$IMAGE" \
--format '{{index .Config.Labels "org.opencontainers.image.base.name"}}' 2>/dev/null)"
case "$base" in
*@sha256:*) ok "метка base.name содержит digest" ;;
""|"<no value>") no "метка base.name пуста в собранном образе" ;;
*) no "метка base.name без digest: $base" ;;
esac
fi
exit "$fail"
SH
chmod +x image-policy.sh
echo "═══ закреплённый Dockerfile ═══"
./image-policy.sh Dockerfile.pinned supply:pinned
echo
echo "═══ незакреплённый — для сравнения ═══"
cat > Dockerfile.loose <<'EOF'
FROM python:latest
ARG API_TOKEN
RUN echo "сборка"
CMD ["true"]
EOF
./image-policy.sh Dockerfile.loose || true
Ожидаемый вывод:
═══ закреплённый Dockerfile ═══
Проверка Dockerfile.pinned
✓ все FROM закреплены по digest
✓ тег latest не используется
✓ метка org.opencontainers.image.base.name задана
✓ метка org.opencontainers.image.version задана
✓ секретов в ARG нет
✓ метка base.name содержит digest
═══ незакреплённый — для сравнения ═══
Проверка Dockerfile.loose
не закреплён: FROM python:latest
✗ не закреплено образов: 1
✗ используется тег latest
✗ метка org.opencontainers.image.base.name отсутствует
✗ метка org.opencontainers.image.version отсутствует
✗ секрет через ARG — попадёт в историю
Пять нарушений в четырёх строках Dockerfile — типичный результат для файла, написанного без политики.
Такую проверку ставят в CI: она дешёвая, детерминированная и ловит нарушения до публикации образа.
docker rmi -f supply:pinned > /dev/null 2>&1
cd /tmp && rm -rf /tmp/supply
Практическое упражнение
Задание. Приведите образ в соответствие требованиям цепочки поставки и подтвердите каждое.
Требования:
- Базовый образ закреплён по digest; метка
base.nameсодержит digest. - Показано, что тег и digest — разные вещи: один тег может указывать на разные образы.
- Получен SBOM; по нему найден конкретный компонент.
- Схема тегирования: неизменяемый тег по коммиту, неизменяемый релизный, изменяемый указатель.
- Продемонстрирован откат по неизменяемому тегу и невозможность отката по
latest. - Скрипт проверки политики находит нарушения в «плохом»
Dockerfileи не находит в «хорошем».
Подсказки
Подсказка 1
Digest можно получить из RepoDigests — но только для образа, побывавшего в реестре.
Подсказка 2
Пункт 2 моделируется двумя сборками с одним тегом: второй перезаписывает первый.
Подсказка 3
Для пункта 6 нужны оба варианта: скрипт, всегда возвращающий успех, ничего не проверяет.
Решение
Показать решение
mkdir -p /tmp/supplyfull && cd /tmp/supplyfull
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Требование 1: закрепление по digest ═══\n'
docker pull -q python:3.13-slim > /dev/null 2>&1
DIGEST="$(docker image inspect python:3.13-slim \
--format '{{index .RepoDigests 0}}' 2>/dev/null | cut -d@ -f2)"
if [ -z "$DIGEST" ]; then
bad "digest не получен — образ не из реестра"
DIGEST="sha256:0000000000000000000000000000000000000000000000000000000000000000"
fi
printf ' digest базового образа: %s\n' "$(echo "$DIGEST" | cut -c1-40)…"
cat > Dockerfile <<EOF
# syntax=docker/dockerfile:1
FROM python:3.13-slim@${DIGEST}
LABEL org.opencontainers.image.base.name="python:3.13-slim@${DIGEST}"
LABEL org.opencontainers.image.title="supplyfull"
LABEL org.opencontainers.image.version="1.0.0"
LABEL org.opencontainers.image.source="https://example.com/repo"
WORKDIR /app
ARG BUILD_COMMIT=unknown
ARG BUILD_VERSION=0.0.0
RUN printf 'версия %s, коммит %s\n' "\${BUILD_VERSION}" "\${BUILD_COMMIT}" > /app/VERSION
CMD ["cat", "/app/VERSION"]
EOF
docker build -q --build-arg BUILD_COMMIT=a3f8c91 --build-arg BUILD_VERSION=1.2.3 \
-t supplyfull:build . > /dev/null 2>&1
label="$(docker image inspect supplyfull:build \
--format '{{index .Config.Labels "org.opencontainers.image.base.name"}}')"
printf ' метка base.name: %s\n' "$(echo "$label" | cut -c1-50)…"
case "$label" in
*@sha256:*) ok "базовый образ закреплён, метка содержит digest" ;;
*) bad "метка без digest: $label" ;;
esac
printf '\n═══ Требование 2: тег против digest ═══\n'
cat > Dockerfile.v1 <<'EOF'
FROM alpine:3.21
RUN echo "содержимое ВЕРСИЯ-1" > /content.txt
CMD ["cat", "/content.txt"]
EOF
cat > Dockerfile.v2 <<'EOF'
FROM alpine:3.21
RUN echo "содержимое ВЕРСИЯ-2" > /content.txt
CMD ["cat", "/content.txt"]
EOF
docker build -q -f Dockerfile.v1 -t movable:tag . > /dev/null
id1="$(docker image inspect movable:tag --format '{{.Id}}')"
out1="$(docker run --rm movable:tag)"
printf ' первая сборка тега movable:tag: %s → %s\n' "$(echo "$id1" | cut -c8-20)" "$out1"
docker build -q -f Dockerfile.v2 -t movable:tag . > /dev/null
id2="$(docker image inspect movable:tag --format '{{.Id}}')"
out2="$(docker run --rm movable:tag)"
printf ' вторая сборка того же тега: %s → %s\n' "$(echo "$id2" | cut -c8-20)" "$out2"
[ "$id1" != "$id2" ] && [ "$out1" != "$out2" ] \
&& ok "один тег указал на два разных образа" || bad "образы совпали"
printf ' → тег не идентифицирует образ; идентифицирует digest\n'
printf '\n═══ Требование 3: SBOM и поиск компонента ═══\n'
sbom_ok=0
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
anchore/syft:latest supplyfull:build -o spdx-json > sbom.json 2>/dev/null && sbom_ok=1
if [ "$sbom_ok" = "1" ] && [ -s sbom.json ]; then
python3 - <<'PY'
import json
from collections import Counter
with open("sbom.json") as f:
sbom = json.load(f)
pkgs = sbom.get("packages", [])
print(f" формат: {sbom.get('spdxVersion', '?')}, компонентов: {len(pkgs)}")
kinds = Counter()
for p in pkgs:
purl = next((r["referenceLocator"] for r in p.get("externalRefs", [])
if r.get("referenceType") == "purl"), "")
kinds[purl.split("/")[0].replace("pkg:", "") if purl else "прочее"] += 1
for k, n in kinds.most_common(4):
print(f" {k:<10} {n}")
for target in ("zlib", "openssl", "django"):
found = [p for p in pkgs if target in p.get("name", "").lower()]
if found:
print(f" поиск '{target}': найдено — " +
", ".join(f"{p['name']} {p.get('versionInfo', '?')}" for p in found[:2]))
else:
print(f" поиск '{target}': не найдено")
PY
ok "SBOM получен, поиск компонентов работает"
else
printf ' Syft недоступен. Команда для справки:\n'
printf ' docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \\\n'
printf ' anchore/syft:latest ОБРАЗ -o spdx-json > sbom.json\n'
printf ' Требование не подтверждено в этой среде.\n'
bad "SBOM не получен"
fi
printf '\n═══ Требование 4: схема тегирования ═══\n'
release() { # release <коммит> <версия>
local commit="$1" version="$2"
docker build -q --build-arg BUILD_COMMIT="$commit" --build-arg BUILD_VERSION="$version" \
-t "supplyfull:git-$commit" . > /dev/null
docker tag "supplyfull:git-$commit" "supplyfull:v$version" # неизменяемый
docker tag "supplyfull:git-$commit" "supplyfull:latest" # изменяемый указатель
}
release a3f8c91 1.2.3
printf ' выпуск 1.2.3: latest → %s\n' "$(docker run --rm supplyfull:latest | tr -d '\n')"
release b7e2f14 1.2.4
printf ' выпуск 1.2.4: latest → %s\n' "$(docker run --rm supplyfull:latest | tr -d '\n')"
printf ' все теги:\n'
docker images --format ' {{.Repository}}:{{.Tag}} → {{.ID}}' \
| grep '^ supplyfull:' | sort | sed 's/sha256://'
n_immutable="$(docker images --format '{{.Repository}}:{{.Tag}}' \
| grep -cE '^supplyfull:(v[0-9]|git-)' || true)"
[ "$n_immutable" -ge 4 ] && ok "неизменяемых тегов: $n_immutable" || bad "тегов мало: $n_immutable"
printf '\n═══ Требование 5: откат ═══\n'
printf ' что сейчас в latest: %s\n' "$(docker run --rm supplyfull:latest | tr -d '\n')"
printf ' откат по v1.2.3: %s\n' "$(docker run --rm supplyfull:v1.2.3 | tr -d '\n')"
printf ' откат по git-a3f8c91: %s\n' "$(docker run --rm supplyfull:git-a3f8c91 | tr -d '\n')"
id_latest="$(docker image inspect supplyfull:latest --format '{{.Id}}')"
id_v123="$(docker image inspect supplyfull:v1.2.3 --format '{{.Id}}')"
id_v124="$(docker image inspect supplyfull:v1.2.4 --format '{{.Id}}')"
printf ' latest = %s\n' "$([ "$id_latest" = "$id_v124" ] && echo 'v1.2.4 (последний)' || echo 'что-то другое')"
[ "$id_v123" != "$id_v124" ] && [ "$id_latest" = "$id_v124" ] \
&& ok "откат по неизменяемому тегу точен, latest указывает на последний" \
|| bad "теги ведут себя неожиданно"
printf ' → откат «на latest» невозможен: неизвестно, каким он был\n'
printf '\n═══ Требование 6: проверка политики ═══\n'
cat > image-policy.sh <<'SH'
#!/usr/bin/env bash
set -uo pipefail
DOCKERFILE="${1:?укажите Dockerfile}"
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
no() { printf ' ✗ %s\n' "$1"; fail=1; }
unpinned=0
while read -r line; do
case "$line" in
*@sha256:*|*scratch*) ;;
*) unpinned=$((unpinned + 1)) ;;
esac
done < <(grep -iE '^\s*FROM ' "$DOCKERFILE" 2>/dev/null)
[ "$unpinned" -eq 0 ] && ok "FROM закреплены по digest" || no "не закреплено: $unpinned"
grep -iqE '^\s*FROM .*:latest' "$DOCKERFILE" 2>/dev/null \
&& no "тег latest в FROM" || ok "нет тега latest"
for label in base.name version; do
grep -q "org.opencontainers.image.$label" "$DOCKERFILE" 2>/dev/null \
&& ok "метка $label" || no "нет метки $label"
done
grep -iqE '^\s*(ARG|ENV) .*(TOKEN|SECRET|PASSWORD|API_KEY)' "$DOCKERFILE" 2>/dev/null \
&& no "секрет через ARG/ENV" || ok "секретов в ARG/ENV нет"
exit "$fail"
SH
chmod +x image-policy.sh
cat > Dockerfile.bad <<'EOF'
FROM python:latest
ARG API_TOKEN
ENV SECRET_KEY=hardcoded-value-123
RUN echo сборка
CMD ["true"]
EOF
printf ' хороший Dockerfile:\n'
./image-policy.sh Dockerfile
good_rc=$?
printf ' плохой Dockerfile:\n'
./image-policy.sh Dockerfile.bad
bad_rc=$?
printf ' коды возврата: хороший=%s плохой=%s\n' "$good_rc" "$bad_rc"
[ "$good_rc" -eq 0 ] && [ "$bad_rc" -ne 0 ] \
&& ok "проверка различает соответствующий и нарушающий файлы" \
|| bad "проверка не различает (хороший=$good_rc плохой=$bad_rc)"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
docker rmi -f supplyfull:build supplyfull:latest supplyfull:v1.2.3 supplyfull:v1.2.4 \
supplyfull:git-a3f8c91 supplyfull:git-b7e2f14 movable:tag > /dev/null 2>&1
cd /tmp && rm -rf /tmp/supplyfull
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: закрепление по digest ═══
digest базового образа: sha256:3f8a91c2e7d45b6a09fe1c3d8b27a4e5f60c9…
метка base.name: python:3.13-slim@sha256:3f8a91c2e7d45b6a09fe1c3d8b2…
✓ базовый образ закреплён, метка содержит digest
═══ Требование 2: тег против digest ═══
первая сборка тега movable:tag: 8f3a2c1e9b04 → содержимое ВЕРСИЯ-1
вторая сборка того же тега: 2d9e4f71a6c3 → содержимое ВЕРСИЯ-2
✓ один тег указал на два разных образа
→ тег не идентифицирует образ; идентифицирует digest
═══ Требование 3: SBOM и поиск компонента ═══
формат: SPDX-2.3, компонентов: 104
deb 97
pypi 4
binary 3
поиск 'zlib': найдено — zlib1g 1:1.3.dev-1
поиск 'openssl': найдено — libssl3 3.5.1-1, openssl 3.5.1-1
поиск 'django': не найдено
✓ SBOM получен, поиск компонентов работает
═══ Требование 4: схема тегирования ═══
выпуск 1.2.3: latest → версия 1.2.3, коммит a3f8c91
выпуск 1.2.4: latest → версия 1.2.4, коммит b7e2f14
все теги:
supplyfull:git-a3f8c91 → 4b8e1f2c
supplyfull:git-b7e2f14 → 9d3a7c05
supplyfull:latest → 9d3a7c05
supplyfull:v1.2.3 → 4b8e1f2c
supplyfull:v1.2.4 → 9d3a7c05
✓ неизменяемых тегов: 4
═══ Требование 5: откат ═══
что сейчас в latest: версия 1.2.4, коммит b7e2f14
откат по v1.2.3: версия 1.2.3, коммит a3f8c91
откат по git-a3f8c91: версия 1.2.3, коммит a3f8c91
latest = v1.2.4 (последний)
✓ откат по неизменяемому тегу точен, latest указывает на последний
→ откат «на latest» невозможен: неизвестно, каким он был
═══ Требование 6: проверка политики ═══
хороший Dockerfile:
✓ FROM закреплены по digest
✓ нет тега latest
✓ метка base.name
✓ метка version
✓ секретов в ARG/ENV нет
плохой Dockerfile:
✗ не закреплено: 1
✗ тег latest в FROM
✗ нет метки base.name
✗ нет метки version
✗ секрет через ARG/ENV
коды возврата: хороший=0 плохой=1
✓ проверка различает соответствующий и нарушающий файлы
═══ ИТОГ ═══
все требования выполнены
Все требования выполнены.
Три решения, определяющие качество.
Требование 2 моделируется двумя сборками одного тега, а не рассуждением. Утверждение «теги изменяемы» звучит абстрактно, пока не увидишь два разных идентификатора у одного имени. Вывод ВЕРСИЯ-1 и ВЕРСИЯ-2 от одной команды docker run movable:tag делает проблему осязаемой.
Требование 6 проверяет оба файла и сравнивает коды возврата. Скрипт, возвращающий ноль на хорошем файле, мог бы возвращать ноль на любом. Пара «0 и 1» доказывает, что проверка различает случаи, а не просто завершается успешно.
Требование 3 честно сообщает о недоступности инструмента. Syft требует сети и доступа к сокету Docker; в закрытой среде он не запустится. Скрипт печатает команду для справки и помечает требование как неподтверждённое, а не притворяется, что всё в порядке. Проверка, которая молча пропускает недоступный шаг, хуже отсутствующей.
Чего решение не делает. Не проверяется provenance: attestations существуют в реестре, а без публикации проверять нечего. Локальная сборка с --load их теряет — это ограничение механизма, а не упущение. Не выполняется и сканирование уязвимостей: его результат зависит от даты и содержимого баз данных, поэтому воспроизводимой проверкой он быть не может. Сканирование ставят в CI как отдельный шаг с порогом по severity, а не как часть тестов.
Проверка результата
docker pull -q alpine:3.21 > /dev/null
docker image inspect alpine:3.21 --format 'RepoDigests: {{json .RepoDigests}}'
docker image inspect alpine:3.21 --format 'Id: {{.Id}}'
echo "Тег alpine:3.21 сегодня указывает на этот digest; через месяц может на другой."
Ожидается непустой RepoDigests и отличающийся от него Id.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Развёртывание по latest | Удобно | Откат невозможен: неизвестно, что было |
| Перезапись релизного тега | «Исправили и перевыложили» | Выпускать новую версию |
| Закрепление digest без процесса обновления | Услышали про воспроизводимость | Образ застынет с уязвимостями |
| Игнорируют все находки сканера | Их много | Разделять по Status и достижимости |
| Исправляют каждую CVE по отдельности | Кажется добросовестным | Обновление базового образа закрывает большинство |
Считают digest равным Id | Оба выглядят как sha256 | Id локальный, digest — из реестра |
Ждут attestations после --load | Логично предположить | Они существуют только в реестре |
Секрет через --build-arg при --provenance | Не связали одно с другим | Build args попадают в provenance |
| SBOM генерируют, но не хранят | Формальность | Ценность — в поиске при инциденте |
docker login с паролем в CI | Привычно | Токен с ограниченной областью |
Контрольные вопросы
На понимание:
- Почему тег не идентифицирует образ?
- Чем
Idотличается от digest изRepoDigests? - Какие факторы определяют, требует ли CVE действий?
- Зачем хранить SBOM, если можно пересканировать образ?
- Почему закрепление по digest без процесса обновления вредно?
На применение:
- Как организовать теги, чтобы откат был возможен?
- Как получить SBOM и найти в нём компонент?
- Как проверить политику образов автоматически?
На диагностику:
- Сборка перестала работать без изменений в коде. Первая версия?
- Нужно откатиться, развёрнут
myapp:latest. Что делать?
Краткое резюме
- Теги изменяемы: один тег в разное время указывает на разные образы.
- Digest неизменяем и вычисляется по содержимому манифеста.
Id— локальный идентификатор конфигурации; digest берут изRepoDigests.- Закрепление базового образа по digest даёт воспроизводимость, но требует процесса обновления.
- Метка
org.opencontainers.image.base.nameс digest сохраняет происхождение для аудита. - Действий требует меньшинство находок сканера: смотрят severity, наличие исправления, достижимость.
- Большинство уязвимостей — в системных пакетах базового образа, а не в вашем коде.
- Первое действие при множестве находок — обновить базовый образ.
- SBOM ценен возможностью найти компонент за секунды при объявлении уязвимости.
- Provenance содержит параметры сборки, включая build args — ещё одна причина не передавать через них секреты.
- Attestations живут в реестре и теряются при
--load. - Откат требует неизменяемых тегов: релизные теги не перезаписывают.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: image security | https://docs.docker.com/build/building/best-practices/#pin-base-image-versions | Закрепление по digest |
| Docker: attestations | https://docs.docker.com/build/metadata/attestations/ | Provenance, SBOM, --attest |
| Docker: SBOM attestations | https://docs.docker.com/build/metadata/attestations/sbom/ | Формат и получение |
| Docker: provenance attestations | https://docs.docker.com/build/metadata/attestations/slsa-provenance/ | Состав provenance |
| Docker Scout | https://docs.docker.com/scout/ | Сканирование, сравнение образов |
| Docker: registry authentication | https://docs.docker.com/reference/cli/docker/login/ | Хранение учётных данных, helpers |
| OCI image spec: annotations | https://github.com/opencontainers/image-spec/blob/main/annotations.md | Стандартные метки |
| SPDX | https://spdx.dev/ | Формат SBOM |
| CycloneDX | https://cyclonedx.org/ | Альтернативный формат |
| Trivy | https://trivy.dev/ | Сканирование и генерация SBOM |
| Syft | https://github.com/anchore/syft | Генерация SBOM |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление