Главная/Production-ready containers/Урок

11.6. Supply chain

Цели

После этого материала вы сможете:

  • отличить уязвимость, требующую действий, от той, что попадает в отчёт, но не эксплуатируется;
  • получить SBOM образа и объяснить, зачем он нужен на практике;
  • закрепить базовый образ по digest и объяснить, что это даёт;
  • показать, почему тег не является идентификатором образа;
  • организовать откат, зная точно, какой образ был развёрнут;
  • приложить к образу provenance и проверить его.

Предварительные знания

Ключевые термины

ТерминОбъяснение
SBOMSoftware Bill of Materials — перечень компонентов образа
provenanceСвидетельство о происхождении: чем, когда и из чего собран
attestationПодписанное утверждение, приложенное к образу
digestНеизменяемый идентификатор содержимого: sha256:...
CVEИдентификатор публично известной уязвимости
VEXУтверждение о применимости уязвимости к конкретному продукту

Теория

Тег не идентифицирует образ

text
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

dockerfile
FROM python:3.13-slim@sha256:a3ff2e57e2a1c0d3b4c5d6e7f8091a2b3c4d5e6f7089a1b2c3d4e5f60718293a
Что даётЧто отнимает
Воспроизводимость сборкиАвтоматические исправления безопасности
Точный откатТребует процесса обновления
Понятный аудитРучная работа

Закрепление без процесса обновления вреднее его отсутствия: образ застынет с известными уязвимостями.

Рабочая схема: digest в Dockerfile плюс автоматическое обновление (Renovate, Dependabot) с прогоном тестов.

Метка для аудита:

dockerfile
LABEL org.opencontainers.image.base.name="python:3.13-slim@sha256:a3ff..."

Сканирование: что требует действий

Сканер выдаёт список CVE. Действий требует меньшинство.

ФакторВопрос
SeverityCritical и High — в первую очередь
Наличие исправленияЕсть ли обновлённая версия пакета
Достижимость кодаИспользует ли приложение уязвимую функцию
Вектор атакиНужен ли сетевой доступ или локальный
Наличие эксплойтаИзвестен ли рабочий способ эксплуатации

Пример: уязвимость в libxml2 уровня Critical в образе Python-приложения, которое XML не обрабатывает. Формально Critical, практически недостижима.

Это не повод игнорировать — но повод расставить приоритеты и зафиксировать решение, а не молча пропустить.

КатегорияДействие
Critical/High с исправлениемОбновить базовый образ или пакет
Critical/High без исправленияОценить достижимость, зафиксировать решение
Medium/LowВ плановое обновление
Недостижимая уязвимостьЗадокументировать через VEX

Инструменты

ИнструментЧто делаетКак запустить
docker scoutСканирование, сравнение, рекомендацииПлагин Docker CLI
TrivyСканирование, SBOMdocker run aquasec/trivy
GrypeСканированиеdocker run anchore/grype
SyftТолько SBOMdocker run anchore/syft

Все они читают одни и те же базы данных уязвимостей, но различаются полнотой определения пакетов и форматом вывода. Результаты не совпадают полностью — это нормально.

SBOM

Перечень всего, что попало в образ: системные пакеты, библиотеки Python, версии.

ЗачемПример
Ответить на вопрос «есть ли у нас X?»Опубликована уязвимость в libwebp — где она у нас?
Аудит лицензийНе попал ли GPL-компонент в проприетарный продукт
Требование регулятораВсё чаще обязателен
Сравнение образовЧто изменилось между версиями

Первый пункт — главный практический. Без SBOM ответ на вопрос «в каких из наших ста образов есть уязвимая библиотека» требует пересканирования всех.

Форматы:

ФорматКто продвигает
SPDXLinux Foundation
CycloneDXOWASP

Оба поддерживаются инструментами; выбор обычно диктуется тем, что понимает ваша система учёта.

Provenance и attestations

Provenance отвечает на вопрос как был собран образ: каким инструментом, из какого исходного кода, какими параметрами.

bash
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.

Практическая схема тегирования:

text
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

bash
docker image inspect образ --format '{{index .RepoDigests 0}}'

Поле заполняется после push или pull: digest вычисляется по манифесту в реестре. Локально собранный и не отправленный образ RepoDigests не имеет — идентификатор есть, но он локальный (Id).

Отсюда практическое следствие: закрепить по digest можно только то, что побывало в реестре.

Почему digest не меняется

Digest — хеш манифеста, который содержит хеши всех слоёв и конфигурации. Изменение любого байта меняет digest. Это делает его надёжным идентификатором и одновременно объясняет, почему пересборка того же Dockerfile обычно даёт другой digest: меняются метки времени (урок 5.9).


Команды и примеры

Тег указывает на разное в разное время

bash
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

Ожидаемый вывод:

text
═══ текущий 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 — идентификатор в реестре. Закрепляют по второму.

Закрепление базового образа

bash
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

Ожидаемый вывод:

text
  используем 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 — то, что позволит через полгода узнать, на чём собран образ, даже если тег давно указывает на другое.

Сканирование образа

bash
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)"

Ожидаемый вывод:

text
═══ доступные инструменты ═══
  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 требует решения: оценить, достижима ли уязвимость в вашем приложении, и записать вывод.

Что даёт обновление базового образа

bash
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)"
'

Ожидаемый вывод:

text
═══ сравнение старого и нового базового образа ═══
  Приём: сравнить число уязвимостей до и после обновления.
    docker pull python:3.13-slim
    trivy image --severity HIGH,CRITICAL python:3.13-slim
  Обычно обновление базового образа закрывает большинство находок,
  потому что подавляющая их часть — в системных пакетах, а не в вашем коде.
═══ откуда берутся уязвимости ═══
  системных пакетов Debian: 97
  пакетов Python:           3

Девяносто семь системных пакетов против трёх Python-пакетов. Это объясняет, почему выбор базового образа влияет на число находок сильнее, чем ваши зависимости (урок 6.1).

Отсюда практический вывод: первое действие при большом числе находок — обновить базовый образ, а не разбирать каждую CVE.

SBOM

bash
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

Ожидаемый вывод:

text
═══ получение SBOM через Syft ═══
  формат:     SPDX-2.3
  компонентов: 104
    deb          97
    pypi         4
    binary       3

Сто четыре компонента — это и есть ответ на вопрос «что внутри образа».

Практическая ценность видна при инциденте: когда объявлена уязвимость в конкретной библиотеке, поиск по сохранённым SBOM даёт ответ за секунды, а не за часы пересканирования.

Поиск компонента по SBOM

bash
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

Ожидаемый вывод:

text
  поиск '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

bash
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

Ожидаемый вывод:

text
═══ сборка с 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).

Тегирование для отката

bash
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

Ожидаемый вывод:

text
═══ выпуск 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 остались на прежнем образе: откат возможен и точен.

Проверка политики образов

bash
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

Ожидаемый вывод:

text
═══ закреплённый 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: она дешёвая, детерминированная и ловит нарушения до публикации образа.

bash
docker rmi -f supply:pinned > /dev/null 2>&1
cd /tmp && rm -rf /tmp/supply

Практическое упражнение

Задание. Приведите образ в соответствие требованиям цепочки поставки и подтвердите каждое.

Требования:

  1. Базовый образ закреплён по digest; метка base.name содержит digest.
  2. Показано, что тег и digest — разные вещи: один тег может указывать на разные образы.
  3. Получен SBOM; по нему найден конкретный компонент.
  4. Схема тегирования: неизменяемый тег по коммиту, неизменяемый релизный, изменяемый указатель.
  5. Продемонстрирован откат по неизменяемому тегу и невозможность отката по latest.
  6. Скрипт проверки политики находит нарушения в «плохом» Dockerfile и не находит в «хорошем».

Подсказки

Подсказка 1

Digest можно получить из RepoDigests — но только для образа, побывавшего в реестре.

Подсказка 2

Пункт 2 моделируется двумя сборками с одним тегом: второй перезаписывает первый.

Подсказка 3

Для пункта 6 нужны оба варианта: скрипт, всегда возвращающий успех, ничего не проверяет.

Решение

Показать решение
bash
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"

Ожидаемый вывод:

text
═══ Требование 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, а не как часть тестов.

Проверка результата

bash
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Оба выглядят как sha256Id локальный, digest — из реестра
Ждут attestations после --loadЛогично предположитьОни существуют только в реестре
Секрет через --build-arg при --provenanceНе связали одно с другимBuild args попадают в provenance
SBOM генерируют, но не хранятФормальностьЦенность — в поиске при инциденте
docker login с паролем в CIПривычноТокен с ограниченной областью

Контрольные вопросы

На понимание:

  1. Почему тег не идентифицирует образ?
  2. Чем Id отличается от digest из RepoDigests?
  3. Какие факторы определяют, требует ли CVE действий?
  4. Зачем хранить SBOM, если можно пересканировать образ?
  5. Почему закрепление по digest без процесса обновления вредно?

На применение:

  1. Как организовать теги, чтобы откат был возможен?
  2. Как получить SBOM и найти в нём компонент?
  3. Как проверить политику образов автоматически?

На диагностику:

  1. Сборка перестала работать без изменений в коде. Первая версия?
  2. Нужно откатиться, развёрнут myapp:latest. Что делать?

Краткое резюме

  1. Теги изменяемы: один тег в разное время указывает на разные образы.
  2. Digest неизменяем и вычисляется по содержимому манифеста.
  3. Id — локальный идентификатор конфигурации; digest берут из RepoDigests.
  4. Закрепление базового образа по digest даёт воспроизводимость, но требует процесса обновления.
  5. Метка org.opencontainers.image.base.name с digest сохраняет происхождение для аудита.
  6. Действий требует меньшинство находок сканера: смотрят severity, наличие исправления, достижимость.
  7. Большинство уязвимостей — в системных пакетах базового образа, а не в вашем коде.
  8. Первое действие при множестве находок — обновить базовый образ.
  9. SBOM ценен возможностью найти компонент за секунды при объявлении уязвимости.
  10. Provenance содержит параметры сборки, включая build args — ещё одна причина не передавать через них секреты.
  11. Attestations живут в реестре и теряются при --load.
  12. Откат требует неизменяемых тегов: релизные теги не перезаписывают.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: image securityhttps://docs.docker.com/build/building/best-practices/#pin-base-image-versionsЗакрепление по digest
Docker: attestationshttps://docs.docker.com/build/metadata/attestations/Provenance, SBOM, --attest
Docker: SBOM attestationshttps://docs.docker.com/build/metadata/attestations/sbom/Формат и получение
Docker: provenance attestationshttps://docs.docker.com/build/metadata/attestations/slsa-provenance/Состав provenance
Docker Scouthttps://docs.docker.com/scout/Сканирование, сравнение образов
Docker: registry authenticationhttps://docs.docker.com/reference/cli/docker/login/Хранение учётных данных, helpers
OCI image spec: annotationshttps://github.com/opencontainers/image-spec/blob/main/annotations.mdСтандартные метки
SPDXhttps://spdx.dev/Формат SBOM
CycloneDXhttps://cyclonedx.org/Альтернативный формат
Trivyhttps://trivy.dev/Сканирование и генерация SBOM
Syfthttps://github.com/anchore/syftГенерация SBOM

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление

Markdown на GitHub ↗