12.7. Supply chain security
Цели
После этого материала вы сможете:
- оценить незнакомый образ до его запуска и обосновать решение о доверии;
- отличить официальный образ от похожего на официальный;
- обнаружить typosquatting в списке зависимостей Python;
- закрепить зависимости по хешам и объяснить, от чего это защищает;
- проверить подпись образа и его происхождение;
- назвать, какие звенья цепочки поставок вы не контролируете.
Предварительные знания
- 11.6. Supply chain — digest pinning, сканирование, SBOM, provenance;
- 12.1. Модель угроз;
- 6.3. Зависимости — установка пакетов Python.
Урок 11.6 разбирает гигиену: закрепление версий, приоритизацию находок, откат. Этот урок разбирает доверие: как решить, что артефакту можно верить, и как это проверить.
Ключевые термины
| Термин | Объяснение |
|---|---|
typosquatting | Пакет с именем, похожим на популярный |
dependency confusion | Подмена внутреннего пакета одноимённым публичным |
подпись | Криптографическое подтверждение авторства артефакта |
keyless signing | Подпись без долговременного ключа, через OIDC |
attestation | Заверенное утверждение о происхождении артефакта |
Rekor | Публичный журнал прозрачности подписей |
Теория
Что входит в цепочку поставок образа
базовый образ ─┐
├─→ сборка ─→ образ ─→ 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, только для образов.
Оценка незнакомого образа
Порядок проверки до запуска:
| Шаг | Команда | На что смотреть |
|---|---|---|
| 1 | docker manifest inspect | Существует ли, для каких платформ |
| 2 | docker pull + docker history | Из чего собран, нет ли странных RUN |
| 3 | docker inspect | Entrypoint, Cmd, User, Env |
| 4 | docker save + распаковка | Что в слоях, нет ли лишнего |
| 5 | Сканер | Известные уязвимости |
| 6 | Подпись | Кто и чем подписал |
Ключевой принцип: docker pull безопасен, docker run — нет. Скачивание образа не выполняет его код; запуск выполняет. Между этими двумя шагами есть место для проверки, и им стоит пользоваться.
Единственное исключение — docker build: инструкции RUN базового образа при сборке не выполняются, но ONBUILD-инструкции выполняются. Образ с ONBUILD может выполнить код при сборке производного образа.
Typosquatting в PyPI
Атака проста: злоумышленник публикует пакет с именем, отличающимся от популярного на один символ, и ждёт опечаток.
| Настоящий | Типичные подделки |
|---|---|
requests | request, requsts, requestss |
python-dateutil | python-dateutils, dateutil |
beautifulsoup4 | beautifulsoup, beautifulsoup44 |
urllib3 | urllib, urllib4 |
pyyaml | pyaml (существует легитимно — усложняет проверку) |
Последняя строка показывает, почему автоматическая проверка сложна: похожее имя не всегда означает подделку.
Вредоносный пакет выполняет код на этапе установки — в setup.py или через build hook. Это происходит во время сборки образа, то есть в вашем CI, с доступом к его окружению.
Связанная атака — dependency confusion: если сборка настроена на два индекса (публичный PyPI и внутренний), а злоумышленник публикует в PyPI пакет с именем вашего внутреннего, установщик может выбрать публичный — по более высокой версии.
Защита: --index-url вместо --extra-index-url для внутреннего индекса, закрепление по хешам.
Закрепление по хешам
requests==2.32.4 \
--hash=sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c
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.
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
- CI-система получает OIDC-токен, подтверждающий её идентичность (репозиторий, workflow, ветка).
- Cosign обменивает токен на короткоживущий сертификат в удостоверяющем центре Fulcio.
- Артефакт подписывается закрытым ключом, существующим только в памяти.
- Подпись и сертификат записываются в журнал Rekor.
- Ключ уничтожается.
Долговременного ключа не существует — красть нечего. Проверка обращается к Rekor и подтверждает, что подпись сделана сертификатом с ожидаемой идентичностью в момент, когда сертификат был действителен.
Команды и примеры
Официальный образ и похожий на официальный
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['похож_на']}\")
"
Ожидаемый вывод:
═══ классификация ссылок на образы ═══
✓ 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 при беглом чтении.
Оценка незнакомого образа до запуска
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
Ожидаемый вывод:
═══ оценка официального образа ═══
Оценка образа: 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 в зависимостях
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
Ожидаемый вывод:
═══ аудит requirements.txt ═══
пакетов: 7, находок: 3 (высокого уровня: 2)
✗ requsts имя отличается от «requests» на 1
→ проверить на PyPI: автор, дата, число загрузок
✗ python-dateutils имя отличается от «python-dateutil» на 1
→ проверить на PyPI: автор, дата, число загрузок
! httpx версия не закреплена
→ проверить на PyPI: автор, дата, число загрузок
Две подделки найдены, pyaml — нет: он в списке известных легитимных, хотя отличается от pyyaml на один символ.
Эта строка в списке KNOWN_GOOD — не деталь реализации, а признание ограничения: проверка по расстоянию имён требует ручного списка исключений, иначе она либо пропускает подделки, либо ругается на легитимные пакеты.
Что делает вредоносный пакет при установке
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
Ожидаемый вывод:
═══ демонстрация: код выполняется при установке ═══
=======================================================
КОД ИЗ 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.
Закрепление по хешам
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
Ожидаемый вывод:
═══ генерация файла с хешами ═══
---
#
# 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 на своей машине.
Проверка подписи
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
Ожидаемый вывод:
═══ доступность 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 отсутствует
Последняя строка существенна: скрипт не делает вид, что проверил подпись. Инструмент отсутствует — об этом сказано явно.
Проверка безопасности, молча пропускающая шаг при отсутствии инструмента, хуже отсутствия проверки: она создаёт ложную уверенность.
Сводная оценка цепочки
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
Ожидаемый вывод:
═══ сводная оценка ═══
звено покрытие невыполненное
──────────────────────────────────────────────────────────────────────────
Базовый образ 2/3 обновляется по расписанию
Зависимости 4/4 —
Ваш код 2/2 —
CI-система 0/3 секреты не в переменных окружения job; подпись
Registry 2/2 —
Развёртывание 1/2 проверка подписи перед запуском
итого: 11 из 16 (68%)
Наименее покрытое звено определяет реальный уровень защиты:
CI-система — 0/3
Итоговая строка — главный вывод урока в числах. Зависимости закреплены полностью, реестр защищён, код проверяется — но CI не покрыт вовсе.
Атакующий выбирает слабейшее звено, а не среднее. Компрометация CI даёт ему возможность собрать и опубликовать образ, проходящий все остальные проверки как легитимный.
Практическое упражнение
Задание. Постройте проверки для трёх звеньев цепочки поставок.
Требования:
- Проверка ссылок на образы: отличить официальные от похожих на официальные.
- Проверка
requirements.txt: найти typosquatting, незакреплённые версии, отсутствие хешей. - Показать, что код зависимости выполняется при установке из sdist, и меру против этого.
- Показать, что закрепление по хешам прерывает установку при несовпадении.
- Оценка незнакомого образа до запуска — не менее пяти признаков.
- Сводный отчёт по звеньям с указанием слабейшего.
Дополнительно: если cosign доступен, проверить подпись; если нет — отметить шаг как невыполненный, не имитируя результат.
Подсказки
Подсказка 1
Официальные образы не имеют пространства имён. Всё, где есть / перед именем (кроме library/), — сторонний образ.
Подсказка 2
Расстояние Левенштейна плюс список известных легитимных исключений — иначе pyaml будет ложным срабатыванием.
Подсказка 3
Для пункта 4 достаточно вписать заведомо неверный хеш и запустить pip install --require-hashes.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Требование 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. Наконец, ни одна из проверок не обнаружит вредоносный код внутри легитимного пакета правильной версии с верным хешем — это остаётся неконтролируемым звеном.
Проверка результата
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 | Оба про зависимости | Имя вписали вы; хеш это не проверяет |
| Доверяют образу из-за числа скачиваний | Выглядит популярным | Число накручивается; смотреть на владельца |
| Проверка молча пропускается | Инструмент не установлен | Отличать «не выполнено» от «прошло» |
| Смотрят на среднее покрытие | Число выглядит хорошо | Атакующий выбирает слабейшее звено |
| Считают свой код главным риском | Он свой и понятный | Пять звеньев из шести — чужие |
Контрольные вопросы
На понимание:
- Как отличить официальный образ от стороннего по имени?
- Почему
docker pullбезопасен, аdocker run— нет? Какое есть исключение? - Что такое dependency confusion и чем она отличается от typosquatting?
- От чего защищает
--require-hashesи от чего не защищает? - Почему
cosign verifyбез параметров идентичности почти бесполезен?
На применение:
- Как оценить незнакомый образ до запуска?
- Как обнаружить typosquatting в списке зависимостей?
- Как запретить выполнение кода при установке пакетов?
На диагностику:
- В
compose.yamlнайденimage: postgress/postgres:17. Что это и что делать? - Проверка подписи в CI не выполнилась из-за отсутствия инструмента. Как должен вести себя шаг?
Краткое резюме
- В цепочке поставок шесть звеньев, и вы полностью контролируете одно — свой код.
- Официальные образы не имеют пространства имён:
python, а неsomeuser/python. docker pullне выполняет код;docker runвыполняет — между ними есть место для проверки.- Исключение:
ONBUILDв базовом образе выполняется при сборке производного. - Typosquatting в PyPI работает на опечатках; код выполняется при установке из sdist.
- Dependency confusion подменяет внутренний пакет публичным с большей версией.
--index-urlвместо--extra-index-urlзакрывает dependency confusion.--only-binary=:all:запрещает выполнениеsetup.py, но ломает пакеты без wheel.--require-hashesподтверждает «то же, что раньше», но не «безопасное».- Проверка имён по расстоянию редактирования требует списка легитимных исключений.
- Подпись отвечает «кто собрал», digest — «тот ли это образ»; вопросы разные.
cosign verifyобязан указывать ожидаемую идентичность — иначе подтверждает лишь факт подписи.- Проверка, молча пропущенная из-за отсутствия инструмента, опаснее её отсутствия.
- Уровень защиты определяется слабейшим звеном, а не средним покрытием.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker Official Images | https://docs.docker.com/trusted-content/official-images/ | Признаки официальных образов |
Docker: ONBUILD | https://docs.docker.com/reference/dockerfile/#onbuild | Выполнение при сборке производного |
| Sigstore cosign | https://docs.sigstore.dev/cosign/signing/overview/ | Keyless signing, Fulcio, Rekor |
| cosign verify | https://docs.sigstore.dev/cosign/verifying/verify/ | Параметры идентичности |
| pip: hash-checking mode | https://pip.pypa.io/en/stable/topics/secure-installs/ | --require-hashes |
| PyPA: dependency confusion | https://pypi.org/security/ | Рекомендации по индексам |
| SLSA | https://slsa.dev/spec/v1.0/levels | Уровни гарантий цепочки поставок |
| in-toto attestation | https://github.com/in-toto/attestation | Формат provenance |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление