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

12.1. Модель угроз

Цели

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

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

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

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

ТерминОбъяснение
граница доверияРубеж, при пересечении которого меняется уровень доверия к данным или коду
активТо, что имеет ценность и подлежит защите
векторПуть, которым угроза может реализоваться
поверхность атакиСовокупность точек, доступных для воздействия
container escapeВыход из изоляции container на host
защита в глубинуНесколько независимых рубежей

Теория

Container — это процесс

Ключевое утверждение раздела:

Container — обычный процесс host-системы, ограниченный namespaces, cgroups и capabilities. Все они реализованы в том же ядре, что и всё остальное.

Сравнение с виртуальной машиной:

ContainerВиртуальная машина
ЯдроОбщее с hostСвоё
Граница изоляцииМеханизмы ядраГипервизор
Поверхность атакиВсе системные вызовы ядраИнтерфейс гипервизора
Уязвимость в ядреЗатрагивает host и все container'ыОграничена гостем
Накладные расходыМинимальныеЗаметные
Время запускаМиллисекундыСекунды

Средняя строка объясняет всё остальное: приложение в container'е обращается к тому же ядру, что и host. Уязвимость в обработчике системного вызова доступна изнутри container'а.

Отсюда официальная формулировка Docker: container'ы не следует считать достаточной границей для запуска недоверенного кода без дополнительных мер.

Когда изоляции достаточно, а когда нет

СценарийДостаточно ли container'а
Ваш код, ваша инфраструктураДа, при базовом hardening
Код команды, общий кластерДа, при hardening и политиках
Открытый код из репозиторияУсловно: нужен hardening плюс сканирование
Код пользователей платформыНет — нужны gVisor, Kata или VM
Заведомо недоверенный кодНет
Многопользовательская среда с враждебными арендаторамиНет

Три нижние строки — область, где применяют изоляцию уровня ядра-песочницы (gVisor), лёгкие виртуальные машины (Kata Containers, Firecracker) или обычные VM.

Для типичного случая — вы собираете и запускаете свой сервис — контейнеризации достаточно, и раздел посвящён именно ему.

Что контейнеризация защищает

ЗащищаетКак
Файловую систему host от случайной записиСвоя mount namespace
Процессы host от вмешательстваСвоя PID namespace
Сеть host от привязки к портамСвоя network namespace
Ресурсы узла от исчерпанияcgroups
Другие container'ы от прямого доступаСовокупность namespaces

Что не защищает

Не защищаетПочему
От уязвимости в приложенииSQL-инъекция работает от любого UID
От уязвимости в ядреЯдро общее
От избыточных привилегийВы сами их выдали
От доступа к Docker socketЭто доступ к демону, а тот — root
От вредоносного базового образаОн часть вашего образа
От утечки данных через API приложенияПриложение имеет доступ по определению
От компрометации при сборкеСборка происходит до изоляции

Строка про socket — важнейшая практически (урок 12.2).

Процедура построения модели угроз

Модель угроз — не документ на сто страниц, а таблица на страницу, отвечающая на четыре вопроса.

ШагВопрос
1Что защищаем? (активы)
2От кого? (нарушители)
3Как могут добраться? (векторы)
4Что делаем? (меры и остаточный риск)

Шаг 1: активы. Не «сервис», а конкретные вещи с ценностью:

АктивПочему ценен
Персональные данные пользователейРегуляторные требования, репутация
Учётные данные к базеДоступ ко всем данным
Исходный кодИнтеллектуальная собственность
Вычислительные ресурсыМайнинг, рассылка
Доверие к доменуФишинг от вашего имени
Доступ к внутренней сетиПлацдарм для дальнейшего продвижения

Два последних забывают чаще прочих — а именно они делают взлом второстепенного сервиса началом большой проблемы.

Шаг 2: нарушители. Реалистичные, а не гипотетические:

НарушительВозможностиМотив
Внешний, без доступаЗапросы к публичному APIДанные, ресурсы
Пользователь сервисаАутентифицированные запросыЧужие данные, повышение прав
Скомпрометированная зависимостьВыполнение кода при сборке или работеЧто угодно
Сотрудник с доступом к CIИзменение образаЗависит от роли
Сосед по узлуДругой containerПобег и продвижение

Шаг 3: векторы. Как нарушитель добирается до актива:

text
внешний ──► уязвимость приложения ──► выполнение кода в container
                                            │
                                            ├──► данные в базе (актив)
                                            ├──► секреты в окружении (актив)
                                            ├──► сеть: соседние сервисы
                                            └──► попытка побега на host

Шаг 4: меры и остаточный риск. Для каждого вектора — что делаем и что остаётся:

ВекторМераОстаточный риск
Выполнение кода в containernon-root, read-only, cap-drop=ALLДанные, доступные приложению, скомпрометированы
Чтение секретов из окруженияSecrets файламиФайл читается тем же процессом
Продвижение по сетиСегментация сетейДоступное приложению остаётся доступным
Побег на hostseccomp, отказ от --privilegedУязвимость ядра остаётся
Вредоносная зависимостьФиксация версий, сканированиеАтака на цепочку до публикации

Колонка «остаточный риск» — самая полезная. Она не даёт создать иллюзию, что меры устраняют угрозу полностью.

Иерархия векторов по частоте

По данным разборов инцидентов порядок примерно такой:

МестоВекторКомментарий
1Уязвимость в приложенииSQL-инъекция, десериализация, SSRF
2Утёкшие учётные данныеВ коде, в образе, в логах
3Уязвимая зависимостьИзвестная CVE без обновления
4Ошибка конфигурацииОткрытый порт, отсутствие аутентификации
5Избыточные привилегии--privileged, socket, root
6Побег через уязвимость ядраРедко, требует высокой квалификации

Практический вывод: hardening container'а закрывает пункты 5 и частично 6, но не трогает 1–4. Это не повод его не делать — это повод не считать его достаточным.

Границы доверия

Граница — там, где меняется уровень доверия:

text
   интернет
      │  ◄── граница 1: недоверенный ввод
   reverse proxy
      │
   ┌──┴──────────── граница 2: сеть приложения ────────┐
   │  api ──► worker                                    │
   │   │        │                                       │
   │   └────────┴──► граница 3: сеть данных (internal)  │
   │                    db, cache                       │
   └────────────────────────────────────────────────────┘
ГраницаЧто проверяется при пересечении
1. Интернет → proxyTLS, ограничение частоты, размер тела
2. Proxy → приложениеАутентификация, авторизация, валидация
3. Приложение → данныеПараметризованные запросы, минимальные права в БД

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

Соразмерность мер

Меры имеют цену: время, сложность, вероятность ошибки. Модель угроз нужна, чтобы тратить усилия там, где они дают эффект.

МераЦенаЭффектКогда оправдана
non-rootНизкаяВысокийВсегда
cap-drop=ALLНизкаяВысокийВсегда
read-only кореньСредняяВысокийПочти всегда
Сегментация сетейСредняяВысокийПри нескольких сервисах
Свой профиль seccompВысокаяСреднийПри конкретной угрозе
gVisor или KataВысокаяОчень высокийНедоверенный код
Подпись образовСредняяСреднийПубличные образы, требования

Первые четыре строки — обязательный минимум: цена низкая, эффект существенный. Остальное — по результатам модели угроз.


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

Почему namespace не является границей безопасности

Namespace изменяет то, что процесс видит, но не то, к чему он обращается. Системный вызов идёт в то же ядро; namespace лишь подменяет контекст.

Уязвимость в обработчике ioctl, io_uring или любой другой подсистеме доступна изнутри container'а так же, как с host. Именно поэтому seccomp — существенная мера: он сокращает число достижимых обработчиков (урок 12.5).

Что реально ограничивает побег

МеханизмЧто даёт
User namespaceUID 0 в container ≠ UID 0 на host
seccompМеньше достижимых системных вызовов
CapabilitiesМеньше привилегированных операций
AppArmor/SELinuxОграничение доступа к путям и объектам
Read-only кореньНет места для закрепления

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


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

Container — это процесс host

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

docker run -d --name proc-demo python:3.13-slim sleep 300 > /dev/null
sleep 2

echo "═══ что видит container ═══"
docker exec proc-demo sh -c '
    echo "  PID внутри:    $(cat /proc/self/stat | cut -d" " -f4 > /dev/null; echo $$)"
    echo "  процессов:     $(ls -d /proc/[0-9]* | wc -l)"
'

echo "═══ что видит host ═══"
pid="$(docker inspect proc-demo --format '{{.State.Pid}}')"
printf '  PID на host:   %s\n' "$pid"
printf '  команда:       %s\n' "$(ps -o args= -p "$pid" 2>/dev/null | head -1)"
printf '  владелец:      %s\n' "$(ps -o user= -p "$pid" 2>/dev/null)"

echo "═══ ядро одно и то же ═══"
printf '  host:      %s\n' "$(uname -r)"
printf '  container: %s\n' "$(docker exec proc-demo uname -r)"

echo "═══ namespaces процесса ═══"
sudo ls -l "/proc/$pid/ns/" 2>/dev/null | awk '{print "  " $9 " -> " $11}' | grep -v '^  ->' \
    || ls -l "/proc/$pid/ns/" 2>/dev/null | head -8 | sed 's/^/  /'

docker rm -f proc-demo > /dev/null

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

text
═══ что видит container ═══
  PID внутри:    1
  процессов:     3
═══ что видит host ═══
  PID на host:   58412
  команда:       sleep 300
  владелец:      root
═══ ядро одно и то же ═══
  host:      6.8.0-88-generic
  container: 6.8.0-88-generic
═══ namespaces процесса ═══
  cgroup -> cgroup:[4026533456]
  ipc -> ipc:[4026533389]
  mnt -> mnt:[4026533387]
  net -> net:[4026533392]
  pid -> pid:[4026533390]
  user -> user:[4026531837]
  uts -> uts:[4026533388]

Три наблюдения складываются в модель.

PID 1 внутри — это PID 58412 на host: один и тот же процесс с двумя номерами.

Версия ядра совпадает: изоляции ядра нет, оно общее.

Список namespaces показывает, что user не изолирован — его идентификатор 4026531837 совпадает с host. Именно поэтому root в container'е по умолчанию — это root на host (урок 12.3).

Что видно из container'а о host

bash
cd /tmp/threat
echo "═══ информация о host, доступная изнутри ═══"
docker run --rm python:3.13-slim sh -c '
    echo "  ядро:      $(uname -a | cut -c1-60)"
    echo "  CPU:       $(grep -c ^processor /proc/cpuinfo) шт. (это CPU HOST)"
    echo "  память:    $(awk "/MemTotal/{printf \"%.1f ГиБ\", \$2/1048576}" /proc/meminfo) (это память HOST)"
    echo "  загрузка:  $(cut -d" " -f1-3 /proc/loadavg) (это загрузка HOST)"
    echo "  uptime:    $(awk "{printf \"%.0f ч\", \$1/3600}" /proc/uptime) (это uptime HOST)"
'

echo "═══ что это означает ═══"
cat <<'TXT'
  procfs не виртуализирован по железу: container видит характеристики
  и загрузку host. Это не уязвимость, но это информация о среде,
  которую атакующий использует для разведки.
TXT

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

text
═══ информация о host, доступная изнутри ═══
  ядро:      Linux 3f8a91c2e7d4 6.8.0-88-generic #88-Ubuntu SMP PREEMPT_D
  CPU:       8 шт. (это CPU HOST)
  память:    15.6 ГиБ (это память HOST)
  загрузка:  0.42 0.38 0.35 (это загрузка HOST)
  uptime:    142 ч (это uptime HOST)
═══ что это означает ═══
  procfs не виртуализирован по железу: container видит характеристики
  и загрузку host. Это не уязвимость, но это информация о среде,
  которую атакующий использует для разведки.

Версия ядра — первое, что интересует атакующего: она определяет, какие известные уязвимости применимы.

Это же объясняет проблему из урока 6.13: Python видит CPU и память host, а не лимиты.

Активы: что именно можно потерять

bash
cd /tmp/threat
cat > assets.py <<'PY'
"""Инвентаризация активов, доступных скомпрометированному процессу."""
from __future__ import annotations

import json
import os
import socket
from pathlib import Path


def check_env_secrets() -> list[str]:
    """Секреты в переменных окружения — первое, что смотрит атакующий."""
    patterns = ("PASSWORD", "SECRET", "TOKEN", "KEY", "CREDENTIAL", "DSN")
    return [k for k in os.environ if any(p in k.upper() for p in patterns)]


def check_mounted_secrets() -> list[str]:
    d = Path("/run/secrets")
    return [p.name for p in d.iterdir()] if d.is_dir() else []


def check_docker_socket() -> str:
    p = Path("/var/run/docker.sock")
    if not p.exists():
        return "нет"
    return "ЕСТЬ — это доступ к демону, то есть к root на host"


def check_reachable(targets: list[tuple[str, int]]) -> list[str]:
    reachable = []
    for host, port in targets:
        s = socket.socket()
        s.settimeout(0.5)
        try:
            s.connect((host, port))
            reachable.append(f"{host}:{port}")
        except OSError:
            pass
        finally:
            s.close()
    return reachable


def check_host_mounts() -> list[str]:
    """Смонтированные каталоги host — путь к данным вне container."""
    found = []
    try:
        for line in Path("/proc/self/mountinfo").read_text().splitlines():
            parts = line.split()
            if len(parts) > 4 and parts[4] not in ("/", "/proc", "/sys", "/dev"):
                fstype = line.split(" - ")[1].split()[0] if " - " in line else "?"
                if fstype in ("ext4", "xfs", "btrfs", "overlay", "9p", "virtiofs"):
                    found.append(parts[4])
    except OSError:
        pass
    return sorted(set(found))


report = {
    "uid": os.getuid(),
    "секреты в окружении": check_env_secrets(),
    "секреты в /run/secrets": check_mounted_secrets(),
    "docker socket": check_docker_socket(),
    "смонтировано с host": check_host_mounts(),
    "доступные сервисы": check_reachable([("db", 5432), ("cache", 6379), ("127.0.0.1", 8000)]),
}
print(json.dumps(report, ensure_ascii=False, indent=2))
PY

echo "═══ типичная небезопасная конфигурация ═══"
docker run --rm \
    -e DATABASE_PASSWORD=secret123 \
    -e API_TOKEN=tok-abc \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -v /etc:/host-etc:ro \
    -v "$PWD/assets.py:/a.py:ro" \
    python:3.13-slim python /a.py

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

text
═══ типичная небезопасная конфигурация ═══
{
  "uid": 0,
  "секреты в окружении": [
    "DATABASE_PASSWORD",
    "API_TOKEN"
  ],
  "секреты в /run/secrets": [],
  "docker socket": "ЕСТЬ — это доступ к демону, то есть к root на host",
  "смонтировано с host": [
    "/host-etc"
  ],
  "доступные сервисы": []
}

Отчёт составлен обычным Python-скриптом за доли секунды — ровно то, что выполнит атакующий первым делом после получения выполнения кода.

Четыре находки, каждая из которых расширяет последствия компрометации: работа от root, два секрета в окружении, доступ к Docker socket и смонтированный /etc host.

Та же проверка на защищённой конфигурации

bash
cd /tmp/threat
mkdir -p sec && printf 'пароль-в-файле' > sec/db_password

echo "═══ конфигурация с базовыми мерами ═══"
docker run --rm \
    --user 10001:10001 \
    --cap-drop=ALL \
    --security-opt=no-new-privileges \
    --read-only --tmpfs /tmp:size=8m \
    -v "$PWD/sec/db_password:/run/secrets/db_password:ro" \
    -e DB_PASSWORD_FILE=/run/secrets/db_password \
    -v "$PWD/assets.py:/a.py:ro" \
    python:3.13-slim python /a.py

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

text
═══ конфигурация с базовыми мерами ═══
{
  "uid": 10001,
  "секреты в окружении": [
    "DB_PASSWORD_FILE"
  ],
  "секреты в /run/secrets": [
    "db_password"
  ],
  "docker socket": "нет",
  "смонтировано с host": [],
  "доступные сервисы": []
}

Различие принципиальное. В окружении теперь только путь к секрету, а не значение. Socket недоступен, каталоги host не смонтированы, работа от непривилегированного UID.

Но обратите внимание на честный остаточный риск: файл /run/secrets/db_password процессу доступен — он должен быть доступен, иначе приложение не подключится к базе. Компрометация процесса по-прежнему означает компрометацию пароля базы.

Меры сократили поверхность, но не устранили угрозу — именно это фиксирует колонка «остаточный риск» в модели.

Модель угроз как таблица

bash
cd /tmp/threat
cat > threat-model.md <<'TXT'
# Модель угроз: сервис обработки заказов

## Активы

| Актив | Ценность | Где хранится |
|---|---|---|
| Персональные данные клиентов | Высокая: регуляторные требования | PostgreSQL |
| Учётные данные к базе | Критическая: доступ ко всем данным | Compose secret |
| Токен платёжного шлюза | Критическая: финансовые операции | Compose secret |
| Доступ во внутреннюю сеть | Высокая: плацдарм для продвижения | Сетевая конфигурация |
| Вычислительные ресурсы | Средняя: майнинг, рассылка | Лимиты cgroup |

## Нарушители

| Нарушитель | Возможности | Вероятность |
|---|---|---|
| Внешний, без доступа | Запросы к публичному API | Высокая |
| Аутентифицированный пользователь | Запросы от своего имени | Средняя |
| Скомпрометированная зависимость PyPI | Выполнение кода при сборке или работе | Низкая, ущерб высокий |
| Сосед по узлу | Другой container на том же host | Низкая |

## Векторы и меры

| # | Вектор | Мера | Остаточный риск |
|---:|---|---|---|
| 1 | Инъекция через API → выполнение кода | Валидация ввода, параметризованные запросы | Уязвимость нулевого дня |
| 2 | Выполнение кода → чтение секретов | Secrets файлами, не в окружении | Процесс читает файл сам |
| 3 | Выполнение кода → запись веб-шелла | read-only корень, tmpfs без noexec-исключений | Работа в памяти процесса |
| 4 | Выполнение кода → продвижение к базе | Сеть internal, минимальные права в БД | Данные, доступные приложению |
| 5 | Выполнение кода → побег на host | non-root, cap-drop=ALL, seccomp по умолчанию | Уязвимость ядра |
| 6 | Вредоносная зависимость | Фиксация версий, сканирование, lock-файл | Атака до публикации пакета |
| 7 | Утечка секрета в образ | BuildKit secret mounts, проверка history в CI | Секрет в логах приложения |

## Принятые решения

- **Не применяем gVisor/Kata**: код собственный, недоверенный код не запускается.
  Пересмотр — при появлении плагинов от сторонних разработчиков.
- **Не применяем свой профиль seccomp**: профиль по умолчанию покрывает
  известные векторы. Пересмотр — при обнаружении конкретной угрозы.
- **Не подписываем образы**: реестр внутренний, доступ ограничен.
  Пересмотр — при публикации образов наружу.

## Что НЕ покрыто мерами

- Уязвимость в самом приложении: закрывается тестами и код-ревью, не изоляцией.
- Утечка данных через легитимный API: требует авторизации и аудита.
- Компрометация CI: отдельная модель угроз.
TXT

echo "═══ структура модели ═══"
grep -E '^#|^\| [0-9#]' threat-model.md | head -20 | sed 's/^/  /'

echo
echo "═══ проверка полноты ═══"
python3 - <<'PY'
import pathlib
import re

text = pathlib.Path("threat-model.md").read_text()
required = {
    "Активы": r"## Активы",
    "Нарушители": r"## Нарушители",
    "Векторы и меры": r"## Векторы и меры",
    "Остаточный риск": r"Остаточный риск",
    "Принятые решения": r"## Принятые решения",
    "Что НЕ покрыто": r"## Что НЕ покрыто",
}
for name, pattern in required.items():
    mark = "✓" if re.search(pattern, text) else "✗"
    print(f"  {mark} {name}")

vectors = len(re.findall(r"^\| \d+ \|", text, re.M))
print(f"  векторов описано: {vectors}")
PY

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

text
═══ структура модели ═══
  # Модель угроз: сервис обработки заказов
  ## Активы
  ## Нарушители
  ## Векторы и меры
  | # | Вектор | Мера | Остаточный риск |
  | 1 | Инъекция через API → выполнение кода | Валидация ввода, параметризованные запросы | Уязвимость нулевого дня |
  | 2 | Выполнение кода → чтение секретов | Secrets файлами, не в окружении | Процесс читает файл сам |
  ...
  ## Принятые решения
  ## Что НЕ покрыто мерами

═══ проверка полноты ═══
  ✓ Активы
  ✓ Нарушители
  ✓ Векторы и меры
  ✓ Остаточный риск
  ✓ Принятые решения
  ✓ Что НЕ покрыто
  векторов описано: 7

Модель умещается на страницу и отвечает на все четыре вопроса. Два последних раздела — «Принятые решения» и «Что НЕ покрыто» — превращают её из формальности в рабочий документ: через полгода будет видно, что решили и почему.

Соразмерность: цена и эффект мер

bash
cd /tmp/threat
cat > measures.py <<'PY'
"""Оценка мер по цене и эффекту — вход в решение о применении."""
from __future__ import annotations

MEASURES = [
    # (мера, цена 1-5, эффект 1-5, закрывает векторы)
    ("non-root (USER в образе)",        1, 4, "5"),
    ("--cap-drop=ALL",                  1, 4, "5"),
    ("no-new-privileges",               1, 3, "5"),
    ("read-only корень + tmpfs",        2, 4, "3"),
    ("secrets файлами вместо ENV",      2, 4, "2"),
    ("сегментация сетей",               3, 4, "4"),
    ("фиксация версий + сканирование",  2, 4, "6"),
    ("BuildKit secret mounts",          2, 4, "7"),
    ("свой профиль seccomp",            5, 3, "5"),
    ("подпись образов",                 4, 3, "6"),
    ("gVisor / Kata Containers",        5, 5, "5"),
]


def priority(cost: int, effect: int) -> str:
    ratio = effect / cost
    if ratio >= 2.0:
        return "делать сразу"
    if ratio >= 1.0:
        return "делать"
    return "по модели угроз"


print(f"  {'мера':<32} {'цена':>5} {'эффект':>7} {'вектор':>7}  решение")
print("  " + "─" * 72)
for name, cost, effect, vectors in sorted(MEASURES, key=lambda m: -m[2] / m[1]):
    print(f"  {name:<32} {cost:>5} {effect:>7} {vectors:>7}  {priority(cost, effect)}")
PY

python3 measures.py

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

text
  мера                              цена  эффект  вектор  решение
  ────────────────────────────────────────────────────────────────────────
  non-root (USER в образе)             1       4       5  делать сразу
  --cap-drop=ALL                       1       4       5  делать сразу
  no-new-privileges                    1       3       5  делать сразу
  read-only корень + tmpfs             2       4       3  делать сразу
  secrets файлами вместо ENV           2       4       2  делать сразу
  фиксация версий + сканирование       2       4       6  делать сразу
  BuildKit secret mounts               2       4       7  делать сразу
  сегментация сетей                    3       4       4  делать
  gVisor / Kata Containers             5       5       5  делать
  подпись образов                      4       3       3  по модели угроз
  свой профиль seccomp                 5       3       5  по модели угроз

Семь мер с отношением эффекта к цене выше двух — их делают без обсуждения. Всё остальное требует обоснования моделью угроз.

Обратите внимание на gVisor: эффект максимальный, но и цена тоже. Он попадает в «делать» только когда модель угроз содержит недоверенный код.

Границы доверия в конфигурации

bash
cd /tmp/threat
cat > compose.trust.yaml <<'EOF'
name: trust

services:
  # Граница 1: интернет → приложение
  proxy:
    image: python:3.13-slim
    command: ["sleep", "300"]
    networks: [edge]
    ports:
      - "127.0.0.1:8950:8000"

  # Граница 2: приложение → данные
  api:
    image: python:3.13-slim
    command: ["sleep", "300"]
    networks: [edge, data]

  worker:
    image: python:3.13-slim
    command: ["sleep", "300"]
    networks: [data]

  db:
    image: python:3.13-slim
    command: ["sleep", "300"]
    networks: [data]

networks:
  edge:
  data:
    internal: true      # граница 3: наружу хода нет
EOF

docker compose -f compose.trust.yaml up -d > /dev/null 2>&1
sleep 3

echo "═══ карта границ доверия ═══"
printf '  %-8s %-34s %s\n' "сервис" "сети" "выход наружу"
for svc in proxy api worker db; do
    cid="$(docker compose -f compose.trust.yaml ps -q $svc)"
    nets="$(docker inspect "$cid" --format '{{range $n, $v := .NetworkSettings.Networks}}{{$n}} {{end}}' | sed 's/trust_//g')"
    routes="$(docker exec "$cid" sh -c 'ip route 2>/dev/null | grep -c "^default"' 2>/dev/null || echo '?')"
    printf '  %-8s %-34s %s\n' "$svc" "$nets" \
        "$([ "$routes" = "0" ] && echo 'нет' || echo 'есть')"
done

echo "═══ проверка изоляции ═══"
probe() {
    docker compose -f compose.trust.yaml exec -T "$1" python - "$2" <<'PY' 2>/dev/null
import socket, sys
try:
    print(f"  {sys.argv[1]}: {socket.gethostbyname(sys.argv[1])}")
except socket.gaierror:
    print(f"  {sys.argv[1]}: имя не разрешается")
PY
}
printf '  из proxy:\n'; probe proxy db | sed 's/^/  /'
printf '  из api:\n';   probe api db | sed 's/^/  /'

docker compose -f compose.trust.yaml down > /dev/null 2>&1
cd /tmp && rm -rf /tmp/threat

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

text
═══ карта границ доверия ═══
  сервис   сети                               выход наружу
  proxy    edge                               есть
  api      data edge                          есть
  worker   data                               нет
  db       data                               нет
═══ проверка изоляции ═══
  из proxy:
    db: имя не разрешается
  из api:
    db: 172.35.0.4

Три границы видны в одной таблице. proxy не может даже разрешить имя базы; worker и db не имеют выхода наружу.

api состоит в обеих сетях — он и есть точка пересечения границы, и именно поэтому валидация ввода в нём критична (урок 8.2).


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

Задание. Постройте модель угроз для сервиса и подтвердите соответствие конфигурации.

Требования:

  1. Модель содержит активы, нарушителей, векторы, меры и остаточный риск для каждого вектора.
  2. Есть раздел принятых решений: что сознательно не делаем и почему.
  3. Есть раздел «что не покрыто мерами».
  4. Скрипт инвентаризации показывает, что доступно скомпрометированному процессу.
  5. Показаны две конфигурации: до применения мер и после; различие измерено.
  6. Меры оценены по цене и эффекту; выбор обоснован.

Подсказки

Подсказка 1

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

Подсказка 2

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

Подсказка 3

Пункт 5 нагляднее, если обе конфигурации проверяются одним и тем же скриптом.

Решение

Показать решение
bash
mkdir -p /tmp/tmodel/{sec,app} && cd /tmp/tmodel

cat > app/inventory.py <<'PY'
"""Инвентаризация: что доступно процессу после компрометации.

Выполняет то же, что сделал бы атакующий, получивший выполнение кода:
осматривается и оценивает, куда можно продвинуться.
"""
from __future__ import annotations

import json
import os
import socket
import sys
from pathlib import Path

SECRET_HINTS = ("PASSWORD", "SECRET", "TOKEN", "KEY", "CREDENTIAL", "DSN", "PRIVATE")
SENSITIVE_PATHS = ("/var/run/docker.sock", "/root", "/etc/shadow", "/host")


def read_status(field: str) -> str:
    try:
        for line in Path("/proc/self/status").read_text().splitlines():
            if line.startswith(field + ":"):
                return line.split(maxsplit=1)[1].strip()
    except OSError:
        pass
    return "?"


def env_secrets() -> dict[str, str]:
    """Значения секретов в окружении — не только имена."""
    out = {}
    for key, value in os.environ.items():
        upper = key.upper()
        if any(h in upper for h in SECRET_HINTS) and not upper.endswith("_FILE"):
            out[key] = f"{value[:4]}…({len(value)} симв.)" if value else "(пусто)"
    return out


def secret_files() -> list[str]:
    d = Path("/run/secrets")
    return sorted(p.name for p in d.iterdir()) if d.is_dir() else []


def sensitive_access() -> dict[str, str]:
    out = {}
    for path in SENSITIVE_PATHS:
        p = Path(path)
        if not p.exists():
            continue
        try:
            if p.is_dir():
                list(p.iterdir())
                out[path] = "доступен на чтение"
            else:
                p.read_bytes()[:1]
                out[path] = "доступен на чтение"
        except OSError as exc:
            out[path] = f"отказ: {exc.strerror}"
    return out


def writable_paths() -> list[str]:
    out = []
    for path in ("/", "/app", "/usr/local/bin", "/etc", "/tmp"):
        probe = Path(path) / ".probe-write"
        try:
            probe.write_text("x")
            probe.unlink()
            out.append(path)
        except OSError:
            pass
    return out


def reachable(targets: list[tuple[str, int]]) -> list[str]:
    out = []
    for host, port in targets:
        s = socket.socket()
        s.settimeout(0.5)
        try:
            s.connect((host, port))
            out.append(f"{host}:{port}")
        except OSError:
            pass
        finally:
            s.close()
    return out


def score(report: dict[str, object]) -> int:
    """Грубая оценка поверхности: чем выше, тем хуже."""
    points = 0
    points += 3 if report["uid"] == 0 else 0
    points += 3 * len(report["секреты_в_окружении"])
    points += 5 if "доступен" in str(report["чувствительные_пути"].get("/var/run/docker.sock", "")) else 0
    points += 2 * len([p for p in report["записываемые_пути"] if p != "/tmp"])
    points += 1 if report["capabilities"] != "0000000000000000" else 0
    return points


if __name__ == "__main__":
    report: dict[str, object] = {
        "uid": os.getuid(),
        "capabilities": read_status("CapEff"),
        "no_new_privs": read_status("NoNewPrivs"),
        "секреты_в_окружении": env_secrets(),
        "секреты_файлами": secret_files(),
        "чувствительные_пути": sensitive_access(),
        "записываемые_пути": writable_paths(),
        "доступные_сервисы": reachable([("db", 5432), ("127.0.0.1", 8000)]),
    }
    report["оценка_поверхности"] = score(report)
    print(json.dumps(report, ensure_ascii=False, indent=2))
    sys.exit(0)
PY

printf 'пароль-базы-для-модели' > sec/db_password
chmod 600 sec/db_password

cat > THREAT-MODEL.md <<'TXT'
# Модель угроз: сервис обработки заказов

Дата составления: 2026-07-31. Пересмотр: при изменении состава сервисов
или появлении недоверенного кода.

## 1. Активы

| Актив | Ценность | Где находится |
|---|---|---|
| Персональные данные клиентов | Высокая: регуляторные требования | PostgreSQL |
| Учётные данные к базе | Критическая: доступ ко всем данным | Compose secret |
| Токен платёжного шлюза | Критическая: финансовые операции | Compose secret |
| Доступ во внутреннюю сеть | Высокая: плацдарм для продвижения | Сетевая конфигурация |
| Вычислительные ресурсы | Средняя: майнинг, рассылка спама | Лимиты cgroup |
| Доверие к домену | Средняя: фишинг от нашего имени | Вне контура container |

## 2. Нарушители

| Нарушитель | Возможности | Вероятность | Ущерб |
|---|---|---|---|
| Внешний, без доступа | Запросы к публичному API | Высокая | Высокий |
| Аутентифицированный пользователь | Запросы от своего имени | Средняя | Средний |
| Скомпрометированная зависимость | Выполнение кода при сборке и работе | Низкая | Критический |
| Сосед по узлу | Другой container на том же host | Низкая | Высокий |
| Сотрудник с доступом к CI | Изменение образа | Очень низкая | Критический |

## 3. Векторы, меры, остаточный риск

| # | Вектор | Мера | Остаточный риск |
|---:|---|---|---|
| 1 | Инъекция через API → выполнение кода | Параметризованные запросы, валидация ввода | Уязвимость нулевого дня в зависимости |
| 2 | Выполнение кода → чтение секретов | Secrets файлами, не в переменных окружения | Процесс читает файл сам — устранить нельзя |
| 3 | Выполнение кода → закрепление | read-only корень, tmpfs только для /tmp | Работа целиком в памяти процесса |
| 4 | Выполнение кода → продвижение к базе | Сеть internal, отдельный пользователь БД с минимальными правами | Данные, доступные приложению, скомпрометированы |
| 5 | Выполнение кода → побег на host | non-root, cap-drop=ALL, no-new-privileges, seccomp по умолчанию | Уязвимость ядра: остаётся |
| 6 | Выполнение кода → исчерпание ресурсов узла | Лимиты памяти, CPU, PID | Деградация в пределах лимита |
| 7 | Вредоносная зависимость PyPI | Фиксация версий, lock-файл, сканирование | Атака на пакет до публикации |
| 8 | Утечка секрета в слой образа | BuildKit secret mounts, проверка history в CI | Секрет в логах приложения |
| 9 | Доступ к Docker socket | Socket не монтируется ни в один container | Доступ к socket на самом host |

## 4. Принятые решения

### НЕ применяем gVisor или Kata Containers

**Обоснование:** запускается только собственный код и код известных зависимостей.
Недоверенный код пользователей платформы не выполняется.

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

### НЕ создаём собственный профиль seccomp

**Обоснование:** профиль Docker по умолчанию блокирует около 44 вызовов,
включая известные векторы побега. Свой профиль требует сопровождения
и ломается при обновлении зависимостей.

**Условие пересмотра:** обнаружение конкретной угрозы, не покрытой умолчанием.

### НЕ подписываем образы

**Обоснование:** реестр внутренний, доступ на запись только у CI.
Подмена образа требует компрометации CI, которая закрывается отдельно.

**Условие пересмотра:** публикация образов наружу, требование аудита.

## 5. Что НЕ покрыто мерами изоляции

- **Уязвимость в самом приложении.** Закрывается тестами, код-ревью
  и статическим анализом. Изоляция ограничивает последствия, а не вероятность.
- **Утечка данных через легитимный API.** Требует авторизации на уровне
  объектов и аудита обращений.
- **Компрометация конвейера сборки.** Отдельная модель угроз.
- **Атака на цепочку поставок до публикации пакета.** Частично закрывается
  фиксацией версий, полностью — нет.
- **Уязвимость нулевого дня в ядре.** Принимается как остаточный риск;
  снижается своевременным обновлением ядра host.
TXT

cat > measures.py <<'PY'
"""Оценка мер: цена, эффект, закрываемые векторы."""
from __future__ import annotations

MEASURES = [
    ("USER 10001 в образе",             1, 4, [5]),
    ("--cap-drop=ALL",                  1, 4, [5]),
    ("--security-opt no-new-privileges",1, 3, [5]),
    ("read-only + tmpfs",               2, 4, [3]),
    ("secrets файлами",                 2, 4, [2, 8]),
    ("лимиты памяти/CPU/PID",           1, 3, [6]),
    ("сеть internal для данных",        3, 4, [4]),
    ("фиксация версий + сканирование",  2, 4, [7]),
    ("BuildKit secret mounts",          2, 4, [8]),
    ("socket не монтируется",           1, 5, [9]),
    ("свой профиль seccomp",            5, 3, [5]),
    ("подпись образов",                 4, 3, [7]),
    ("gVisor / Kata",                   5, 5, [5]),
]


def decision(cost: int, effect: int) -> str:
    ratio = effect / cost
    if ratio >= 2.0:
        return "делать сразу"
    if ratio >= 1.0:
        return "делать"
    return "по модели угроз"


if __name__ == "__main__":
    print(f"  {'мера':<34} {'цена':>4} {'эфф.':>5} {'векторы':>9}  решение")
    print("  " + "─" * 74)
    applied = []
    for name, cost, effect, vectors in sorted(MEASURES, key=lambda m: -m[2] / m[1]):
        d = decision(cost, effect)
        print(f"  {name:<34} {cost:>4} {effect:>5} {str(vectors):>9}  {d}")
        if d == "делать сразу":
            applied.append(name)
    print()
    print(f"  применяем безусловно: {len(applied)} мер")
    covered = sorted({v for n, c, e, vs in MEASURES if e / c >= 2.0 for v in vs})
    print(f"  закрываемые векторы: {covered}")
    print(f"  вектор 1 (уязвимость приложения) изоляцией НЕ закрывается")
PY

cat > compose.yaml <<'EOF'
name: tmodel

services:
  # Конфигурация ДО применения мер
  before:
    image: python:3.13-slim
    command: ["sleep", "300"]
    environment:
      DATABASE_PASSWORD: "пароль-в-переменной"
      PAYMENT_API_TOKEN: "tok-live-abcdef123456"
    volumes:
      - ./app/inventory.py:/inventory.py:ro
      - /var/run/docker.sock:/var/run/docker.sock
    networks: [everything]

  # Конфигурация ПОСЛЕ применения мер
  after:
    image: python:3.13-slim
    command: ["sleep", "300"]
    user: "10001:10001"
    read_only: true
    tmpfs:
      - /tmp:size=8m,mode=1777,uid=10001,gid=10001
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    pids_limit: 64
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - ./app/inventory.py:/inventory.py:ro
    networks: [isolated]
    deploy:
      resources:
        limits:
          memory: 128M
          cpus: "0.5"

networks:
  everything:
  isolated:
    internal: true

secrets:
  db_password:
    file: ./sec/db_password
EOF

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

printf '\n═══ Требования 1-3: структура модели ═══\n'
python3 - <<'PY'
import pathlib, re
text = pathlib.Path("THREAT-MODEL.md").read_text()
checks = {
    "раздел «Активы»":            r"## 1\. Активы",
    "раздел «Нарушители»":        r"## 2\. Нарушители",
    "векторы с остаточным риском": r"Остаточный риск",
    "раздел «Принятые решения»":  r"## 4\. Принятые решения",
    "раздел «Что НЕ покрыто»":    r"## 5\. Что НЕ покрыто",
    "условия пересмотра":         r"Условие пересмотра",
}
missing = []
for name, pat in checks.items():
    found = bool(re.search(pat, text))
    print(f"    {'✓' if found else '✗'} {name}")
    if not found:
        missing.append(name)

vectors = re.findall(r"^\| (\d+) \| (.+?) \| (.+?) \| (.+?) \|$", text, re.M)
print(f"    векторов: {len(vectors)}")
empty_residual = [v[0] for v in vectors if not v[3].strip() or v[3].strip() == "—"]
print(f"    векторов без остаточного риска: {len(empty_residual)}")

import sys
sys.exit(1 if missing or empty_residual or len(vectors) < 5 else 0)
PY
[ $? -eq 0 ] && ok "модель полна, у каждого вектора указан остаточный риск" \
    || bad "модель неполна"

printf '\n═══ Требование 6: оценка мер ═══\n'
python3 measures.py

printf '\n═══ Требования 4-5: инвентаризация ДО и ПОСЛЕ ═══\n'
docker compose up -d > /dev/null 2>&1
sleep 3

for svc in before after; do
    printf '\n  ── конфигурация %s ──\n' "$svc"
    docker compose exec -T "$svc" python /inventory.py 2>/dev/null \
        | python3 -c "
import json, sys
r = json.load(sys.stdin)
print(f\"    uid: {r['uid']}, capabilities: {r['capabilities']}, no_new_privs: {r['no_new_privs']}\")
print(f\"    секреты в окружении: {list(r['секреты_в_окружении']) or 'нет'}\")
print(f\"    секреты файлами:     {r['секреты_файлами'] or 'нет'}\")
print(f\"    чувствительные пути: {r['чувствительные_пути'] or 'нет доступа'}\")
print(f\"    записываемые пути:   {r['записываемые_пути']}\")
print(f\"    ОЦЕНКА ПОВЕРХНОСТИ:  {r['оценка_поверхности']}\")
"
done

score_before="$(docker compose exec -T before python /inventory.py 2>/dev/null \
    | python3 -c "import json,sys; print(json.load(sys.stdin)['оценка_поверхности'])")"
score_after="$(docker compose exec -T after python /inventory.py 2>/dev/null \
    | python3 -c "import json,sys; print(json.load(sys.stdin)['оценка_поверхности'])")"

printf '\n    оценка до: %s, после: %s\n' "$score_before" "$score_after"
[ "$score_before" -gt "$score_after" ] && ok "поверхность атаки сокращена измеримо" \
    || bad "различия нет: до=$score_before после=$score_after"
[ "$score_after" -le 3 ] && ok "остаточная поверхность минимальна" \
    || bad "остаточная оценка высока: $score_after"

printf '\n═══ Остаточный риск: что осталось доступным ═══\n'
docker compose exec -T after sh -c 'cat /run/secrets/db_password' 2>/dev/null | sed 's/^/    пароль базы читается процессом: /'
cat <<'TXT'
    Это НЕ дефект конфигурации: приложение обязано читать пароль,
    иначе не подключится к базе. Мера сократила поверхность
    (пароля нет в окружении и в docker inspect), но не устранила
    угрозу — что и зафиксировано в модели как остаточный риск вектора 2.
TXT
ok "остаточный риск продемонстрирован, а не скрыт"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker compose down > /dev/null 2>&1
cd /tmp && rm -rf /tmp/tmodel
exit "$fail"

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

text
═══ Требования 1-3: структура модели ═══
    ✓ раздел «Активы»
    ✓ раздел «Нарушители»
    ✓ векторы с остаточным риском
    ✓ раздел «Принятые решения»
    ✓ раздел «Что НЕ покрыто»
    ✓ условия пересмотра
    векторов: 9
    векторов без остаточного риска: 0
  ✓ модель полна, у каждого вектора указан остаточный риск

═══ Требование 6: оценка мер ═══
  мера                                 цена  эфф.   векторы  решение
  ──────────────────────────────────────────────────────────────────────────
  socket не монтируется                   1     5       [9]  делать сразу
  USER 10001 в образе                     1     4       [5]  делать сразу
  --cap-drop=ALL                          1     4       [5]  делать сразу
  --security-opt no-new-privileges        1     3       [5]  делать сразу
  лимиты памяти/CPU/PID                   1     3       [6]  делать сразу
  read-only + tmpfs                       2     4       [3]  делать сразу
  secrets файлами                         2     4    [2, 8]  делать сразу
  фиксация версий + сканирование          2     4       [7]  делать сразу
  BuildKit secret mounts                  2     4       [8]  делать сразу
  сеть internal для данных                3     4       [4]  делать
  gVisor / Kata                           5     5       [5]  делать
  подпись образов                         4     3       [7]  по модели угроз
  свой профиль seccomp                    5     3       [5]  по модели угроз

  применяем безусловно: 9 мер
  закрываемые векторы: [2, 3, 5, 6, 7, 8, 9]
  вектор 1 (уязвимость приложения) изоляцией НЕ закрывается

═══ Требования 4-5: инвентаризация ДО и ПОСЛЕ ═══

  ── конфигурация before ──
    uid: 0, capabilities: 00000000a80425fb, no_new_privs: 0
    секреты в окружении: ['DATABASE_PASSWORD', 'PAYMENT_API_TOKEN']
    секреты файлами:     нет
    чувствительные пути: {'/var/run/docker.sock': 'доступен на чтение', '/root': 'доступен на чтение'}
    записываемые пути:   ['/', '/app', '/usr/local/bin', '/etc', '/tmp']
    ОЦЕНКА ПОВЕРХНОСТИ:  23

  ── конфигурация after ──
    uid: 10001, capabilities: 0000000000000000, no_new_privs: 1
    секреты в окружении: нет
    секреты файлами:     ['db_password']
    чувствительные пути: нет доступа
    записываемые пути:   ['/tmp']
    ОЦЕНКА ПОВЕРХНОСТИ:  0

    оценка до: 23, после: 0
  ✓ поверхность атаки сокращена измеримо
  ✓ остаточная поверхность минимальна

═══ Остаточный риск: что осталось доступным ═══
    пароль базы читается процессом: пароль-базы-для-модели
    Это НЕ дефект конфигурации: приложение обязано читать пароль,
    иначе не подключится к базе. Мера сократила поверхность
    (пароля нет в окружении и в docker inspect), но не устранила
    угрозу — что и зафиксировано в модели как остаточный риск вектора 2.
  ✓ остаточный риск продемонстрирован, а не скрыт

═══ ИТОГ ═══
  все требования выполнены

Все требования выполнены.

Обратите внимание на последний блок: он показывает, что пароль по-прежнему читается процессом. Скрыть это было бы соблазнительно — оценка поверхности равна нулю, все галочки зелёные. Но модель угроз ценна ровно тем, что фиксирует остаточный риск, а не создаёт впечатление полной защищённости.

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

Инвентаризация выполняется одним скриптом на обеих конфигурациях. Разные проверки для «до» и «после» позволили бы подогнать результат. Один скрипт, два запуска, числовая оценка — сравнение становится объективным.

Проверка модели требует остаточного риска у каждого вектора. Строка векторов без остаточного риска: 0 — не формальность: пустая ячейка означала бы утверждение «эта угроза устранена полностью», которое почти всегда неверно. Автоматическая проверка не даёт написать такое по невнимательности.

Оценка мер печатает, какие векторы не закрываются. Строка «вектор 1 изоляцией НЕ закрывается» важнее списка закрытых: она предотвращает главную ошибку — считать, что hardening решает проблему безопасности приложения.

Чего решение не делает. Числовая оценка поверхности — грубая эвристика с произвольными весами; она годится для сравнения двух конфигураций, но не для абсолютных выводов. Модель не покрывает угрозы уровня платформы: компрометацию узла, атаку на реестр, доступ администратора. Не проверяется и реальная эксплуатируемость: конфигурация «после» может содержать уязвимость приложения, которую никакая изоляция не закроет.

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

bash
docker run --rm python:3.13-slim sh -c '
    echo "ядро container: $(uname -r)"
    echo "ядро host:      см. uname -r на host — совпадёт"
    echo "user namespace: $(readlink /proc/self/ns/user)"
'
readlink /proc/self/ns/user

Ожидается совпадение идентификатора user namespace — подтверждение, что он не изолирован.

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

ОшибкаПричинаИсправление
Считают container изолированным как VMПохожее применениеЯдро общее; это не граница безопасности
Запускают недоверенный код в обычном container«Он же изолирован»Нужны gVisor, Kata или VM
Модель угроз не составляютКажется формальностьюМеры применяются наугад
В модели нет остаточного рискаНеудобно признаватьСоздаётся иллюзия полной защиты
Hardening считают защитой приложенияЛогично предположитьНе закрывает инъекции и утечки через API
Забывают активы «сеть» и «ресурсы»Думают только о данныхВзлом второстепенного сервиса — плацдарм
Применяют дорогие меры до дешёвыхКажутся серьёзнееСначала то, где эффект на цену выше
Границы доверия не определеныНе задумывалисьВвод считается доверенным без основания

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

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

  1. Почему изоляция container не равна границе безопасности?
  2. Назовите три вещи, от которых контейнеризация не защищает.
  3. Из каких четырёх частей состоит модель угроз?
  4. Зачем в модели нужна колонка «остаточный риск»?
  5. Какие векторы hardening не закрывает и почему?

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

  1. Как определить границы доверия в своей системе?
  2. Как выбрать, какие меры применять, а какие нет?
  3. Что проверит атакующий первым делом после получения выполнения кода?

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

  1. Приложение скомпрометировано. Что оценивать в первую очередь?
  2. Требуется запустить код пользователей платформы. Достаточно ли hardening?

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

  1. Container — процесс host, ограниченный namespaces, cgroups и capabilities того же ядра.
  2. Ядро общее: уязвимость в нём доступна изнутри container'а.
  3. Для собственного кода контейнеризации достаточно; для недоверенного нужны gVisor, Kata или VM.
  4. Контейнеризация защищает от случайного воздействия, но не от уязвимости приложения.
  5. Модель угроз отвечает на четыре вопроса: активы, нарушители, векторы, меры.
  6. Колонка «остаточный риск» обязательна: она не даёт создать иллюзию полной защиты.
  7. Активами являются и доступ во внутреннюю сеть, и вычислительные ресурсы.
  8. По частоте инцидентов лидируют уязвимость приложения и утёкшие учётные данные.
  9. Hardening закрывает избыточные привилегии и частично побег, но не первые четыре вектора.
  10. Границы доверия — места, где ввод проверяется заново.
  11. Меры выбирают по отношению эффекта к цене; первые четыре обязательны всегда.
  12. Разделы «принятые решения» и «что не покрыто» превращают модель в рабочий документ.

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

ИсточникСсылкаЧто подтверждает
Docker: securityhttps://docs.docker.com/engine/security/Модель безопасности, границы применимости
Docker: security non-eventshttps://docs.docker.com/engine/security/#conclusionsПозиция о недоверенном коде
NIST SP 800-190https://csrc.nist.gov/pubs/sp/800/190/finalРуководство по безопасности container'ов
OWASP: Docker Securityhttps://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.htmlПрактики и приоритеты
OWASP: Threat Modelinghttps://cheatsheetseries.owasp.org/cheatsheets/Threat_Modeling_Cheat_Sheet.htmlПроцедура построения модели
CIS Docker Benchmarkhttps://www.cisecurity.org/benchmark/dockerПроверяемые требования
gVisorhttps://gvisor.dev/docs/Изоляция для недоверенного кода
Kata Containershttps://katacontainers.io/Лёгкие виртуальные машины

Навигация

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

Markdown на GitHub ↗