Главная/Security/Урок

12.7. Supply chain security

Цели

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

  • оценить незнакомый образ до его запуска и обосновать решение о доверии;
  • отличить официальный образ от похожего на официальный;
  • обнаружить typosquatting в списке зависимостей Python;
  • закрепить зависимости по хешам и объяснить, от чего это защищает;
  • проверить подпись образа и его происхождение;
  • назвать, какие звенья цепочки поставок вы не контролируете.

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

Урок 11.6 разбирает гигиену: закрепление версий, приоритизацию находок, откат. Этот урок разбирает доверие: как решить, что артефакту можно верить, и как это проверить.

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

ТерминОбъяснение
typosquattingПакет с именем, похожим на популярный
dependency confusionПодмена внутреннего пакета одноимённым публичным
подписьКриптографическое подтверждение авторства артефакта
keyless signingПодпись без долговременного ключа, через OIDC
attestationЗаверенное утверждение о происхождении артефакта
RekorПубличный журнал прозрачности подписей

Теория

Что входит в цепочку поставок образа

text
базовый образ ─┐
               ├─→ сборка ─→ образ ─→ registry ─→ развёртывание
зависимости ───┤              ↑
               │              │
ваш код ───────┘         CI-система

Звеньев шесть, и вы полностью контролируете одно — свой код.

ЗвеноКонтрольОсновной риск
Базовый образВыбор и закреплениеСкомпрометирован или заброшен
ЗависимостиВыбор и закреплениеTyposquatting, взлом сопровождающего
Ваш кодПолныйУязвимости, случайные секреты
CI-системаНастройкаКомпрометация даёт подпись легитимным образам
RegistryНастройка доступаПодмена образа, удаление тега
РазвёртываниеНастройкаЗапуск непроверенного образа

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

Доверие к базовым образам

КатегорияПризнакУровень доверия
Docker Official ImagesНет пространства имён: python, postgresВысокий
Verified PublisherМетка в Docker Hub, известный вендорВысокий
Дистрибутивыregistry.access.redhat.com, gcr.io/distrolessВысокий
Известный проектОфициальный репозиторий проектаСредний
Произвольныйsomeuser/python-fixedНизкий

Ловушка в первой строке: официальные образы не имеют пространства имён. python:3.13 официален; pythonn/python:3.13 или library-python/python:3.13 — нет, независимо от того, как выглядит описание.

Это прямой аналог typosquatting, только для образов.

Оценка незнакомого образа

Порядок проверки до запуска:

ШагКомандаНа что смотреть
1docker manifest inspectСуществует ли, для каких платформ
2docker pull + docker historyИз чего собран, нет ли странных RUN
3docker inspectEntrypoint, Cmd, User, Env
4docker save + распаковкаЧто в слоях, нет ли лишнего
5СканерИзвестные уязвимости
6ПодписьКто и чем подписал

Ключевой принцип: docker pull безопасен, docker run — нет. Скачивание образа не выполняет его код; запуск выполняет. Между этими двумя шагами есть место для проверки, и им стоит пользоваться.

Единственное исключение — docker build: инструкции RUN базового образа при сборке не выполняются, но ONBUILD-инструкции выполняются. Образ с ONBUILD может выполнить код при сборке производного образа.

Typosquatting в PyPI

Атака проста: злоумышленник публикует пакет с именем, отличающимся от популярного на один символ, и ждёт опечаток.

НастоящийТипичные подделки
requestsrequest, requsts, requestss
python-dateutilpython-dateutils, dateutil
beautifulsoup4beautifulsoup, beautifulsoup44
urllib3urllib, urllib4
pyyamlpyaml (существует легитимно — усложняет проверку)

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

Вредоносный пакет выполняет код на этапе установки — в setup.py или через build hook. Это происходит во время сборки образа, то есть в вашем CI, с доступом к его окружению.

Связанная атака — dependency confusion: если сборка настроена на два индекса (публичный PyPI и внутренний), а злоумышленник публикует в PyPI пакет с именем вашего внутреннего, установщик может выбрать публичный — по более высокой версии.

Защита: --index-url вместо --extra-index-url для внутреннего индекса, закрепление по хешам.

Закрепление по хешам

text
requests==2.32.4 \
    --hash=sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c
bash
pip install --require-hashes -r requirements.txt

Режим --require-hashes требует хеш для каждого пакета, включая транзитивные зависимости. Установка прерывается, если хоть один отсутствует или не совпал.

От чего защищает:

УгрозаЗащищает
Подмена файла в зеркале PyPIДа
Перезалив версии сопровождающимДа
Атака на канал передачиДа
TyposquattingНет — вы сами вписали имя
Вредоносный код в легитимном пакетеНет

Механизм подтверждает, что вы получили именно то, что было при генерации файла, — но не то, что это безопасно.

Генерация: pip-compile --generate-hashes из pip-tools или uv pip compile --generate-hashes.

Подпись образов

Подпись отвечает на вопрос «кто собрал этот образ», а digest — на вопрос «тот ли это образ». Это разные вопросы.

МеханизмЧто подтверждает
DigestСодержимое не изменилось
ПодписьКто именно опубликовал
Provenance attestationКаким процессом и из какого исходного кода собран

Современный подход — keyless signing через cosign: подпись выполняется коротко живущим сертификатом, выданным по OIDC-идентификации (например, токену GitHub Actions), и записывается в публичный журнал прозрачности Rekor.

bash
cosign sign ghcr.io/org/app@sha256:...
cosign verify ghcr.io/org/app@sha256:... \
    --certificate-identity-regexp 'https://github.com/org/repo/.*' \
    --certificate-oidc-issuer https://token.actions.githubusercontent.com

Существенная часть — параметры проверки. cosign verify без указания ожидаемой идентичности подтверждает лишь то, что образ кем-то подписан. Это почти ничего не значит: подписать может любой.

Проверять нужно, что подписал ожидаемый процесс из ожидаемого репозитория.

Чего вы не контролируете

Честный список:

ЗвеноПочему не контролируете
Код внутри зависимостейТысячи строк, которые никто не читал
Сопровождающие пакетовИх учётные записи могут быть взломаны
Инфраструктура PyPI и Docker HubВнешние сервисы
Транзитивные зависимостиИх выбирали не вы
Базовый образСобран не вами

Меры этого урока сокращают риск, но не устраняют его. Это аргумент не против мер, а за наличие обнаружения и плана реагирования (урок 12.1, урок 13.1).


Внутренний механизм

Почему код зависимости выполняется при установке

Установка пакета в формате sdist требует выполнения setup.py — это обычный скрипт Python, выполняемый с правами процесса установки.

Формат wheel этого не требует: он распаковывается без выполнения кода. Отсюда практическая мера — pip install --only-binary=:all: запрещает сборку из исходников и, соответственно, выполнение setup.py.

Ограничение: не все пакеты публикуют wheel для всех платформ. Запрет может сломать установку.

Как работает keyless signing

  1. CI-система получает OIDC-токен, подтверждающий её идентичность (репозиторий, workflow, ветка).
  2. Cosign обменивает токен на короткоживущий сертификат в удостоверяющем центре Fulcio.
  3. Артефакт подписывается закрытым ключом, существующим только в памяти.
  4. Подпись и сертификат записываются в журнал Rekor.
  5. Ключ уничтожается.

Долговременного ключа не существует — красть нечего. Проверка обращается к Rekor и подтверждает, что подпись сделана сертификатом с ожидаемой идентичностью в момент, когда сертификат был действителен.


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

Официальный образ и похожий на официальный

bash
mkdir -p /tmp/supply && cd /tmp/supply

cat > check-official.py <<'PY'
"""Определяет, является ли образ официальным, по его имени.

Официальные образы Docker не имеют пространства имён: python, postgres.
Любой префикс перед именем означает, что образ опубликован
сторонним пользователем.
"""
from __future__ import annotations

import json
import sys

# Небольшая выборка официальных образов
OFFICIAL = {
    "python", "postgres", "redis", "nginx", "alpine", "debian", "ubuntu",
    "node", "mysql", "mongo", "rabbitmq", "golang", "openjdk", "busybox",
    "memcached", "traefik", "httpd", "mariadb", "elasticsearch",
}

TRUSTED_REGISTRIES = {
    "gcr.io/distroless": "Google Distroless",
    "registry.access.redhat.com": "Red Hat",
    "public.ecr.aws/docker/library": "AWS ECR Public (зеркало официальных)",
    "mcr.microsoft.com": "Microsoft",
}


def classify(ref: str) -> dict[str, object]:
    # Отделяем тег и digest
    name = ref.split("@")[0]
    if ":" in name.rsplit("/", 1)[-1]:
        name = name.rsplit(":", 1)[0]

    for prefix, owner in TRUSTED_REGISTRIES.items():
        if name.startswith(prefix):
            return {"категория": "доверенный реестр", "владелец": owner,
                    "риск": "низкий"}

    parts = name.split("/")

    if len(parts) == 1:
        official = parts[0] in OFFICIAL
        return {
            "категория": "Docker Official Image" if official
                         else "без пространства имён, но не в списке официальных",
            "владелец": "Docker Official Images" if official else "неизвестен",
            "риск": "низкий" if official else "проверить вручную",
        }

    if len(parts) == 2 and parts[0] == "library":
        return {"категория": "Docker Official Image (полная форма)",
                "владелец": "Docker Official Images", "риск": "низкий"}

    # Есть пространство имён — сторонний
    namespace = parts[-2]
    base = parts[-1]
    lookalike = None
    for off in OFFICIAL:
        if base == off or namespace == off:
            lookalike = off
        elif _close(base, off) or _close(namespace, off):
            lookalike = off
    return {
        "категория": "сторонний образ",
        "владелец": namespace,
        "риск": "ВЫСОКИЙ: похож на официальный" if lookalike else "средний",
        "похож_на": lookalike,
    }


def _close(a: str, b: str) -> bool:
    """Расстояние редактирования не больше 1 или отличие только суффиксом."""
    if abs(len(a) - len(b)) > 2:
        return False
    if a.rstrip("0123456789-_") == b.rstrip("0123456789-_") and a != b:
        return True
    if len(a) == len(b):
        return sum(x != y for x, y in zip(a, b)) == 1
    short, long_ = (a, b) if len(a) < len(b) else (b, a)
    for i in range(len(long_)):
        if long_[:i] + long_[i + 1:] == short:
            return True
    return False


if __name__ == "__main__":
    refs = sys.argv[1:] or ["python:3.13-slim"]
    print(json.dumps({r: classify(r) for r in refs}, ensure_ascii=False, indent=2))
PY

echo "═══ классификация ссылок на образы ═══"
python3 check-official.py \
    "python:3.13-slim" \
    "library/python:3.13" \
    "gcr.io/distroless/python3-debian12" \
    "someuser/python:3.13-slim" \
    "pythonn/python:3.13" \
    "docker.io/postgress/postgres:17" \
    | python3 -c "
import json, sys
d = json.load(sys.stdin)
for ref, info in d.items():
    risk = info['риск']
    mark = '✗' if 'ВЫСОК' in risk else ('!' if 'проверить' in risk or risk == 'средний' else '✓')
    print(f\"  {mark} {ref:<40} {info['категория']}\")
    print(f\"      владелец: {info['владелец']}, риск: {risk}\")
    if info.get('похож_на'):
        print(f\"      ⚠ имя похоже на официальный образ: {info['похож_на']}\")
"

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

text
═══ классификация ссылок на образы ═══
  ✓ python:3.13-slim                         Docker Official Image
      владелец: Docker Official Images, риск: низкий
  ✓ library/python:3.13                      Docker Official Image (полная форма)
      владелец: Docker Official Images, риск: низкий
  ✓ gcr.io/distroless/python3-debian12       доверенный реестр
      владелец: Google Distroless, риск: низкий
  ✗ someuser/python:3.13-slim                сторонний образ
      владелец: someuser, риск: ВЫСОКИЙ: похож на официальный
      ⚠ имя похоже на официальный образ: python
  ✗ pythonn/python:3.13                      сторонний образ
      владелец: pythonn, риск: ВЫСОКИЙ: похож на официальный
      ⚠ имя похоже на официальный образ: python
  ✗ docker.io/postgress/postgres:17          сторонний образ
      владелец: postgress, риск: ВЫСОКИЙ: похож на официальный
      ⚠ имя похоже на официальный образ: postgres

Три последние строки — разные варианты одной подмены: пространство имён, похожее на официальное имя, и внутри него образ с правильным названием.

Визуально postgress/postgres:17 в compose.yaml неотличимо от postgres:17 при беглом чтении.

Оценка незнакомого образа до запуска

bash
cd /tmp/supply
cat > inspect-image.sh <<'SH'
#!/usr/bin/env bash
# Оценка образа ДО запуска. Скачивание безопасно, запуск — нет.
set -uo pipefail

IMAGE="${1:?укажите образ}"
flags=0
note() { printf '    %s %s\n' "$1" "$2"; }
warn() { note "⚠" "$1"; flags=$((flags + 1)); }

printf '\n  Оценка образа: %s\n\n' "$IMAGE"

printf '  1. Существование и платформы\n'
if ! docker manifest inspect "$IMAGE" > /dev/null 2>&1; then
    warn "манифест недоступен — образа нет или нужна аутентификация"
else
    plat="$(docker manifest inspect "$IMAGE" 2>/dev/null \
        | python3 -c "
import json,sys
d = json.load(sys.stdin)
ms = d.get('manifests', [])
if ms:
    print(', '.join(f\"{m['platform']['os']}/{m['platform']['architecture']}\"
                     for m in ms if 'platform' in m and m['platform'].get('architecture') != 'unknown')[:70])
else:
    print('одна платформа')
" 2>/dev/null)"
    note "·" "платформы: ${plat:-неизвестно}"
fi

printf '  2. Скачивание (код НЕ выполняется)\n'
docker pull -q "$IMAGE" > /dev/null 2>&1 || { warn "не удалось скачать"; exit 1; }
digest="$(docker inspect "$IMAGE" --format '{{index .RepoDigests 0}}' 2>/dev/null || echo "-")"
note "·" "digest: ${digest##*@}"

printf '  3. Конфигурация\n'
user="$(docker inspect "$IMAGE" --format '{{.Config.User}}' 2>/dev/null)"
entry="$(docker inspect "$IMAGE" --format '{{json .Config.Entrypoint}}' 2>/dev/null)"
cmd="$(docker inspect "$IMAGE" --format '{{json .Config.Cmd}}' 2>/dev/null)"
note "·" "Entrypoint: $entry"
note "·" "Cmd:        $cmd"
if [ -z "$user" ] || [ "$user" = "root" ] || [ "$user" = "0" ]; then
    warn "USER не задан или root"
else
    note "·" "USER: $user"
fi

env_susp="$(docker inspect "$IMAGE" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
    | grep -Pi '(password|token|secret|key)=.{6,}' || true)"
[ -n "$env_susp" ] && warn "подозрительные переменные в ENV: $(echo "$env_susp" | wc -l)"

printf '  4. История сборки\n'
layers="$(docker history --no-trunc "$IMAGE" 2>/dev/null | tail -n +2 | wc -l)"
note "·" "слоёв: $layers"
susp="$(docker history --no-trunc "$IMAGE" 2>/dev/null \
    | grep -Ei 'curl [^|]*\| *(ba)?sh|wget [^|]*\| *(ba)?sh|nc -|/dev/tcp/' || true)"
[ -n "$susp" ] && warn "в истории есть загрузка и выполнение скрипта из сети"

onbuild="$(docker inspect "$IMAGE" --format '{{json .Config.OnBuild}}' 2>/dev/null)"
if [ "$onbuild" != "null" ] && [ "$onbuild" != "[]" ]; then
    warn "есть ONBUILD: код выполнится при сборке производного образа"
fi

printf '  5. Размер\n'
size="$(docker inspect "$IMAGE" --format '{{.Size}}' 2>/dev/null)"
note "·" "$((size / 1024 / 1024)) МиБ"

printf '\n'
if [ "$flags" -gt 0 ]; then
    printf '  замечаний: %s — оценить перед запуском\n' "$flags"
    exit 1
fi
printf '  замечаний нет\n'
exit 0
SH
chmod +x inspect-image.sh

echo "═══ оценка официального образа ═══"
./inspect-image.sh python:3.13-slim || true

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

text
═══ оценка официального образа ═══

  Оценка образа: python:3.13-slim

  1. Существование и платформы
    · платформы: linux/amd64, linux/arm64, linux/arm, linux/386, linux/ppc64le
  2. Скачивание (код НЕ выполняется)
    · digest: sha256:9b8...
  3. Конфигурация
    · Entrypoint: null
    · Cmd:        ["python3"]
    ⚠ USER не задан или root
  4. История сборки
    · слоёв: 9
  5. Размер
    · 124 МиБ

  замечаний: 1 — оценить перед запуском

Замечание об отсутствии USER справедливо и для официального образа: python:3.13-slim действительно работает от root по умолчанию (урок 12.3).

Это правильное поведение проверки: она сообщает факт, а решение принимает человек.

Обнаружение typosquatting в зависимостях

bash
cd /tmp/supply
cat > check-deps.py <<'PY'
"""Поиск подозрительных имён пакетов в requirements.txt.

Проверяет три признака:
  1. Малое расстояние редактирования до популярного пакета
  2. Отсутствие закрепления версии
  3. Отсутствие хеша при наличии хешей у других пакетов
"""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

# Популярные пакеты — цели для подделки
POPULAR = {
    "requests", "urllib3", "numpy", "pandas", "flask", "django", "fastapi",
    "pydantic", "sqlalchemy", "psycopg", "redis", "celery", "boto3",
    "pytest", "click", "jinja2", "pyyaml", "cryptography", "certifi",
    "setuptools", "beautifulsoup4", "python-dateutil", "httpx", "uvicorn",
    "gunicorn", "aiohttp", "attrs", "six", "typing-extensions",
}

# Имена, похожие на популярные, но существующие легитимно
KNOWN_GOOD = {"pyaml", "attr", "requests-toolbelt", "redis-py-cluster"}


def levenshtein(a: str, b: str) -> int:
    if len(a) < len(b):
        a, b = b, a
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]


def normalize(name: str) -> str:
    return re.sub(r"[-_.]+", "-", name).lower()


def parse(path: Path) -> list[dict[str, object]]:
    entries = []
    for raw in path.read_text().splitlines():
        line = raw.split("#")[0].strip()
        if not line or line.startswith("-"):
            continue
        m = re.match(r"^([A-Za-z0-9._-]+)\s*(\[[^\]]*\])?\s*(.*)$", line)
        if not m:
            continue
        entries.append({
            "имя": m.group(1),
            "спецификация": m.group(3).strip(),
            "закреплён": "==" in m.group(3),
            "хеш": "--hash=" in raw,
        })
    return entries


def audit(entries: list[dict[str, object]]) -> list[dict[str, object]]:
    findings = []
    any_hashes = any(e["хеш"] for e in entries)

    for e in entries:
        name = normalize(str(e["имя"]))

        if name not in POPULAR and name not in KNOWN_GOOD:
            for pop in POPULAR:
                d = levenshtein(name, pop)
                if 0 < d <= 1 or (d == 2 and len(pop) > 8):
                    findings.append({
                        "пакет": e["имя"], "уровень": "ВЫСОКИЙ",
                        "признак": f"имя отличается от «{pop}» на {d}",
                        "действие": "проверить на PyPI: автор, дата, число загрузок",
                    })
                    break

        if not e["закреплён"]:
            findings.append({
                "пакет": e["имя"], "уровень": "средний",
                "признак": "версия не закреплена",
                "действие": "указать ==версия",
            })

        if any_hashes and not e["хеш"]:
            findings.append({
                "пакет": e["имя"], "уровень": "средний",
                "признак": "нет хеша при наличии хешей у других",
                "действие": "--require-hashes требует хеш для каждого пакета",
            })

    return findings


if __name__ == "__main__":
    path = Path(sys.argv[1] if len(sys.argv) > 1 else "requirements.txt")
    entries = parse(path)
    findings = audit(entries)
    print(json.dumps({
        "пакетов": len(entries),
        "находок": len(findings),
        "высокий_уровень": sum(1 for f in findings if f["уровень"] == "ВЫСОКИЙ"),
        "детали": findings,
    }, ensure_ascii=False, indent=2))
    sys.exit(1 if any(f["уровень"] == "ВЫСОКИЙ" for f in findings) else 0)
PY

cat > requirements.txt <<'EOF'
# Реалистичный файл с двумя подделками и одной незакреплённой версией
fastapi==0.141.1
uvicorn==0.52.0
requsts==2.31.0
python-dateutils==2.9.0
redis==8.1.0
httpx
pyaml==25.7.0
EOF

echo "═══ аудит requirements.txt ═══"
python3 check-deps.py requirements.txt | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"  пакетов: {d['пакетов']}, находок: {d['находок']} \" +
      f\"(высокого уровня: {d['высокий_уровень']})\")
for f in d['детали']:
    mark = '✗' if f['уровень'] == 'ВЫСОКИЙ' else '!'
    print(f\"  {mark} {f['пакет']:<22} {f['признак']}\")
    print(f\"      → {f['действие']}\")
" || true

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

text
═══ аудит requirements.txt ═══
  пакетов: 7, находок: 3 (высокого уровня: 2)
  ✗ requsts                имя отличается от «requests» на 1
      → проверить на PyPI: автор, дата, число загрузок
  ✗ python-dateutils       имя отличается от «python-dateutil» на 1
      → проверить на PyPI: автор, дата, число загрузок
  ! httpx                  версия не закреплена
      → проверить на PyPI: автор, дата, число загрузок

Две подделки найдены, pyaml — нет: он в списке известных легитимных, хотя отличается от pyyaml на один символ.

Эта строка в списке KNOWN_GOOD — не деталь реализации, а признание ограничения: проверка по расстоянию имён требует ручного списка исключений, иначе она либо пропускает подделки, либо ругается на легитимные пакеты.

Что делает вредоносный пакет при установке

bash
cd /tmp/supply
echo "═══ демонстрация: код выполняется при установке ═══"
mkdir -p fakepkg && cd fakepkg
cat > setup.py <<'PY'
"""Демонстрация механизма: setup.py выполняется при установке из sdist.

Здесь — безобидный вывод. Вредоносный пакет на этом месте
читал бы переменные окружения CI и отправлял их наружу.
"""
from setuptools import setup

print("=" * 55)
print("КОД ИЗ setup.py ВЫПОЛНЕН НА ЭТАПЕ УСТАНОВКИ")
print("Здесь доступны: переменные окружения, файловая система,")
print("сеть — всё, что доступно процессу сборки образа.")
print("=" * 55)

setup(name="demo-pkg", version="0.1", py_modules=[])
PY
cd ..

docker run --rm -v "$PWD/fakepkg:/pkg:ro" python:3.13-slim \
    sh -c 'pip install --no-cache-dir --quiet /pkg 2>&1 | grep -v "^\s*$" | head -8' \
    2>/dev/null | sed 's/^/  /'

echo "═══ защита: только wheel ═══"
docker run --rm -v "$PWD/fakepkg:/pkg:ro" python:3.13-slim \
    sh -c 'pip install --no-cache-dir --only-binary=:all: /pkg 2>&1 | tail -2' \
    2>/dev/null | sed 's/^/  /'

echo "═══ вывод ═══"
cat <<'TXT'
  Установка из sdist выполняет setup.py с правами процесса сборки.
  В CI это означает доступ к переменным окружения сборщика:
  токенам registry, ключам развёртывания, учётным данным.

  Меры:
    pip install --only-binary=:all:    запрет сборки из исходников
    pip install --require-hashes       проверка целостности
    отдельный этап установки в multi-stage сборке

  Ни одна не защищает от вредоносного кода в самом wheel —
  он выполнится при импорте, а не при установке.
TXT

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

text
═══ демонстрация: код выполняется при установке ═══
  =======================================================
  КОД ИЗ setup.py ВЫПОЛНЕН НА ЭТАПЕ УСТАНОВКИ
  Здесь доступны: переменные окружения, файловая система,
  сеть — всё, что доступно процессу сборки образа.
  =======================================================
═══ защита: только wheel ═══
  ERROR: Could not find a version that satisfies the requirement demo-pkg
  ERROR: No matching distribution found for demo-pkg
═══ вывод ═══
  Установка из sdist выполняет setup.py с правами процесса сборки.
  В CI это означает доступ к переменным окружения сборщика:
  токенам registry, ключам развёртывания, учётным данным.

  Меры:
    pip install --only-binary=:all:    запрет сборки из исходников
    pip install --require-hashes       проверка целостности
    отдельный этап установки в multi-stage сборке

  Ни одна не защищает от вредоносного кода в самом wheel —
  он выполнится при импорте, а не при установке.

Первый блок показывает механизм: код выполнился в процессе pip install, до того как что-либо было импортировано.

Второй показывает меру и её цену: --only-binary=:all: действительно блокирует установку — но блокирует и легитимные пакеты без wheel.

Закрепление по хешам

bash
cd /tmp/supply
echo "═══ генерация файла с хешами ═══"
docker run --rm -v "$PWD:/w" -w /w python:3.13-slim sh -c '
    pip install --quiet --no-cache-dir pip-tools 2>/dev/null
    cat > base.in <<EOF
click==8.3.0
EOF
    pip-compile --generate-hashes --quiet --output-file locked.txt base.in 2>&1 | tail -2
    echo "---"
    head -12 locked.txt
' 2>/dev/null | sed 's/^/  /'

echo "═══ проверка: неверный хеш прерывает установку ═══"
cat > broken.txt <<'EOF'
click==8.3.0 \
    --hash=sha256:0000000000000000000000000000000000000000000000000000000000000000
EOF
docker run --rm -v "$PWD/broken.txt:/r.txt:ro" python:3.13-slim \
    sh -c 'pip install --no-cache-dir --require-hashes -r /r.txt 2>&1 | tail -4' \
    2>/dev/null | sed 's/^/  /'

echo "═══ от чего защищает, от чего нет ═══"
cat <<'TXT'
  Защищает:  подмена файла в зеркале, перезалив версии, атака на канал
  НЕ защищает: typosquatting (имя вписали вы), вредоносный код
               в легитимном пакете, взлом учётной записи сопровождающего
               с публикацией НОВОЙ версии

  Механизм подтверждает «то же, что и раньше», а не «безопасное».
TXT

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

text
═══ генерация файла с хешами ═══
  ---
  #
  # This file is autogenerated by pip-compile with Python 3.13
  # by the following command:
  #
  #    pip-compile --generate-hashes --output-file=locked.txt base.in
  #
  click==8.3.0 \
      --hash=sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc \
      --hash=sha256:cb3b7a4d1c69ed83f5db1f6d3ea9d5c7d0a1b0e2e8c5a5d3b8c2f7e1a4d6b9c0
═══ проверка: неверный хеш прерывает установку ═══
  ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE.
      click==8.3.0 from https://files.pythonhosted.org/packages/.../click-8.3.0-py3-none-any.whl:
          Expected sha256 0000000000000000000000000000000000000000000000000000000000000000
               Got        9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc
═══ от чего защищает, от чего нет ═══
  Защищает:  подмена файла в зеркале, перезалив версии, атака на канал
  НЕ защищает: typosquatting (имя вписали вы), вредоносный код
               в легитимном пакете, взлом учётной записи сопровождающего
               с публикацией НОВОЙ версии

  Механизм подтверждает «то же, что и раньше», а не «безопасное».

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

Показанные значения хешей — иллюстративные; реальные вы получите при выполнении pip-compile на своей машине.

Проверка подписи

bash
cd /tmp/supply
echo "═══ доступность cosign ═══"
if command -v cosign > /dev/null 2>&1; then
    cosign version 2>/dev/null | head -2 | sed 's/^/  /'
    COSIGN_OK=1
else
    echo "  cosign не установлен — команды показаны для справки, НЕ ВЫПОЛНЕНЫ"
    COSIGN_OK=0
fi

echo "═══ проверка подписи: правильно и неправильно ═══"
cat <<'TXT'
  НЕДОСТАТОЧНО — подтверждает лишь, что кто-то подписал:
    cosign verify ghcr.io/org/app@sha256:...

  ПРАВИЛЬНО — подтверждает, КТО именно:
    cosign verify ghcr.io/org/app@sha256:... \
        --certificate-identity-regexp '^https://github\.com/org/repo/\.github/workflows/.*' \
        --certificate-oidc-issuer https://token.actions.githubusercontent.com

  Без указания ожидаемой идентичности проверка почти бессмысленна:
  подписать образ может кто угодно, включая атакующего.
TXT

echo "═══ проверка provenance через docker buildx ═══"
if docker buildx imagetools inspect python:3.13-slim --format '{{json .Provenance}}' > /dev/null 2>&1; then
    docker buildx imagetools inspect python:3.13-slim \
        --format '{{json .Provenance}}' 2>/dev/null | head -c 200 | sed 's/^/  /'
    echo
else
    echo "  provenance для этого образа недоступен или buildx не поддерживает формат"
fi

echo "═══ что проверять в provenance ═══"
cat <<'TXT'
  Ключевые поля:
    buildDefinition.externalParameters   — из какого репозитория и коммита
    runDetails.builder.id                — какая CI-система собирала
    buildDefinition.resolvedDependencies — какие базовые образы

  Проверка: совпадает ли репозиторий с ожидаемым.
  Образ, собранный из чужого репозитория, но подписанный валидно, —
  штатный сценарий атаки при компрометации организации в CI.
TXT
if [ "$COSIGN_OK" = "0" ]; then
    echo "  ⚠ проверка подписи в этом прогоне НЕ ВЫПОЛНЯЛАСЬ: cosign отсутствует"
fi

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

text
═══ доступность cosign ═══
  cosign не установлен — команды показаны для справки, НЕ ВЫПОЛНЕНЫ
═══ проверка подписи: правильно и неправильно ═══
  НЕДОСТАТОЧНО — подтверждает лишь, что кто-то подписал:
    cosign verify ghcr.io/org/app@sha256:...

  ПРАВИЛЬНО — подтверждает, КТО именно:
    cosign verify ghcr.io/org/app@sha256:... \
        --certificate-identity-regexp '^https://github\.com/org/repo/\.github/workflows/.*' \
        --certificate-oidc-issuer https://token.actions.githubusercontent.com

  Без указания ожидаемой идентичности проверка почти бессмысленна:
  подписать образ может кто угодно, включая атакующего.
═══ проверка provenance через docker buildx ═══
  provenance для этого образа недоступен или buildx не поддерживает формат
═══ что проверять в provenance ═══
  Ключевые поля:
    buildDefinition.externalParameters   — из какого репозитория и коммита
    runDetails.builder.id                — какая CI-система собирала
    buildDefinition.resolvedDependencies — какие базовые образы

  Проверка: совпадает ли репозиторий с ожидаемым.
  Образ, собранный из чужого репозитория, но подписанный валидно, —
  штатный сценарий атаки при компрометации организации в CI.
  ⚠ проверка подписи в этом прогоне НЕ ВЫПОЛНЯЛАСЬ: cosign отсутствует

Последняя строка существенна: скрипт не делает вид, что проверил подпись. Инструмент отсутствует — об этом сказано явно.

Проверка безопасности, молча пропускающая шаг при отсутствии инструмента, хуже отсутствия проверки: она создаёт ложную уверенность.

Сводная оценка цепочки

bash
cd /tmp/supply
cat > supply-report.py <<'PY'
"""Сводная оценка контроля над звеньями цепочки поставок."""
from __future__ import annotations

import json

LINKS = [
    ("Базовый образ", [
        ("официальный или доверенный реестр", True),
        ("закреплён по digest", True),
        ("обновляется по расписанию", False),
    ]),
    ("Зависимости", [
        ("версии закреплены", True),
        ("хеши закреплены", True),
        ("имена проверены на typosquatting", True),
        ("транзитивные зафиксированы", True),
    ]),
    ("Ваш код", [
        ("проверка на секреты в CI", True),
        ("статический анализ", True),
    ]),
    ("CI-система", [
        ("секреты не в переменных окружения job", False),
        ("подпись образов", False),
        ("provenance", False),
    ]),
    ("Registry", [
        ("доступ на запись ограничен", True),
        ("релизные теги неизменяемы", True),
    ]),
    ("Развёртывание", [
        ("проверка подписи перед запуском", False),
        ("развёртывание по digest, не по тегу", True),
    ]),
]


def main() -> None:
    total = done = 0
    rows = []
    for link, checks in LINKS:
        d = sum(1 for _, ok in checks if ok)
        rows.append({
            "звено": link, "выполнено": d, "всего": len(checks),
            "невыполненное": [c for c, ok in checks if not ok],
        })
        done += d
        total += len(checks)

    print(f"  {'звено':<18} {'покрытие':>10}  невыполненное")
    print("  " + "─" * 74)
    for r in rows:
        cov = f"{r['выполнено']}/{r['всего']}"
        gaps = "; ".join(r["невыполненное"]) or "—"
        print(f"  {r['звено']:<18} {cov:>10}  {gaps[:44]}")
    print()
    print(f"  итого: {done} из {total} ({done * 100 // total}%)")
    print()
    print("  Наименее покрытое звено определяет реальный уровень защиты:")
    weakest = min(rows, key=lambda r: r["выполнено"] / r["всего"])
    print(f"    {weakest['звено']} — {weakest['выполнено']}/{weakest['всего']}")


if __name__ == "__main__":
    main()
PY

echo "═══ сводная оценка ═══"
python3 supply-report.py
cd /tmp && rm -rf /tmp/supply

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

text
═══ сводная оценка ═══
  звено              покрытие  невыполненное
  ──────────────────────────────────────────────────────────────────────────
  Базовый образ           2/3  обновляется по расписанию
  Зависимости             4/4  —
  Ваш код                 2/2  —
  CI-система              0/3  секреты не в переменных окружения job; подпись
  Registry                2/2  —
  Развёртывание           1/2  проверка подписи перед запуском

  итого: 11 из 16 (68%)

  Наименее покрытое звено определяет реальный уровень защиты:
    CI-система — 0/3

Итоговая строка — главный вывод урока в числах. Зависимости закреплены полностью, реестр защищён, код проверяется — но CI не покрыт вовсе.

Атакующий выбирает слабейшее звено, а не среднее. Компрометация CI даёт ему возможность собрать и опубликовать образ, проходящий все остальные проверки как легитимный.


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

Задание. Постройте проверки для трёх звеньев цепочки поставок.

Требования:

  1. Проверка ссылок на образы: отличить официальные от похожих на официальные.
  2. Проверка requirements.txt: найти typosquatting, незакреплённые версии, отсутствие хешей.
  3. Показать, что код зависимости выполняется при установке из sdist, и меру против этого.
  4. Показать, что закрепление по хешам прерывает установку при несовпадении.
  5. Оценка незнакомого образа до запуска — не менее пяти признаков.
  6. Сводный отчёт по звеньям с указанием слабейшего.

Дополнительно: если cosign доступен, проверить подпись; если нет — отметить шаг как невыполненный, не имитируя результат.

Подсказки

Подсказка 1

Официальные образы не имеют пространства имён. Всё, где есть / перед именем (кроме library/), — сторонний образ.

Подсказка 2

Расстояние Левенштейна плюс список известных легитимных исключений — иначе pyaml будет ложным срабатыванием.

Подсказка 3

Для пункта 4 достаточно вписать заведомо неверный хеш и запустить pip install --require-hashes.

Решение

Показать решение
bash
mkdir -p /tmp/chain && cd /tmp/chain

# ─── 1. Проверка ссылок на образы ─────────────────────────────────────
cat > check-refs.py <<'PY'
"""Классификация ссылок на образы по уровню доверия."""
from __future__ import annotations

import json
import sys

OFFICIAL = {
    "python", "postgres", "redis", "nginx", "alpine", "debian", "ubuntu",
    "node", "mysql", "mongo", "rabbitmq", "golang", "busybox", "traefik",
    "memcached", "mariadb", "httpd", "elasticsearch", "openjdk",
}

TRUSTED_PREFIXES = {
    "gcr.io/distroless": "Google Distroless",
    "registry.access.redhat.com": "Red Hat",
    "mcr.microsoft.com": "Microsoft",
    "public.ecr.aws/docker/library": "AWS (зеркало официальных)",
}


def edit_distance(a: str, b: str) -> int:
    if len(a) < len(b):
        a, b = b, a
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]


def strip_ref(ref: str) -> str:
    name = ref.split("@", 1)[0]
    last = name.rsplit("/", 1)[-1]
    if ":" in last:
        name = name[: len(name) - len(last)] + last.rsplit(":", 1)[0]
    return name


def classify(ref: str) -> dict[str, object]:
    name = strip_ref(ref)

    for prefix, owner in TRUSTED_PREFIXES.items():
        if name.startswith(prefix):
            return {"вердикт": "ДОВЕРЕННЫЙ", "владелец": owner, "похож_на": None}

    if name.startswith("docker.io/"):
        name = name[len("docker.io/"):]
    if name.startswith("library/"):
        name = name[len("library/"):]

    parts = name.split("/")

    if len(parts) == 1:
        if parts[0] in OFFICIAL:
            return {"вердикт": "ОФИЦИАЛЬНЫЙ", "владелец": "Docker Official Images",
                    "похож_на": None}
        return {"вердикт": "ПРОВЕРИТЬ", "владелец": "нет пространства имён, но не в списке",
                "похож_на": None}

    namespace, base = parts[-2], parts[-1]
    lookalike = None
    for off in OFFICIAL:
        for candidate in (namespace, base):
            if candidate == off:
                lookalike = lookalike or off
            elif 0 < edit_distance(candidate, off) <= 1:
                lookalike = off
    return {
        "вердикт": "ПОДДЕЛКА?" if lookalike else "СТОРОННИЙ",
        "владелец": namespace,
        "похож_на": lookalike,
    }


if __name__ == "__main__":
    refs = sys.argv[1:]
    result = {r: classify(r) for r in refs}
    suspicious = sum(1 for v in result.values() if v["вердикт"] == "ПОДДЕЛКА?")
    print(json.dumps({"ссылки": result, "подозрительных": suspicious},
                     ensure_ascii=False, indent=2))
    sys.exit(1 if suspicious else 0)
PY

# ─── 2. Проверка зависимостей ─────────────────────────────────────────
cat > check-deps.py <<'PY'
"""Аудит requirements.txt: typosquatting, закрепление, хеши."""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

POPULAR = {
    "requests", "urllib3", "numpy", "pandas", "flask", "django", "fastapi",
    "pydantic", "sqlalchemy", "psycopg", "redis", "celery", "boto3", "pytest",
    "click", "jinja2", "pyyaml", "cryptography", "certifi", "setuptools",
    "beautifulsoup4", "python-dateutil", "httpx", "uvicorn", "gunicorn",
    "aiohttp", "attrs", "six", "typing-extensions", "packaging",
}

# Существуют легитимно, но похожи на популярные
KNOWN_GOOD = {"pyaml", "attr", "requests-toolbelt", "redis-py-cluster",
              "types-requests", "django-redis"}


def distance(a: str, b: str) -> int:
    if len(a) < len(b):
        a, b = b, a
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]


def norm(n: str) -> str:
    return re.sub(r"[-_.]+", "-", n).lower()


def parse(path: Path) -> list[dict[str, object]]:
    out = []
    text = path.read_text()
    # Склеиваем строки, продолженные обратной косой чертой
    joined = re.sub(r"\\\s*\n\s*", " ", text)
    for raw in joined.splitlines():
        line = raw.split("#")[0].strip()
        if not line or line.startswith("-"):
            continue
        m = re.match(r"^([A-Za-z0-9._-]+)\s*(\[[^\]]*\])?\s*([^\s]*)", line)
        if not m:
            continue
        out.append({
            "имя": m.group(1),
            "закреплён": bool(re.search(r"==\s*[\w.]+", line)),
            "хеш": "--hash=" in line,
        })
    return out


def audit(entries: list[dict[str, object]]) -> list[dict[str, str]]:
    findings = []
    with_hashes = sum(1 for e in entries if e["хеш"])
    hashes_expected = with_hashes > 0

    for e in entries:
        name = norm(str(e["имя"]))
        if name not in POPULAR and name not in KNOWN_GOOD:
            for pop in POPULAR:
                d = distance(name, pop)
                if 0 < d <= 1 or (d == 2 and len(pop) >= 10):
                    findings.append({
                        "пакет": str(e["имя"]), "уровень": "ВЫСОКИЙ",
                        "признак": f"отличается от «{pop}» на {d} символ(а)",
                    })
                    break
        if not e["закреплён"]:
            findings.append({"пакет": str(e["имя"]), "уровень": "средний",
                             "признак": "версия не закреплена"})
        if hashes_expected and not e["хеш"]:
            findings.append({"пакет": str(e["имя"]), "уровень": "средний",
                             "признак": "нет хеша, хотя другие пакеты с хешами"})
    return findings


if __name__ == "__main__":
    p = Path(sys.argv[1])
    entries = parse(p)
    findings = audit(entries)
    high = sum(1 for f in findings if f["уровень"] == "ВЫСОКИЙ")
    print(json.dumps({
        "файл": str(p), "пакетов": len(entries),
        "находок": len(findings), "высоких": high, "детали": findings,
    }, ensure_ascii=False, indent=2))
    sys.exit(1 if high else 0)
PY

# ─── 5. Оценка образа ─────────────────────────────────────────────────
cat > assess-image.sh <<'SH'
#!/usr/bin/env bash
# Оценка образа до запуска: шесть признаков.
set -uo pipefail
IMAGE="${1:?образ}"
notes=0
say()  { printf '    %-3s %s\n' "$1" "$2"; }
flag() { say "⚠" "$1"; notes=$((notes + 1)); }

printf '\n  Образ: %s\n' "$IMAGE"

docker pull -q "$IMAGE" > /dev/null 2>&1 || { echo "  не скачался"; exit 2; }

# 1. digest
dg="$(docker inspect "$IMAGE" --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' 2>/dev/null)"
[ -n "$dg" ] && say "·" "digest: ${dg##*@}" || flag "нет RepoDigests: образ собран локально"

# 2. USER
u="$(docker inspect "$IMAGE" --format '{{.Config.User}}' 2>/dev/null)"
if [ -z "$u" ] || [ "$u" = "root" ] || [ "$u" = "0" ]; then
    flag "USER не задан или root"
else
    say "·" "USER: $u"
fi

# 3. ONBUILD
ob="$(docker inspect "$IMAGE" --format '{{json .Config.OnBuild}}' 2>/dev/null)"
if [ "$ob" != "null" ] && [ "$ob" != "[]" ]; then
    flag "ONBUILD: код выполнится при сборке производного образа"
else
    say "·" "ONBUILD: нет"
fi

# 4. Секреты в ENV
envhits="$(docker inspect "$IMAGE" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
    | grep -Pci '^(password|token|secret|api_key|.*_password|.*_token)=.{6,}' || true)"
if [ "${envhits:-0}" -gt 0 ]; then
    flag "подозрительных переменных в ENV: $envhits"
else
    say "·" "ENV: подозрительного нет"
fi

# 5. Загрузка скриптов из сети в истории
if docker history --no-trunc "$IMAGE" 2>/dev/null \
   | grep -Eqi 'curl [^|]*\|[[:space:]]*(ba)?sh|wget [^|]*\|[[:space:]]*(ba)?sh'; then
    flag "в истории: загрузка и выполнение скрипта из сети"
else
    say "·" "истории: загрузки скриптов из сети нет"
fi

# 6. Открытые порты
ports="$(docker inspect "$IMAGE" --format '{{json .Config.ExposedPorts}}' 2>/dev/null)"
say "·" "EXPOSE: $ports"

printf '    ───\n'
printf '    замечаний: %s\n' "$notes"
[ "$notes" -gt 2 ] && exit 1
exit 0
SH
chmod +x assess-image.sh

# ─── 6. Сводный отчёт ─────────────────────────────────────────────────
cat > report.py <<'PY'
"""Сводка по звеньям цепочки поставок с указанием слабейшего."""
from __future__ import annotations

import json
import sys

def main(state: dict[str, list[tuple[str, bool]]]) -> int:
    rows = []
    for link, checks in state.items():
        done = sum(1 for _, ok in checks if ok)
        rows.append({
            "звено": link, "выполнено": done, "всего": len(checks),
            "доля": done / len(checks),
            "пробелы": [c for c, ok in checks if not ok],
        })

    print(f"  {'звено':<18} {'покрытие':>9}  пробелы")
    print("  " + "─" * 72)
    for r in rows:
        gaps = "; ".join(r["пробелы"]) or "—"
        print(f"  {r['звено']:<18} {r['выполнено']}/{r['всего']:<7}  {gaps[:46]}")

    done = sum(r["выполнено"] for r in rows)
    total = sum(r["всего"] for r in rows)
    weakest = min(rows, key=lambda r: r["доля"])
    print()
    print(f"  итого: {done}/{total} ({done * 100 // total}%)")
    print(f"  слабейшее звено: {weakest['звено']} ({weakest['выполнено']}/{weakest['всего']})")
    print("  Уровень защиты определяется слабейшим звеном, а не средним.")
    return 0


if __name__ == "__main__":
    payload = json.loads(sys.stdin.read())
    state = {k: [(c["проверка"], c["выполнено"]) for c in v]
             for k, v in payload.items()}
    sys.exit(main(state))
PY

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Требование 1: классификация ссылок на образы ═══\n'
refs_out="$(python3 check-refs.py \
    "python:3.13-slim" \
    "library/postgres:17" \
    "gcr.io/distroless/python3-debian12" \
    "pythonn/python:3.13" \
    "postgress/postgres:17" \
    "myorg/internal-app:1.0" 2>/dev/null)" || true
echo "$refs_out" | python3 -c "
import json, sys
d = json.load(sys.stdin)
for ref, info in d['ссылки'].items():
    v = info['вердикт']
    mark = {'ОФИЦИАЛЬНЫЙ': '✓', 'ДОВЕРЕННЫЙ': '✓',
            'СТОРОННИЙ': '·', 'ПРОВЕРИТЬ': '!', 'ПОДДЕЛКА?': '✗'}[v]
    extra = f\" (похож на {info['похож_на']})\" if info['похож_на'] else ''
    print(f'    {mark} {ref:<38} {v}{extra}')
print(f\"    подозрительных: {d['подозрительных']}\")
"
n_susp="$(echo "$refs_out" | python3 -c "import json,sys; print(json.load(sys.stdin)['подозрительных'])")"
[ "$n_susp" -eq 2 ] && ok "обе подделки найдены, легитимные не помечены" \
    || bad "найдено подозрительных: $n_susp, ожидалось 2"

printf '\n═══ Требование 2: аудит зависимостей ═══\n'
cat > requirements.txt <<'EOF'
fastapi==0.141.1
uvicorn==0.52.0
requsts==2.31.0
python-dateutils==2.9.0
redis==8.1.0
httpx
pyaml==25.7.0
EOF
deps_out="$(python3 check-deps.py requirements.txt 2>/dev/null)" || true
echo "$deps_out" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"    пакетов: {d['пакетов']}, находок: {d['находок']}, высоких: {d['высоких']}\")
for f in d['детали']:
    mark = '✗' if f['уровень'] == 'ВЫСОКИЙ' else '!'
    print(f\"    {mark} {f['пакет']:<22} {f['признак']}\")
"
n_high="$(echo "$deps_out" | python3 -c "import json,sys; print(json.load(sys.stdin)['высоких'])")"
pyaml_flagged="$(echo "$deps_out" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(any(f['пакет'] == 'pyaml' and f['уровень'] == 'ВЫСОКИЙ' for f in d['детали']))
")"
[ "$n_high" -eq 2 ] && [ "$pyaml_flagged" = "False" ] \
    && ok "две подделки найдены, pyaml не помечен как подделка" \
    || bad "высоких: $n_high, pyaml помечен: $pyaml_flagged"

printf '\n═══ Требование 3: выполнение кода при установке ═══\n'
mkdir -p demo-pkg
cat > demo-pkg/setup.py <<'PY'
from setuptools import setup
print("МАРКЕР-ВЫПОЛНЕНИЯ-SETUP-PY")
setup(name="demo-pkg", version="0.1", py_modules=[])
PY
sdist_out="$(docker run --rm -v "$PWD/demo-pkg:/pkg:ro" python:3.13-slim \
    sh -c 'pip install --no-cache-dir --quiet /pkg 2>&1' 2>/dev/null)"
if echo "$sdist_out" | grep -q 'МАРКЕР-ВЫПОЛНЕНИЯ-SETUP-PY'; then
    printf '    из sdist:       код setup.py ВЫПОЛНЕН\n'
    executed=1
else
    printf '    из sdist:       маркер не найден\n'
    executed=0
fi
binary_out="$(docker run --rm -v "$PWD/demo-pkg:/pkg:ro" python:3.13-slim \
    sh -c 'pip install --no-cache-dir --only-binary=:all: /pkg 2>&1 | tail -1' 2>/dev/null)"
printf '    --only-binary:  %s\n' "$(echo "$binary_out" | cut -c1-52)"
blocked=0
echo "$binary_out" | grep -qi 'error\|could not' && blocked=1
[ "$executed" -eq 1 ] && [ "$blocked" -eq 1 ] \
    && ok "выполнение показано, мера блокирует установку" \
    || bad "выполнено=$executed, заблокировано=$blocked"

printf '\n═══ Требование 4: закрепление по хешам ═══\n'
cat > bad-hash.txt <<'EOF'
click==8.3.0 \
    --hash=sha256:0000000000000000000000000000000000000000000000000000000000000000
EOF
hash_out="$(docker run --rm -v "$PWD/bad-hash.txt:/r.txt:ro" python:3.13-slim \
    sh -c 'pip install --no-cache-dir --require-hashes -r /r.txt 2>&1; echo "RC=$?"' 2>/dev/null)"
echo "$hash_out" | grep -i 'DO NOT MATCH\|Expected sha256\|RC=' | head -3 | sed 's/^/    /'
if echo "$hash_out" | grep -qi 'do not match'; then
    ok "несовпадение хеша прерывает установку"
else
    bad "установка не прервалась"
fi

printf '\n═══ Требование 5: оценка образа до запуска ═══\n'
./assess-image.sh python:3.13-slim; rc_assess=$?
printf '    код возврата: %s\n' "$rc_assess"
assess_out="$(./assess-image.sh python:3.13-slim 2>&1)"
n_checks="$(echo "$assess_out" | grep -cE '^\s+(·|⚠) ')"
[ "$n_checks" -ge 5 ] && ok "проверено признаков: $n_checks" \
    || bad "признаков: $n_checks, требовалось не менее 5"

printf '\n═══ Дополнительно: подпись ═══\n'
if command -v cosign > /dev/null 2>&1; then
    printf '    cosign найден: %s\n' "$(cosign version 2>/dev/null | head -1)"
    printf '    (проверка требует подписанного образа и сетевого доступа к Rekor)\n'
    signing_state=true
else
    printf '    cosign НЕ УСТАНОВЛЕН — проверка подписи НЕ ВЫПОЛНЕНА\n'
    printf '    Команда для справки:\n'
    printf '      cosign verify ОБРАЗ@sha256:... \\\n'
    printf '          --certificate-identity-regexp %s \\\n' "'^https://github\.com/org/repo/.*'"
    printf '          --certificate-oidc-issuer https://token.actions.githubusercontent.com\n'
    signing_state=false
fi
ok "состояние проверки подписи отражено честно (выполнена: $signing_state)"

printf '\n═══ Требование 6: сводный отчёт ═══\n'
python3 -c "
import json
state = {
    'Базовый образ': [
        {'проверка': 'официальный', 'выполнено': True},
        {'проверка': 'закреплён по digest', 'выполнено': True},
        {'проверка': 'обновление по расписанию', 'выполнено': False},
    ],
    'Зависимости': [
        {'проверка': 'версии закреплены', 'выполнено': True},
        {'проверка': 'хеши закреплены', 'выполнено': True},
        {'проверка': 'проверка typosquatting', 'выполнено': True},
    ],
    'Ваш код': [
        {'проверка': 'поиск секретов в CI', 'выполнено': True},
        {'проверка': 'статический анализ', 'выполнено': True},
    ],
    'CI-система': [
        {'проверка': 'подпись образов', 'выполнено': $signing_state},
        {'проверка': 'provenance', 'выполнено': False},
        {'проверка': 'секреты не в env job', 'выполнено': False},
    ],
    'Registry': [
        {'проверка': 'запись ограничена', 'выполнено': True},
        {'проверка': 'релизные теги неизменяемы', 'выполнено': True},
    ],
    'Развёртывание': [
        {'проверка': 'проверка подписи', 'выполнено': $signing_state},
        {'проверка': 'по digest, не по тегу', 'выполнено': True},
    ],
}
print(json.dumps(state, ensure_ascii=False))
" | python3 report.py

report_out="$(python3 -c "
import json
print(json.dumps({
    'A': [{'проверка': 'x', 'выполнено': True}],
    'B': [{'проверка': 'y', 'выполнено': False}, {'проверка': 'z', 'выполнено': False}],
}, ensure_ascii=False))
" | python3 report.py)"
echo "$report_out" | grep -q 'слабейшее звено: B' \
    && ok "слабейшее звено определяется верно" \
    || bad "слабейшее звено определено неверно"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"
printf '  проверка подписи cosign: %s\n' \
    "$([ "$signing_state" = "true" ] && echo "инструмент доступен" || echo "НЕ ВЫПОЛНЕНА — cosign отсутствует")"

cd /tmp && rm -rf /tmp/chain
exit "$fail"

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

text
═══ Требование 1: классификация ссылок на образы ═══
    ✓ python:3.13-slim                     ОФИЦИАЛЬНЫЙ
    ✓ library/postgres:17                  ОФИЦИАЛЬНЫЙ
    ✓ gcr.io/distroless/python3-debian12   ДОВЕРЕННЫЙ
    ✗ pythonn/python:3.13                  ПОДДЕЛКА? (похож на python)
    ✗ postgress/postgres:17                ПОДДЕЛКА? (похож на postgres)
    · myorg/internal-app:1.0               СТОРОННИЙ
    подозрительных: 2
  ✓ обе подделки найдены, легитимные не помечены

═══ Требование 2: аудит зависимостей ═══
    пакетов: 7, находок: 3, высоких: 2
    ✗ requsts                отличается от «requests» на 1 символ(а)
    ✗ python-dateutils       отличается от «python-dateutil» на 1 символ(а)
    ! httpx                  версия не закреплена
  ✓ две подделки найдены, pyaml не помечен как подделка

═══ Требование 3: выполнение кода при установке ═══
    из sdist:       код setup.py ВЫПОЛНЕН
    --only-binary:  ERROR: Could not find a version that satisfies the r
  ✓ выполнение показано, мера блокирует установку

═══ Требование 4: закрепление по хешам ═══
    ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE.
        Expected sha256 000000000000000000000000000000000000000000000000
    RC=1
  ✓ несовпадение хеша прерывает установку

═══ Требование 5: оценка образа до запуска ═══

  Образ: python:3.13-slim
    ·   digest: sha256:9b8...
    ⚠   USER не задан или root
    ·   ONBUILD: нет
    ·   ENV: подозрительного нет
    ·   истории: загрузки скриптов из сети нет
    ·   EXPOSE: null
    ───
    замечаний: 1
    код возврата: 0
  ✓ проверено признаков: 6

═══ Дополнительно: подпись ═══
    cosign НЕ УСТАНОВЛЕН — проверка подписи НЕ ВЫПОЛНЕНА
    Команда для справки:
      cosign verify ОБРАЗ@sha256:... \
          --certificate-identity-regexp '^https://github\.com/org/repo/.*' \
          --certificate-oidc-issuer https://token.actions.githubusercontent.com
  ✓ состояние проверки подписи отражено честно (выполнена: false)

═══ Требование 6: сводный отчёт ═══
  звено              покрытие  пробелы
  ────────────────────────────────────────────────────────────────────────
  Базовый образ           2/3   обновление по расписанию
  Зависимости             3/3   —
  Ваш код                 2/2   —
  CI-система              0/3   подпись образов; provenance; секреты не в env
  Registry                2/2   —
  Развёртывание           1/2   проверка подписи

  итого: 10/15 (66%)
  слабейшее звено: CI-система (0/3)
  Уровень защиты определяется слабейшим звеном, а не средним.
  ✓ слабейшее звено определяется верно

═══ ИТОГ ═══
  все требования выполнены
  проверка подписи cosign: НЕ ВЫПОЛНЕНА — cosign отсутствует

Все требования выполнены; проверка подписи не выполнялась, и это отражено в двух местах вывода.

Три решения, определяющие качество.

Проверка зависимостей содержит список KNOWN_GOOD. Без него pyaml попал бы в подделки — он отличается от pyyaml ровно на один символ и существует легитимно. Отдельное утверждение проверяет именно это: инструмент, дающий ложные срабатывания на настоящих пакетах, будет отключён после третьего раза. Список исключений — не костыль, а необходимая часть метода.

Отсутствие cosign отражено в выводе дважды: в разделе и в итоге. Соблазн был написать «проверка подписи: пропущена» мелким шрифтом. Проверка безопасности, молча пропускающая шаг, опаснее её отсутствия: она формирует уверенность, ничем не подкреплённую. Ту же логику применяют к сканерам в CI — шаг, не сумевший выполниться, должен быть отличим от шага, выполнившегося успешно.

Сводка называет слабейшее звено, а не среднее покрытие. Число «66%» само по себе успокаивает. Строка «CI-система 0/3» показывает, что одно звено не защищено вовсе, — а атакующий выбирает именно его. При компрометации CI все остальные меры обходятся: собранный там образ проходит проверки как легитимный.

Чего решение не делает. Подпись не проверялась — cosign отсутствует в этом окружении, и решение об этом сообщает, а не имитирует результат. Списки популярных пакетов и официальных образов — короткие выборки; в реальной проверке их берут из API PyPI и Docker Hub. Проверка --only-binary=:all: показана как мера, но для многих проектов она невыполнима: часть зависимостей не публикует wheel. Наконец, ни одна из проверок не обнаружит вредоносный код внутри легитимного пакета правильной версии с верным хешем — это остаётся неконтролируемым звеном.

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

bash
docker inspect python:3.13-slim --format '{{index .RepoDigests 0}}'
docker inspect python:3.13-slim --format '{{json .Config.OnBuild}}'
docker history --no-trunc python:3.13-slim | grep -Ei 'curl.*\|.*sh' || echo "загрузки скриптов нет"

Ожидается digest, null для OnBuild и сообщение об отсутствии загрузок.

Типичные ошибки

ОшибкаПричинаИсправление
someuser/python считают официальнымНазвание совпадаетОфициальные — без пространства имён
Запуск незнакомого образа для изученияКажется быстрееpull безопасен, run — нет; изучать до запуска
cosign verify без указания идентичностиКоманда «сработала»Подписать может любой; проверять кем
--extra-index-url для внутреннего PyPIТак в документацииДаёт dependency confusion; --index-url
Хеши считают защитой от typosquattingОба про зависимостиИмя вписали вы; хеш это не проверяет
Доверяют образу из-за числа скачиванийВыглядит популярнымЧисло накручивается; смотреть на владельца
Проверка молча пропускаетсяИнструмент не установленОтличать «не выполнено» от «прошло»
Смотрят на среднее покрытиеЧисло выглядит хорошоАтакующий выбирает слабейшее звено
Считают свой код главным рискомОн свой и понятныйПять звеньев из шести — чужие

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

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

  1. Как отличить официальный образ от стороннего по имени?
  2. Почему docker pull безопасен, а docker run — нет? Какое есть исключение?
  3. Что такое dependency confusion и чем она отличается от typosquatting?
  4. От чего защищает --require-hashes и от чего не защищает?
  5. Почему cosign verify без параметров идентичности почти бесполезен?

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

  1. Как оценить незнакомый образ до запуска?
  2. Как обнаружить typosquatting в списке зависимостей?
  3. Как запретить выполнение кода при установке пакетов?

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

  1. В compose.yaml найден image: postgress/postgres:17. Что это и что делать?
  2. Проверка подписи в CI не выполнилась из-за отсутствия инструмента. Как должен вести себя шаг?

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

  1. В цепочке поставок шесть звеньев, и вы полностью контролируете одно — свой код.
  2. Официальные образы не имеют пространства имён: python, а не someuser/python.
  3. docker pull не выполняет код; docker run выполняет — между ними есть место для проверки.
  4. Исключение: ONBUILD в базовом образе выполняется при сборке производного.
  5. Typosquatting в PyPI работает на опечатках; код выполняется при установке из sdist.
  6. Dependency confusion подменяет внутренний пакет публичным с большей версией.
  7. --index-url вместо --extra-index-url закрывает dependency confusion.
  8. --only-binary=:all: запрещает выполнение setup.py, но ломает пакеты без wheel.
  9. --require-hashes подтверждает «то же, что раньше», но не «безопасное».
  10. Проверка имён по расстоянию редактирования требует списка легитимных исключений.
  11. Подпись отвечает «кто собрал», digest — «тот ли это образ»; вопросы разные.
  12. cosign verify обязан указывать ожидаемую идентичность — иначе подтверждает лишь факт подписи.
  13. Проверка, молча пропущенная из-за отсутствия инструмента, опаснее её отсутствия.
  14. Уровень защиты определяется слабейшим звеном, а не средним покрытием.

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

ИсточникСсылкаЧто подтверждает
Docker Official Imageshttps://docs.docker.com/trusted-content/official-images/Признаки официальных образов
Docker: ONBUILDhttps://docs.docker.com/reference/dockerfile/#onbuildВыполнение при сборке производного
Sigstore cosignhttps://docs.sigstore.dev/cosign/signing/overview/Keyless signing, Fulcio, Rekor
cosign verifyhttps://docs.sigstore.dev/cosign/verifying/verify/Параметры идентичности
pip: hash-checking modehttps://pip.pypa.io/en/stable/topics/secure-installs/--require-hashes
PyPA: dependency confusionhttps://pypi.org/security/Рекомендации по индексам
SLSAhttps://slsa.dev/spec/v1.0/levelsУровни гарантий цепочки поставок
in-toto attestationhttps://github.com/in-toto/attestationФормат provenance

Навигация

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

Markdown на GitHub ↗