devops-курс Все курсы

✻ Урок 4.8 · Тема 4: Docker и Compose

Безопасность образов: Trivy, Hadolint, SBOM

⏱ 4 ч

Зачем это нужно

Образ - это не только твой app.py. Внутри лежат чужие программы: Debian (популярная версия Linux, на которой построен базовый образ Python) с десятками системных пакетов (пакет это программа или библиотека в готовом для установки виде), Python, pip, библиотеки из requirements.txt. В каждой из них могла найтись ошибка, которую можно использовать для взлома. Такая ошибка называется уязвимостью (vulnerability): как незапертая форточка в доме, о которой никто не знал, пока кто-то её не заметил. Когда её находят, о ней публикуют в открытом списке известных уязвимостей (номера в нём называются CVE, подробно разберём в теории), чтобы все успели обновиться. Ты ничего не менял, а образ, который вчера считался чистым, сегодня «дырявый».

На работе это решают не вручную. Перед выкладкой в реестр (registry, склад образов из урока 4.7) образ проверяет автомат в CI (робот, который запускается сам при каждом изменении, урок 3.3): нашёл серьёзную уязвимость, для которой уже есть исправление (новая версия пакета), - выкладка останавливается. Отдельно проверяют, как написан Dockerfile (с помощью линтера: программы, которая читает файл и находит в нём плохие привычки, как проверка правописания), и хранят опись состава образа (SBOM: список всех программ внутри с версиями, как список ингредиентов на упаковке), чтобы в день громкой уязвимости за минуту ответить «затронут ли мы». Наконец, контейнер запускают с минимумом прав (не от имени администратора root, урок 1.3), чтобы даже при взломе приложения атакующий получил как можно меньше. Без этих трёх слоёв защиты взлом одного устаревшего пакета даёт злоумышленнику полный контроль над сервисом.

Эти вопросы задают и на собеседованиях: «что делаешь, если сканер нашёл CRITICAL» (самый высокий уровень серьёзности уязвимости), «что такое SBOM», «почему нельзя запускать от root».

Шаг проекта: в репозитории ~/notes появляются .hadolint.yaml (настройки линтера Hadolint: какие правила выключить) и .trivyignore.yaml (список уязвимостей, которые осознанно не чиним, с причиной), в image.yml (описание действий робота из урока 4.7) добавляются шаги Hadolint, Trivy (сканер, который ищет в образе известные уязвимости) и SBOM перед публикацией, а образ notes:0.4.0 проходит эти проверки.

Что нужно знать

Все инструменты урока (Trivy, Hadolint) запускаются как контейнеры из готовых образов, ставить их на компьютер не нужно.

Картина целиком

Представь, что ты открываешь магазин и продаёшь готовую еду, упакованную на фабрике. Как убедиться, что продукт безопасен?

  • Проверка рецепта до производства. Технолог читает рецептуру: «мясо не должно храниться при комнатной температуре». Ошибки видны ещё до того, как что-то приготовлено. Это Hadolint: он читает Dockerfile и находит плохие привычки до сборки.
  • Лаборатория проверяет готовый продукт. По составу и срокам: «в этой партии просроченная добавка, у неё известная проблема». Это Trivy: он берёт собранный образ, находит в нём программы и сверяет их со списком известных уязвимостей.
  • Состав на упаковке. Список всех ингредиентов с версиями. Если завтра объявят «партия муки такого-то поставщика опасна», по этикеткам за минуту видно, какие продукты затронуты, без вскрытия упаковок. Это SBOM (Software Bill of Materials, опись состава).
  • Правила выдачи и хранения. Даже безопасный продукт можно испортить, если оставить на солнце. Так же с контейнером: его запускают с минимумом прав. --read-only делает файлы внутри контейнера неизменяемыми, --cap-drop ALL отбирает особые «кусочки» прав администратора (capabilities, разберём в теории), а запуск не от root не даёт процессу трогать чужое.
  • Контроль на воротах. Продукт не выпускают со склада, пока проверки не пройдены. Это шлюз (gate, как проходная: «стоп, пока проверки не зелёные») в CI: сборка, проверка, и только потом публикация.

Схема того, что ты соберёшь к концу урока:

flowchart LR
    D["Dockerfile<br>(рецепт)"] --> H["Hadolint<br>как написано"] --> B["сборка образа<br>(партия готова)"] --> T["Trivy<br>что внутри"] --> S["SBOM<br>опись состава"] --> P["push в ghcr.io<br>только если всё зелёное"]
    H -.->|"красный на любом шаге: дальше не идём"| X["стоп"]
    T -.-> X
    P --> R["потом, на сервере:<br>docker run --read-only --cap-drop ALL<br>(минимум прав)"]

Дальше по порядку: откуда в образе берутся уязвимости, как их ищет Trivy, что проверяет Hadolint, что такое SBOM, как урезать права контейнера и как всё это встроить в CI.

Теория

Откуда берутся уязвимости в образе

Если думать, что образ - это «мой код», то непонятно, почему сканер ругается на openssl, которого в твоём репозитории нет. Пока не знаешь источники, не знаешь и что исправлять.

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

Вспомни из урока 4.2: образ - это набор слоёв, каждый слой добавляет файлы. Что именно лежит в этих файлах? Три источника:

flowchart TB
    I["образ notes:0.4.0"] --> L1["слой 1: базовый образ python:3.13-slim<br>Debian и Python (чужое)"]
    I --> L2["слой 2: /opt/venv<br>psycopg, psycopg-binary (чужое)"]
    I --> L3["слой 3: /app/app.py, USER, ENV<br>твой код и настройки (своё)"]
    L1 --> M["системные пакеты: openssl, zlib, libc<br>исправляют мейнтейнеры Debian"]

Несколько слов, которые здесь встречаются:

  • Пакет (package) - программа или библиотека, которую ставят одной командой и у которой есть имя и версия. В Debian пакеты ставит apt (openssl 3.5.7), в Python - pip (psycopg 3.3.6).
  • Зависимость (dependency) - пакет, без которого работает твой код. У psycopg есть свои зависимости, у тех - свои, и всё это оказывается в образе, хотя ты написал в requirements.txt одну строку.
  • Мейнтейнер (maintainer) - человек или команда, которая сопровождает пакет в дистрибутиве и выпускает исправления.

Больше всего находок обычно в базовом образе: это сотни системных пакетов, а исправляют их не ты, а мейнтейнеры Debian. Твоя работа - пересобрать образ на свежей базе, когда исправление вышло.

Как называют уязвимость. Найденную ошибку регистрируют в публичном списке и дают ей номер: CVE (Common Vulnerabilities and Exposures) вида CVE-2026-75804: год и порядковый номер. У GitHub есть свой список с номерами GHSA-... (GitHub Security Advisory), по смыслу то же самое. Серьёзность оценивают по шкале CVSS (от 0 до 10), а по ней получают уровень: LOW (низкий), MEDIUM (средний), HIGH (высокий, от 7), CRITICAL (критический, от 9). CRITICAL обычно значит «можно удалённо выполнить чужой код без пароля».

Посмотрим на примере. Из настоящего скана образа этого урока (числа у тебя будут другими, об этом ниже):

Library   Vulnerability    Severity  Status  Installed Version  Fixed Version
openssl   CVE-2026-75804   HIGH      fixed   3.5.7-1~deb13u2    3.5.7-1~deb13u3

Разбор строки: в образе стоит пакет openssl версии 3.5.7-1~deb13u2. Версия Debian читается так: 3.5.7 - версия самой программы, 1 - номер сборки для Debian, deb13u2 - вторая (u2, update) поправка безопасности для Debian 13.

Теперь сравни с колонкой справа. Уязвимость CVE-2026-75804 уровня HIGH исправлена в 3.5.7-1~deb13u3: это версия на единицу новее. Статус fixed значит «исправление существует». Достаточно обновить пакет, то есть пересобрать образ на свежей базе.

Если колонка Fixed Version пустая, а статус affected или will_not_fix, то исправления пока нет и сделать с пакетом нечего. Это принципиально: находка с исправлением - задача для тебя, находка без исправления - предмет для решения («принимаем риск» или «меняем базу»).

Образ неизменяем. Собранный образ не меняется: слои только читаются (это свойство называют иммутабельностью, immutability). Отсюда неочевидное следствие: ты собрал образ месяц назад, ничего не трогал, а сегодня скан красный. Ошибку не «занесли», просто через месяц после сборки в публичный список добавили новую запись, а в образе всё те же старые пакеты. Как узнать, что вчерашний образ стал уязвимым? Запусти сканер заново: он каждый раз сверяет список пакетов образа со свежей базой CVE (Trivy обновляет её сам), поэтому тот же образ может дать новый результат без единой пересборки. Поэтому сканируют не только при сборке, но и по расписанию уже опубликованные образы.

Прикинь сам: В образе 87 пакетов Debian и 2 библиотеки из requirements.txt. Сколько процентов состава написано тобой?

Примерно 0: твой код это один файл app.py, а всё остальное чужое. Поэтому сканер ругается на пакеты, которых нет в твоём репозитории.

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

  • «Есть CVE, значит нас взломают». Не всегда. Часть находок не достижима: библиотека лежит в образе, но приложение её не вызывает, или для эксплуатации нужна функция, которую мы не включаем (в нашем скане CVE-2026-84782 касается протокола DTLS, а «Заметки» его не используют). Оценка «эксплуатируется ли это у нас» - работа человека, сканер её не делает.
  • «Мой код чистый, значит образ безопасен». Образ состоит из чужого кода больше чем на 95%.
  • «Нет находок, значит безопасно». Сканер знает только опубликованные уязвимости. Неизвестные ему ошибки (0-day, ошибки логики твоего кода, секрет в переменной) он не увидит.

Главное: уязвимости приходят из чужих слоёв (базовый образ и библиотеки), а образ неизменяем, поэтому «новые» находки в старом образе появляются при росте базы CVE.

Проверь понимание: ты собрал образ месяц назад, код не менялся, а сегодняшний скан показал новый CRITICAL. Как это возможно и что делать?

Ответ

Кода не менялось, но выросла база знаний об уязвимостях: CVE опубликовали уже после сборки, а образ неизменяем, поэтому находка сидит в старых слоях. Действие: посмотреть колонку Fixed Version. Если исправление есть, пересобрать образ на свежей базе (docker build --pull) и просканировать снова. Если исправления ещё нет, оценить, достижим ли уязвимый код, и записать исключение с обоснованием и сроком пересмотра.

Теперь разберёмся, кто отвечает за пакеты в образе.

Пакет, дистрибутив и мейнтейнер: кто отвечает за то, что внутри

Когда сканер показывает openssl, хочется спросить: «А кто вообще должен это чинить, я или кто-то другой?» Ответ определяет, что ты делаешь дальше: обновляешь образ, ждёшь исправления или меняешь библиотеку.

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

Устроено это так.

  1. Debian это дистрибутив (distribution): собранный кем-то набор из ядра, программ и библиотек. Его команда (мейнтейнеры, maintainers) выбирает версии и отвечает за исправления.
  2. Каждая программа в дистрибутиве это пакет с номером версии. Когда в программе находят уязвимость, мейнтейнер готовит исправленный пакет с новой версией.
  3. Базовый образ python:3.13-slim периодически пересобирается с уже исправленными пакетами.
  4. Твой образ содержит слой с пакетами на момент твоей сборки. Пока ты не пересоберёшь и не выложишь заново, в нём остаётся то, что было тогда.
  5. Поэтому сканер находит «новые» уязвимости в старом образе: сам образ не менялся, изменилась база знаний о дырах.

Вот как это выглядит на деле. Ты собрал образ 1 сентября, openssl версии 3.5.7-1~deb13u2. 15 сентября опубликовали уязвимость, исправленную в 3.5.7-1~deb13u3. Образ тот же, но Trivy теперь показывает HIGH. Чинится пересборкой: Docker берёт свежий базовый образ, где уже u3, и проблема уходит без единой правки кода.

Прикинь сам: Образ собран 1 сентября, исправление openssl вышло 15-го. Сколько дней в твоём образе стоит уязвимая версия, если ты не пересобираешь?

Столько, сколько не пересобираешь: сам образ не обновится, пока ты не соберёшь его заново на свежей базе.

Осторожно, частое заблуждение: «Нашли уязвимость, значит мой код плох». Чаще всего дыра в чужом пакете, а твоё действие это обновление, а не переписывание. Второе заблуждение: «раз образ не менялся, он безопасен»: безопасность образа со временем падает сама.

Главное: исправления готовит мейнтейнер дистрибутива, а твоё действие это пересборка образа на свежей базе.

Чтобы понять отчёт сканера, нужно уметь сравнивать версии.

Проверь понимание: образ не пересобирали три месяца, сканер нашёл 5 новых HIGH. Кто виноват и что делать первым?

Ответ

Виноват не код, а возраст образа: база уязвимостей пополнилась. Первым делом пересобрать образ на свежем базовом образе (с --pull, чтобы Docker не взял устаревший локальный) и сканировать снова. Если находки остались, смотреть, есть ли у них исправление в колонке Fixed Version.

Как читать версии: почему 3.5.7-1~deb13u2 меньше 3.5.7-1~deb13u3

Вся работа сканера сводится к сравнению двух версий: «что стоит» и «с какой версии исправлено». Если не умеешь читать версии, таблица Trivy остаётся набором символов, и ты не поймёшь, действительно ли исправление уже у тебя.

Номер издания книги: «третье издание, исправленное, второе дополнение». Каждая часть номера что-то значит, и сравнивают их по очереди слева направо. Оговорка: у книг нет строгих правил, а у пакетов они есть и отличаются у разных менеджеров.

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

  • Debian: версия состоит из необязательной эпохи с двоеточием (1:), версии самой программы (1.3.1), дефиса и номера сборки Debian. Часть после ~ описывает выпуск Debian, для которого собрана правка: deb13u2 читается «Debian 13, обновление безопасности номер 2». Сравнивают по частям: сначала эпоху, потом версию программы, потом сборку. Символ ~ в Debian означает «раньше, чем без него»: 1.0~rc1 меньше 1.0.
  • Python (pip): версии вида 3.3.6 (мажорная, минорная, патч). Числа сравнивают по порядку и как числа: 70.3.0 меньше 78.1.1, потому что 70 меньше 78.

Теперь на числах. Из нашего скана: стоит 3.5.7-1~deb13u2, исправлено в 3.5.7-1~deb13u3. Версия программы одинаковая (3.5.7), номер сборки Debian тоже (1), а обновление безопасности u2 меньше u3. Значит, нам не хватает ровно одной последней правки. Для setuptools: 70.3.0 против 78.1.1: разница уже в мажорной версии, а сам пакет вложен в pip, поэтому лечится обновлением pip в базовом образе, а не отдельной установкой. Сравнивать версии на глаз опасно (1.10 новее 1.9, хотя выглядит наоборот), поэтому это делает Trivy: он применяет правила сравнения того менеджера, к которому относится пакет.

Прикинь сам: Стоит 1.9.4-2, исправлено в 1.10.0-1. Какая версия новее, 9 или 10?

10 новее: версии сравнивают по частям как числа. Значит 1.9.4 старее 1.10.0, и исправление ещё не установлено.

Осторожно: цифры версии программы не показывают, исправлена ли уязвимость.

  • «Версия программы та же, значит, уязвимость не исправлена». В Debian исправление часто «переносят» в старую версию программы, меняя только суффикс сборки (deb13u3), а 3.5.7 остаётся прежней. Смотреть надо на всю строку, а не на первые цифры.
  • «Обновлюсь до последней мажорной версии, там точно исправлено». Мажорное обновление может сломать совместимость: сначала пробуй патч из той же ветки.

Главное: версии сравнивают по частям слева направо как числа, а в Debian исправление может прийти только в номере сборки (deb13u3).

Проверь понимание: в таблице Installed 1.9.4-2, Fixed 1.10.0-1. Исправление уже стоит?

Ответ

Нет: 10 больше 9, значит, 1.10.0 новее 1.9.4. Версии сравнивают по частям как числа, а не как строки. Чтобы получить исправление, нужно обновить пакет до 1.10.0-1 или новее.

Теперь посмотрим, как сканер делает это за нас.

Trivy: сканер образов

Проверить руками сотни пакетов на сотни тысяч записей в списке уязвимостей невозможно. Сканер делает это за десятки секунд и одинаково каждый раз.

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

Как устроено, по шагам. Trivy (проект компании Aqua Security, курс использует v0.74.0) делает вот что:

  1. Скачивает базу уязвимостей (vulnerability database): свежий список CVE в удобном для машины виде. Делает это при первом запуске и потом обновляет. Файлы хранит в кэше, чтобы не качать заново.
  2. Распаковывает слои образа и определяет операционную систему по файлу /etc/os-release (например Debian 13.7).
  3. Ищет списки установленных пакетов. У Debian это файл /var/lib/dpkg/status, у Python - каталоги *.dist-info с файлами METADATA. Каждый пакет - пара «имя и версия».
  4. Сверяет каждую пару с базой: есть ли для этого пакета этой версии известная уязвимость и в какой версии она исправлена.
  5. Печатает таблицу и завершается с кодом выхода.
flowchart LR
    IMG["docker-образ"] --> L["слои"] --> PK["список пакетов<br>openssl 3.5.7-1~deb13u2<br>psycopg 3.3.6"] --> DB["сверка с базой CVE<br>openssl < 3.5.7-1~deb13u3 уязвим к CVE-2026-75804"] --> OUT["таблица + код выхода"]

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

Флаги, которые ты будешь использовать.

  • --severity HIGH,CRITICAL - показать только серьёзные находки. Низкие и средние тоже существуют, но по ним обычно не блокируют выкладку.
  • --ignore-unfixed - скрыть находки, для которых нет исправленной версии. Логика: с ними ты сделать ничего не можешь, а блокировать релиз из-за того, что не в твоих силах, бессмысленно. Такие находки смотрят отдельно.
  • --exit-code 1 - вернуть код выхода 1, если что-то найдено. Код выхода (exit code) - число, которое программа отдаёт системе при завершении: 0 значит «успех», любое другое «ошибка». CI смотрит именно на него (урок 1.6 про $?). Без этого флага Trivy всегда завершается с 0, даже если нашёл сто CRITICAL, и CI считает шаг успешным. Флаг превращает скан в шлюз.
  • --format table|json|cyclonedx|spdx-json - формат вывода: таблица для человека, остальные для машин и для SBOM.
  • trivy config - другой режим: проверка конфигурации (Dockerfile, манифесты Kubernetes, Terraform) на плохие настройки вроде запуска от root. trivy fs из урока 3.4 смотрит файлы репозитория, trivy image смотрит собранный образ.

Как запускать. Ставить Trivy на хост не нужно: есть официальный образ aquasec/trivy:0.74.0. Ему нужно два разрешения. Первое: доступ к Docker, чтобы он мог достать твой образ из локального хранилища. Для этого внутрь контейнера пробрасывают сокет Docker /var/run/docker.sock (файл, через который клиент разговаривает с демоном Docker). Второе: каталог для кэша базы, иначе она будет скачиваться при каждом запуске.

Здесь есть противоречие с уроком 4.1, где говорилось «не пробрасывай docker.sock в контейнеры»: доступ к сокету равен root на хосте. Правило остаётся верным для приложений. Мы даём сокет проверенному инструменту на своей учебной машине и на одноразовом CI-раннере, который удаляется после запуска. В рабочем кластере с чужими нагрузками так не делают: сканируют образы в реестре.

Прикинь сам: В образе 90 пакетов, в базе 300 000 записей. Сколько уязвимостей Trivy найдёт, запустив образ и «взломав» его?

Ни одной таким способом: Trivy образ не запускает. Он читает список пакетов и сверяет версии с базой.

Осторожно, тут часто путают.

Главное: Trivy читает файлы образа и сверяет пакеты с базой CVE; шлюзом он становится только с --exit-code 1.

Trivy смотрит на результат, а ошибки рецепта ловит другой инструмент.

  • Код выхода 0 у Trivy не значит «уязвимостей нет»: только «Trivy отработал без ошибок».
  • Пустая таблица не значит «безопасно», см. выше про предел сканера.
  • --ignore-unfixed не прячет проблему навсегда: когда исправление выйдет, находка появится в отчёте снова.

Исключения делают явно. Иногда находка есть, исправления нет, а выкладку блокировать нельзя. Тогда в файл .trivyignore.yaml записывают исключение с тремя вещами: номер уязвимости, причина (statement) и срок (expired_at). Когда срок проходит, Trivy перестаёт исключение учитывать, находка возвращается в отчёт, и кто-то обязан снова принять решение (мы это проверим руками). Молчаливое «добавлю в игнор, чтобы позеленело» без причины и срока - худшее, что можно сделать: через полгода никто не вспомнит, почему.

Проверь понимание: пайплайн зелёный, хотя в логе Trivy видна таблица с CRITICAL. Какая ошибка в настройке?

Ответ

Не указан --exit-code 1: по умолчанию Trivy печатает находки, но завершается с кодом 0, и CI считает шаг успешным. Нужно --exit-code 1 --severity HIGH,CRITICAL, а шум сокращать через --ignore-unfixed.

Hadolint: линтер Dockerfile

Trivy смотрит на результат, но есть проблемы, которые видны только в рецепте: Dockerfile без USER, установка без очистки кэша, ADD вместо COPY. Они не всегда дают уязвимость, но делают образ больше, сборку хрупче, а запуск опаснее.

Корректор проверяет текст, не запуская его: не «правда ли это», а «написано ли по правилам». Линтер (linter) - программа, которая читает исходный файл и ищет в нём плохие приёмы по списку правил, ничего не выполняя.

Hadolint (Haskell Dockerfile Linter, курс использует v2.15.1) разбирает Dockerfile на инструкции и проверяет каждую по правилам. У каждого правила есть код: DL3008 (DL от Dockerfile Linter) и текст. Для команд внутри RUN он вызывает ещё и ShellCheck (проверка shell-скриптов), его правила начинаются с SC. У находки есть уровень: error, warning, info, style (от серьёзного к рекомендации).

Правила, которые встретишь в этом уроке:

Код О чём Почему плохо
DL3006 у FROM нет тега python значит «какой-то самый свежий», завтра сборка даст другое
DL3008 apt-get install без версий пакетов сборка не воспроизводится, пакет завтра будет другим
DL3009, DL3015 не очищены списки apt, нет --no-install-recommends образ раздувается лишними файлами
DL3013, DL3042 pip без версий, без --no-cache-dir то же плюс кэш в слое
DL3020 ADD вместо COPY ADD умеет скачивать по ссылкам и распаковывать архивы, это лишняя магия
DL3025 CMD python app.py без квадратных скобок shell-форма, см. ниже
DL3002 последний USER - root контейнер работает от root

Про DL3025 подробнее, потому что эта ошибка касается сигналов из урока 1.4. У CMD две формы. Exec-форма: CMD ["python", "app.py"] (список в квадратных скобках): Docker запускает python напрямую, он становится PID 1 и сам получает SIGTERM при docker stop. Shell-форма: CMD python app.py: Docker запускает /bin/sh -c "python app.py", PID 1 - это оболочка, а она сигнал дальше не передаёт. В итоге docker stop ждёт 10 секунд и убивает контейнер SIGKILL, без корректного завершения. То же относится к CMD внутри HEALTHCHECK, там shell-форма тоже лишний запуск оболочки на каждую проверку.

Исключения правил и файл .hadolint.yaml. Иногда правило приходится отключить осознанно. Например, DL3008 требует закрепить версию каждого apt-пакета, а старые версии из репозитория Debian исчезают: сборка сломается без единого изменения в коде. Отключение записывают в файл .hadolint.yaml с комментарием «почему», а не молча в командной строке: файл лежит в репозитории, проходит ревью и переживёт того, кто его написал. Ещё один полезный ключ: failure-threshold: warning говорит «падать на warning и error, не падать на info и style».

Разделение труда.

Инструмент Что читает Когда Пример находки
Hadolint Dockerfile (рецепт) до сборки, секунды ADD вместо COPY, shell-форма CMD
trivy config Dockerfile, манифесты Kubernetes, Terraform до сборки USER root, правило DS-0002
trivy image собранный образ (результат) после сборки уязвимый openssl

Уязвимость в openssl не видна в Dockerfile, а USER root не видна в списке пакетов. Поэтому нужны оба вида проверок. Hadolint и trivy config частично пересекаются (оба ловят root), это нормально: два независимых проверяющих лучше одного.

Прикинь сам: В Dockerfile 20 инструкций, и в двух есть нарушения. Сколько из них найдёт Trivy по пакетам образа?

Скорее ни одного: Trivy смотрит на пакеты, а правила записи ловит Hadolint. Поэтому проверки не заменяют друг друга.

Осторожно: Hadolint не проверяет безопасность образа: чистый отчёт линтера говорит только о том, что Dockerfile написан по правилам. А отсутствие USER в Dockerfile Hadolint по умолчанию не ловит (правило DL3002 срабатывает только на явное USER root), зато trivy config ловит оба случая.

Главное: Hadolint читает рецепт (Dockerfile) до сборки, Trivy читает результат (образ) после сборки, и нужны оба.

Дальше научимся составлять опись образа, чтобы искать по ней быстро.

Проверь понимание: зачем и Hadolint, и Trivy, если оба «что-то сканируют»?

Ответ

Hadolint читает исходник (Dockerfile) и находит плохие практики до сборки. Trivy читает результат (слои образа) и находит уязвимые пакеты, о которых Dockerfile не говорит. Один без другого оставляет слепую зону.

SBOM: опись образа

Представь: в понедельник объявили уязвимость в библиотеке zlib, которая сидит почти везде. У вас 40 образов. Как узнать, в каких из них она есть? Пересобирать и сканировать всё - долго, а какие-то образы вообще собраны год назад из исходников, которых уже нет.

Пример из жизни отрасли: в декабре 2021 года нашли уязвимость Log4Shell (CVE-2021-44228) в Java-библиотеке log4j. Компании неделями выясняли, где эта библиотека у них используется, потому что никто не знал точного состава своих программ.

Состав на упаковке продукта. Не надо вскрывать упаковку и делать анализ, достаточно прочитать этикетку. SBOM (Software Bill of Materials, спецификация программных компонентов) - этикетка образа в машиночитаемом виде: список всех пакетов, версии, лицензии и откуда они взяты.

Есть два распространённых формата описи: CycloneDX и SPDX. Оба - обычные JSON-файлы (или XML). Trivy умеет получить их из образа: --format cyclonedx или --format spdx-json. Внутри CycloneDX главное - массив components, и у каждого компонента есть имя, версия, тип и purl (package URL, единый формат ссылки на пакет).

Настоящий кусок из SBOM образа этого урока (одна запись, пакет zlib1g):

{
  "name": "zlib1g",
  "version": "1:1.3.dfsg+really1.3.1-1+b1",
  "type": "library",
  "purl": "pkg:deb/debian/zlib1g@1.3.dfsg%2Breally1.3.1-1%2Bb1?arch=arm64&distro=debian-13.7&epoch=1",
  "licenses": [{ "license": { "id": "Zlib" } }]
}

Разбор: name и version - что за пакет. type: library - тип компонента. purl начинается с pkg:deb/debian/ (пакет менеджера deb, дистрибутив Debian), у Python-пакетов будет pkg:pypi/. licenses - под какой лицензией распространяется (для юристов и аудиторов).

Ответ на понедельничный вопрос про zlib выглядит как поиск по файлам, без пересборки. Если SBOM каждого релиза лежит рядом с образом, то jq (программа для разбора JSON, урок 1.2 про пайпы) находит нужное в одну команду:

jq -r '.components[] | select(.name | test("zlib")) | "\(.name) \(.version)"' sbom.cdx.json

Разбор команды: jq -r печатает строки без кавычек; .components[] берёт каждый компонент; select(.name | test("zlib")) оставляет только те, у которых в имени есть zlib; "\(.name) \(.version)" печатает имя и версию.

SBOM ещё одним способом полезен: по нему можно просканировать состав без самого образа: trivy sbom sbom.cdx.json. Опись хранят как артефакт релиза рядом с образом (в CI это upload-artifact, мы сделаем его в конце урока). Подпись образа (cosign) и provenance (происхождение сборки: кем и из чего собрано) - следующие шаги той же цепочки поставки (supply chain), они разбираются в теме 9.

Прикинь сам: У вас 40 образов, объявили CVE в zlib. Сколько образов придётся пересобирать, чтобы найти затронутые, если у каждого релиза есть SBOM?

Ни одного: достаточно поискать zlib по описям командой jq или grep.

Осторожно: SBOM - это не отчёт об уязвимостях, а просто список состава. Уязвимости из него получают, сверяя список с базой. Второе заблуждение: «SBOM и Dockerfile одно и то же». Dockerfile описывает, как собирать, а SBOM - что в итоге получилось, включая зависимости зависимостей, которых в Dockerfile нет.

Главное: SBOM (CycloneDX или SPDX) это JSON с составом образа, и по нему ищут затронутые образы без пересборки.

Теперь про то, как сузить последствия уязвимости при запуске.

Проверь понимание: в понедельник объявили CVE в zlib. Как за 5 минут найти, какие из 40 ваших образов затронуты?

Ответ

Если для каждого релиза сохранён SBOM, ищем zlib по всем описям (grep или jq), а не пересобираем и сканируем 40 образов. Затем trivy sbom по найденным файлам подтверждает версию, и пересборка запускается только для затронутых.

Минимум прав при запуске

Уязвимость в приложении даёт атакующему возможность выполнить команды от имени процесса приложения. Насколько это плохо, зависит от того, что этому процессу разрешено. Контейнер работает на общем ядре с хостом, а изоляция (namespaces, урок 4.1) не абсолютная. Цель: чтобы после взлома приложения атакующий оказался в комнате без ключей, без возможности что-то записать и без пути наверх.

Сотрудник в офисе. Мастер-ключ от всех помещений (root) выдают только тому, кому он действительно нужен. Обычному сотруднику дают ключ от его кабинета. Ещё лучше: шкафы в кабинете заперты, чтобы нельзя было переложить документы (--read-only), а пропуск нельзя «повысить» без администратора (no-new-privileges).

Как устроено: пять защит на docker run.

  1. Не root. В образе есть USER 10001:10001 (урок 4.2): процесс работает под обычным uid, а не под 0. Проверка: docker run --rm notes:0.4.0 id. Это самая дешёвая защита и самая важная.
  2. --read-only. Корневая файловая система контейнера (то, что лежит в самом образе: /app, /usr, /etc) становится только для чтения. Атакующий не может дописать вредоносный файл или подменить app.py. Приложению нужно писать данные и временные файлы, поэтому для этого явно дают отдельные места: том (-v notes-data:/data) для данных и --tmpfs /tmp для временных файлов. tmpfs - каталог в оперативной памяти, он исчезает вместе с контейнером.
  3. --cap-drop ALL. Capabilities (возможности) - это «права root, нарезанные на кусочки». Вместо «может всё или ничего» ядро Linux делит полномочия root на десятки отдельных: CHOWN (менять владельца файлов), NET_BIND_SERVICE (слушать порты меньше 1024), SYS_ADMIN (почти всё остальное) и так далее. Docker по умолчанию оставляет процессам контейнера около 14 из них. --cap-drop ALL убирает все, а нужную можно вернуть точечно: --cap-add NET_BIND_SERVICE.
  4. --security-opt no-new-privileges. В Linux есть файлы с особым битом setuid: программа, запущенная обычным пользователем, работает с правами владельца файла (обычно root). Так устроены su, passwd, mount. Это нужно системе, но если атакующий найдёт ошибку в такой программе, он поднимется до root. Флаг запрещает процессу и его потомкам получать больше прав через setuid, ядро просто игнорирует бит.
  5. Не --privileged и не docker.sock внутрь. --privileged снимает почти всю изоляцию, а сокет Docker даёт управление всем демоном, то есть root на хосте (урок 4.1).

Схема слоёв защиты («глубокая защита», defense in depth): если сломался один слой, остаются другие.

flowchart TB
    A["атакующий нашёл ошибку в приложении<br>и выполнил команду в контейнере"] --> R1["не root (uid 10001)<br>нельзя ставить пакеты, читать чужое"]
    A --> R2["--read-only<br>нельзя изменить код и записать вредонос"]
    A --> R3["--cap-drop ALL<br>нет кусочков root: смена владельца, raw-сокеты"]
    A --> R4["no-new-privileges<br>setuid-программы не поднимут права"]
    A --> R5["нет docker.sock и --privileged<br>нет пути к управлению хостом"]

Разобранные эксперименты с настоящими значениями. Мы сравнили запуск с защитой и без. Нулевой CapEff (effective, действующие права процесса) у процесса под uid 10001 есть уже по умолчанию, а различается CapBnd (bounding set, «потолок»: то, что процесс вообще мог бы получить):

запуск                     CapEff             CapBnd
обычный, uid 10001         0000000000000000   00000000a80425fb
--cap-drop ALL, uid 10001  0000000000000000   0000000000000000
обычный, root (uid 0)      00000000a80425fb   00000000a80425fb
--cap-drop ALL, root       0000000000000000   0000000000000000

Значения в шестнадцатеричной записи (маска: каждый бит - одна capability). a80425fb - набор Docker по умолчанию (14 штук), нули - пусто. Вывод: для не-root процесса главное - обнулить «потолок»: тогда ничто и никогда не вернёт права. Для root --cap-drop ALL меняет всё сразу.

Ещё одна проверка, которая ломает популярное заблуждение. Считают, что без NET_BIND_SERVICE приложение не сможет слушать порт 80. Мы проверили: в Docker на этой машине привязка к порту 80 удалась у всех четырёх вариантов, даже у не-root с --cap-drop ALL. Причина: Docker внутри контейнера устанавливает net.ipv4.ip_unprivileged_port_start=0, то есть «привилегированных» портов там нет. На обычном Linux-сервере (не в контейнере) порог 1024, и там порт 80 без прав не откроется. Поэтому в Kubernetes и других средах поведение может отличаться, а внутри Docker NET_BIND_SERVICE для порта 80 не нужен.

И про setuid: в образе python:3.13-slim нашлось восемь setuid-программ (su, mount, passwd, gpasswd, chsh, chfn, newgrp, umount). Приложению они не нужны, но лежат в образе. no-new-privileges делает их безвредными для взломщика.

Прикинь сам: Атакующий выполнил команду в контейнере. Сколько защит остаётся, если приложение не root, но запущено без --read-only?

Остаются другие слои (--cap-drop ALL, no-new-privileges, нет docker.sock). Поэтому защиту строят слоями.

Осторожно, тут часто путают.

Главное: права урезают пять защит на docker run: не root, --read-only, --cap-drop ALL, no-new-privileges, без --privileged и docker.sock.

Проверки должен делать автомат, а не человек.

  • «Я не root в образе, значит --cap-drop не нужен». Не нужен для работы, но это ещё один слой: если кто-то запустит образ с -u 0, права всё равно будут урезаны.
  • «--read-only сломает приложение». Ломает только то, что пишет в неожиданные места. Ошибка Read-only file system в логе показывает, куда именно, и ты даёшь этому пути том или tmpfs, а защиту не снимаешь.

Проверь понимание: приложение запущено с --read-only и отвечает 500, в логе OSError: [Errno 30] Read-only file system. Что делать: снять --read-only?

Ответ

Нет. Найти, куда приложение пишет (docker logs, путь в сообщении об ошибке), и дать только этот путь: том для данных или --tmpfs для временных файлов. Защита остаётся, добавляется узкое исключение.

Шлюз в CI: порядок шагов и что делать с красным

Все проверки выше - вручную. Настоящая защита начинается, когда их делает автомат и не даёт плохому образу уехать в реестр. Это шлюз (gate): шаг пайплайна (CI запускается автоматически при событии, у нас - при пуше тега v*), который останавливает дальнейшие шаги при красном результате.

Порядок шагов имеет значение. Что будет, если сначала docker push, а потом скан? Образ уже опубликован: его успеют скачать, красный CI ничего не отменит. Правильный порядок:

flowchart LR
    H["Hadolint<br>дёшево, до сборки"] --> B["сборка в локальный Docker<br>БЕЗ push"] --> T["Trivy<br>шлюз"] --> S["SBOM как артефакт"] --> L["вход в реестр"] --> P["push"]

Ключевое слово - «без push»: docker/build-push-action с load: true и push: false кладёт собранный образ в локальный Docker раннера, Trivy сканирует его оттуда, а опубликован он будет только последним шагом.

Что делать, когда шлюз красный. Красный шлюз - не сбой, а сообщение «посмотри». Порядок действий:

  1. Открой таблицу и найди Fixed Version. Есть исправление - иди к шагу 2. Нет - к шагу 4.
  2. Пересобери на свежей базе: docker build --pull (флаг --pull заставляет Docker заново проверить базовый образ, а не брать из локального кэша старый). Если мейнтейнеры образа python уже выпустили обновление, находка исчезнет.
  3. Если база ещё не обновлена, а исправление уже есть в репозитории Debian, добавь в итоговую стадию Dockerfile apt-get upgrade (обновить установленные пакеты). Цена: сборка перестаёт быть строго воспроизводимой (завтра обновится другое), зато закрыто окно между выходом исправления и пересборкой базового образа.
  4. Исправления нет или оно не про нас: оцени, вызывается ли уязвимый код, и запиши исключение в .trivyignore.yaml с причиной и сроком пересмотра. Это последний вариант, а не первый.
  5. Профилактика: бот вроде Dependabot сам открывает Pull Request при выходе нового тега базового образа, а плановый скан (cron в CI) находит новые CVE в опубликованных образах, пока никто ничего не собирает.

Прикинь сам: Если сначала сделать docker push, а потом скан, сколько времени у людей будет на скачивание плохого образа?

Столько, сколько он лежит в реестре до отзыва: на практике достаточно минут. Поэтому скан идёт до push.

Осторожно, частое заблуждение: «Заигнорю, чтобы пайплайн позеленел» без причины и срока. И наоборот: «блокируем всё, включая LOW и находки без исправления», и через неделю все отключают шлюз, потому что он мешает. Хороший шлюз узкий: HIGH,CRITICAL, --ignore-unfixed, исключения с датой.

Главное: сначала Hadolint, потом сборка без push, потом Trivy как шлюз и только потом вход в реестр и push.

Больше всего находок даёт база, давай выберем её осознанно.

Проверь понимание: в CI шаг Trivy красный, а исправленной версии в таблице нет. Что делаешь?

Ответ

Не отключаю шаг и не игнорирую молча. Оцениваю, достижим ли уязвимый код из нашего приложения. Если нет или риск приемлем, записываю CVE в .trivyignore.yaml с причиной и датой пересмотра (expired_at), после которой находка вернётся в отчёт. Если риск неприемлем, меняю базовый образ или смягчаю запуском (--read-only, минимум прав, сеть).

Базовый образ: с чего начинается риск

Зачем это выбирать осознанно. Больше всего находок приходит из базового образа (той строки после FROM, с которой начинается Dockerfile, урок 4.2). Чем больше в нём пакетов, тем больше шансов, что в каком-то из них найдётся уязвимость. Выбор базы - самое дешёвое решение по безопасности: один раз выбрал, и не надо потом разбирать сотни находок.

Кладовка. Чем больше в ней лежит вещей «на всякий случай», тем больше вероятность, что что-то из них испортится, а хозяину придётся это проверять. Иногда проще держать в кладовке только то, что нужно для готовки.

Для Python типичны четыре семейства баз:

  • Полный образ (python:3.13): Debian с компиляторами, git и сотнями утилит. Удобно собирать, но пакетов много, а значит, находок много.
  • Slim (python:3.13-slim): тот же Debian без лишнего. Пакетов около сотни (у нас 87), находок заметно меньше. Наш выбор в курсе.
  • Alpine (python:3.13-alpine): другой дистрибутив, очень маленький, вместо стандартной библиотеки glibc использует musl. Находок ещё меньше, но у части Python-пакетов с готовыми бинарными сборками (те самые psycopg[binary]) бывают проблемы совместимости, и сборка из исходников затягивается.
  • Distroless (образы без оболочки и без менеджера пакетов, только программа и её библиотеки): почти нет, что атаковать, но и отлаживать внутри нечем: нет ни sh, ни ls. Для новичка это лишняя сложность, поэтому в курсе не используется, но на собеседованиях о нём спрашивают.

Разберём пример. В нашем образе 87 пакетов Debian, из них скан выделил один пакет openssl (в трёх видах: libssl3t64, openssl, openssl-provider-legacy). Заметь, что приложению «Заметки» нужна только библиотека, openssl как командная утилита не нужна, но лежит рядом. Если бы в образе не было лишних пакетов, часть находок просто не появилась бы. Именно поэтому в уроке 4.7 мы разделили образ на две стадии: компиляторы и заголовочные файлы остались в стадии сборки и в итоговый образ не попали.

Прикинь сам: python:3.13 имеет сотни пакетов, slim около 87. Что даст больше находок и почему?

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

Осторожно, тут часто путают.

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

Теперь о том, что остаётся в слоях.

  • «Alpine всегда безопаснее». Он меньше, но сканер у него тоже находит уязвимости, а совместимость с бинарными пакетами Python хуже. Безопасность определяют размер поверхности и скорость получения обновлений, а не название.
  • «latest свежее, значит лучше». Тег latest меняется без предупреждения: сегодня одна сборка, завтра другая, воспроизвести вчерашнюю нельзя. В курсе везде закреплены конкретные теги.
  • «Закреплю тег и забуду». Закреплённый тег не обновляется сам: python:3.13-slim получает новые сборки, а вот python:3.9.5-slim не получает вообще. Поэтому закреплённая база требует регулярной пересборки.

Проверь понимание: зачем в multi-stage-сборке из урока 4.7 итоговый образ строят с нуля на python:3.13-slim, а не берут стадию сборки целиком?

Ответ

В стадии сборки лежат компиляторы, заголовочные файлы и кэши: они нужны только для установки пакетов. Каждый лишний пакет - это дополнительная строка в отчёте сканера и дополнительная поверхность для атаки. В итоговый образ переносят только готовое виртуальное окружение и код.

Слои и что в них остаётся: секреты и история

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

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

Вспомни из урока 4.2: каждая инструкция RUN, COPY, ADD создаёт слой, и слой после создания уже не меняется. Инструкция RUN rm secret.txt не «стирает» файл, а создаёт новый слой с пометкой «здесь файла больше нет». Предыдущий слой с самим файлом остаётся внутри образа, и его можно извлечь. То же касается ENV и ARG: значение попадает в метаданные образа, и docker history его покажет.

flowchart TB
    L1["слой 1: COPY .env /app/.env<br>файл с паролем записан в образ"] --> L2["слой 2: RUN rm /app/.env<br>новая пометка: файла нет, слой 1 не тронут"]
    L2 --> R["итог: в контейнере файла не видно,<br>но в слое 1 он лежит целиком"]

Что делать: не класть секреты в образ вообще, а передавать при запуске (переменные окружения, том, менеджер секретов, тема 6). Если секрет нужен на время сборки (например, токен для скачивания приватной библиотеки), используют RUN --mount=type=secret,id=имя ...: секрет виден только одному шагу и в слой не попадает. Нужен BuildKit, это сборщик образов по умолчанию в современном Docker.

Посмотрим на примере. Проверить самому можно так: docker history --no-trunc notes:0.4.0 печатает все инструкции, из которых собран образ, в том числе значения ENV. Если увидишь там пароль, токен или ключ, образ считается скомпрометированным. Одного удаления образа из реестра недостаточно: его уже могли скачать. Секрет считают утёкшим и меняют. Trivy при сканировании образа ещё и ищет секреты по шаблонам (ключи облаков, токены), в логе первого запуска была строка Secret scanning is enabled. Она включена по умолчанию, но не заменяет дисциплину: часть секретов у неё не распознаётся.

Прикинь сам: COPY .env в слое 1, RUN rm .env в слое 2. Сколько слоёв содержат пароль?

Один (первый): слой неизменяем, а rm добавляет слой с пометкой, что файла нет.

Осторожно, тут часто путают.

Главное: секрет, попавший в слой, остаётся в образе навсегда, а .dockerignore это первая линия защиты.

Когда находок много, нужны приоритеты.

  • «Удалил файл следующей командой, значит его нет». Файла нет в файловой системе контейнера, но он есть в слое.
  • «Секрет в ARG, а не в ENV, поэтому безопасно». ARG тоже остаётся в истории сборки.
  • «.dockerignore не нужен». Он не даёт COPY . . захватить в образ .env, .git и прочее лишнее: это первая линия защиты от случайных секретов (урок 4.2).

Проверь понимание: в Dockerfile есть COPY .env /app/ и следом RUN rm /app/.env. Утёк ли пароль?

Ответ

Да. Инструкция RUN rm создаёт новый слой, а слой с COPY остаётся в образе и читается. Правильно: не копировать файл вообще, передавать секрет при запуске или использовать RUN --mount=type=secret. А если образ уже опубликован, считать пароль утёкшим и заменить.

Приоритеты: какие находки чинить первыми

Через месяц работы у тебя будут десятки находок в разных образах. Если считать каждую срочной, команда выгорит и перестанет смотреть отчёты. Значит, нужны способы отличить «горит» от «подождёт».

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

Оценка приоритета складывается из нескольких вопросов:

  1. Уровень по CVSS. Первое приближение. CRITICAL и HIGH разбираем всегда, MEDIUM и LOW пачкой, по расписанию.
  2. Есть ли исправление (Fixed Version). Без исправления мы можем только смягчить.
  3. Достижим ли уязвимый код. Уязвим ли компонент, который наше приложение действительно использует? DTLS-уязвимость в openssl не задевает приложение, которое DTLS не включает. Это решает человек, вооружённый знанием приложения. Иногда для этого смотрят описание уязвимости и ищут, какой функции она касается.
  4. Где работает контейнер. Сервис, доступный из интернета, важнее внутреннего инструмента. Контейнер с ограниченными правами (--read-only, без capabilities) хуже поддаётся эксплуатации.
  5. Используется ли уязвимость злоумышленниками. Есть публичные списки активно эксплуатируемых уязвимостей (например, каталог KEV от американского агентства CISA) и оценка вероятности эксплуатации (EPSS). Находка из такого списка идёт вне очереди, даже если формально у неё MEDIUM.

Вот как это выглядит на деле. Наши шесть находок в openssl (по трём пакетам и двум CVE) имеют HIGH и статус fixed: одна команда apt-get upgrade закрывает все шесть, и повода спорить нет: чиним. Две находки в pip (msgpack, setuptools) тоже HIGH, но лежат внутри вложенных копий, нужных только при сборке, а не при работе сервиса: исправление не в нашей власти (его нужно ждать от авторов pip), а уязвимый код в рантайме не используется. Это как раз повод для исключения в .trivyignore.yaml с причиной и сроком. Одинаковая серьёзность, разные решения, потому что ответы на вопросы 2 и 3 разные.

Прикинь сам: Находок 40: 5 HIGH с исправлением и 35 MEDIUM. С каких начинать?

С 5 HIGH, у которых есть Fixed Version: они дают максимум эффекта за минимум усилий. MEDIUM идут пачкой по расписанию.

Осторожно, тут часто путают.

Главное: приоритет задают уровень CVSS, наличие исправления, достижимость, место запуска и активная эксплуатация (каталог KEV).

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

  • «CVSS 9,8, значит, у нас катастрофа». CVSS описывает худший случай для уязвимого компонента вообще, а не для нашей установки.
  • «MEDIUM можно игнорировать всегда». Если эту уязвимость активно эксплуатируют, она срочная, независимо от оценки.
  • «Исключил один раз, больше не думаю». Поэтому у исключения есть срок: через две недели вопрос вернётся и его надо решить заново.

Проверь понимание: две находки с одинаковым HIGH: в openssl (есть исправление) и во вложенной библиотеке pip (исправления нет). Что делаешь с каждой?

Ответ

openssl: обновляю пакеты (docker build --pull или apt-get upgrade в итоговой стадии) и проверяю повторным сканом. Библиотека в pip: исправить нечем, оцениваю достижимость (в работе сервиса не участвует), записываю исключение в .trivyignore.yaml с причиной и сроком пересмотра и жду обновления базового образа.

Плановый скан и обновление базы

Скан в CI проверяет образ один раз, в момент сборки. Но, как мы выяснили, новые CVE появляются позже. Образ, опубликованный три недели назад и работающий на сервере, может стать уязвимым, а CI за это время не запускался ни разу.

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

Два механизма, которые обычно ставят вместе:

  • Плановый скан (scheduled scan): задача CI запускается по расписанию (on: schedule с выражением cron, например «каждый понедельник в 6 утра»), скачивает опубликованный образ из реестра и сканирует его свежей базой уязвимостей. Красный результат превращается в уведомление или задачу.
  • Автоматические обновления базы: бот вроде Dependabot следит за тегами базовых образов в Dockerfile (и за зависимостями requirements.txt) и открывает Pull Request, когда выходит новая версия. Ты смотришь diff, CI проверяет сборку и скан, и после слияния образ пересобирается.
flowchart TB
    C["новая CVE опубликована"] --> S["плановый скан (cron) находит её<br>узнаём сами, не ждём релиза"]
    C --> D["Dependabot открывает PR с новой базой<br>получаем готовое исправление"]
    S --> CI["CI: Hadolint, сборка, Trivy, push"]
    D --> CI

В рамках курса плановый скан отдельным заданием мы не пишем: для этого достаточно добавить в on: блок schedule и заменить сборку на docker pull. Смысл в том, чтобы понимать: проверка при сборке и проверка «сейчас» отвечают на разные вопросы. Первая говорит «мы не выпустили заведомо плохое», вторая «то, что выпустили, всё ещё хорошее».

Прикинь сам: Образ опубликован 21 день назад, CI не запускался. Сколько новых CVE ты увидишь без планового скана?

Ни одной: скан в CI проверяет образ только в момент сборки. Нужен запуск по расписанию.

Осторожно, частое заблуждение: «У нас есть скан в CI, значит, образы в продакшене проверены». Проверены в день сборки, а не сегодня. Второе заблуждение: «плановый скан сам всё исправит». Он только сообщает. Исправляет человек, пересобирая образ.

Главное: плановый скан по cron и бот-обновление базы (Dependabot) закрывают дыру между релизами.

Проверь понимание: образ опубликован 3 недели назад, CI с тех пор не запускался. Как узнать о новых уязвимостях в нём?

Ответ

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

Podman: то же самое без демона

Podman (проект Red Hat) - альтернатива Docker. Чем отличается: у него нет постоянного фонового демона (Docker работает через демон dockerd, у которого права root), а по умолчанию контейнеры запускаются без root (rootless): процесс работает от твоего обычного пользователя, а «root внутри контейнера» на хосте оказывается непривилегированным uid. Команды почти совпадают: podman run, podman build, podman ps. Dockerfile и образы совместимы, поэтому Trivy, Hadolint, SBOM и флаги прав из этого урока работают и там. В семействе RHEL (Red Hat, Rocky, Alma) Podman стоит из коробки, поэтому о нём спрашивают на собеседованиях. Отдельного урока нет и в проекте «Заметки» его файлов не будет: на Podman ничего из этого урока не запускалось.

Практика

Рабочий каталог для экспериментов: ~/lab48, проект ~/notes трогаем только в последнем задании. Нужен собранный образ notes:0.4.0 из урока 4.7. Если его нет: cd ~/notes && docker build -t notes:0.4.0 ..

Важно про выводы в уроке: они получены на реальном запуске 30 сентября 2026. Пакеты, номера CVE и их количество у тебя будут другими: база уязвимостей обновляется каждый день. Ориентируйся на форму отчёта и на смысл колонок, а не на конкретные числа.

Задание 1. Читаем отчёт Trivy

Цель: просканировать образ, отличить критичное от шума и найти, что можно исправить.

Предскажи: (1) у python:3.9.5-slim (образ, собранный в 2021 году) будет больше или меньше находок HIGH и CRITICAL, чем у notes:0.4.0? (2) какой код выхода вернёт Trivy без --exit-code?

Ответ

(1) Заметно больше: база устарела, а после неё вышли сотни исправлений. (2) 0: без --exit-code Trivy не считает находки ошибкой.

Шаги:

  1. Подготовь каталог и каталог для кэша базы уязвимостей (чтобы не качать её при каждом запуске). mkdir -p создаёт каталоги, и не жалуется, если они уже есть; ~ - твой домашний каталог:
mkdir -p ~/lab48 ~/.cache/trivy
cd ~/lab48
  1. Просканируй свой образ. Сначала разбор команды:
  • docker run --rm - запустить контейнер и удалить его после завершения;
  • -v /var/run/docker.sock:/var/run/docker.sock - пробросить в контейнер сокет Docker, чтобы Trivy достал образ (о рисках сказано в теории);
  • -v ~/.cache/trivy:/root/.cache/trivy - подключить каталог кэша: путь слева на твоём компьютере, справа внутри контейнера;
  • aquasec/trivy:0.74.0 - образ Trivy с закреплённой версией;
  • image --severity HIGH,CRITICAL --ignore-unfixed notes:0.4.0 - сама команда Trivy: просканировать образ, показать только HIGH и CRITICAL и только с доступным исправлением;
  • echo "код выхода: $?" - $? хранит код выхода предыдущей команды.

Первый запуск скачает базу уязвимостей (около 120 МБ, минута-две), а кэш вырастет: в него ещё попадают слои сканируемых образов, и за урок он может занять гигабайт и больше (в конце удалим).

docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v ~/.cache/trivy:/root/.cache/trivy \
  aquasec/trivy:0.74.0 image --severity HIGH,CRITICAL --ignore-unfixed notes:0.4.0
echo "код выхода: $?"

Сначала Trivy пишет служебные строки (при первом запуске о скачивании базы):

2026-09-30T12:12:37Z	INFO	[vulndb] Need to update DB
2026-09-30T12:12:37Z	INFO	[vulndb] Downloading vulnerability DB...
2026-09-30T12:13:45Z	INFO	[secret] Secret scanning is enabled
2026-09-30T12:13:47Z	INFO	Detected OS	family="debian" version="13.7"
2026-09-30T12:13:47Z	INFO	[debian] Detecting vulnerabilities...	os_version="13" pkg_num=87

Потом сводка (строки с 0 находок сокращены):

Report Summary

┌────────────────────────────────────────────────┬────────────┬─────────────────┬─────────┐
│                     Target                     │    Type    │ Vulnerabilities │ Secrets │
├────────────────────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ notes:0.4.0 (debian 13.7)                      │   debian   │        6        │    -    │
├────────────────────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ Python                                         │ python-pkg │        2        │    -    │
├────────────────────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ opt/venv/lib/python3.13/site-packages/...      │ python-pkg │        0        │    -    │
└────────────────────────────────────────────────┴────────────┴─────────────────┴─────────┘

Затем таблица по системным пакетам (колонка Title сокращена, в терминале она шире):

notes:0.4.0 (debian 13.7)
=========================
Total: 6 (HIGH: 6, CRITICAL: 0)

┌─────────────────────────┬────────────────┬──────────┬────────┬───────────────────┬─────────────────┐
│         Library         │ Vulnerability  │ Severity │ Status │ Installed Version │  Fixed Version  │
├─────────────────────────┼────────────────┼──────────┼────────┼───────────────────┼─────────────────┤
│ libssl3t64              │ CVE-2026-75804 │ HIGH     │ fixed  │ 3.5.7-1~deb13u2   │ 3.5.7-1~deb13u3 │
│                         │ CVE-2026-84782 │          │        │                   │                 │
├─────────────────────────┼────────────────┤          │        │                   │                 │
│ openssl                 │ CVE-2026-75804 │          │        │                   │                 │
│                         │ CVE-2026-84782 │          │        │                   │                 │
├─────────────────────────┼────────────────┤          │        │                   │                 │
│ openssl-provider-legacy │ CVE-2026-75804 │          │        │                   │                 │
│                         │ CVE-2026-84782 │          │        │                   │                 │
└─────────────────────────┴────────────────┴──────────┴────────┴───────────────────┴─────────────────┘

Python (python-pkg)
===================
Total: 2 (HIGH: 2, CRITICAL: 0)

│ msgpack    │ GHSA-6v7p-g79w-8964 │ HIGH │ fixed │ 1.1.2  │ 1.2.1  │
│ setuptools │ CVE-2025-47273      │ HIGH │ fixed │ 70.3.0 │ 78.1.1 │

И последняя строка от echo:

код выхода: 0

Как читать вывод:

  • Report Summary - сводка «что просканировано»: операционная система образа (debian 13.7, по ней выбирается набор пакетов) и каждый найденный список Python-пакетов. - в колонке Secrets значит «не проверялось в этой сводке».
  • Total: 6 (HIGH: 6, CRITICAL: 0) - итог по разделу. Одну и ту же уязвимость Trivy показывает отдельной строкой для каждого пакета, где она нашлась: здесь три пакета из одной сборки openssl и по две уязвимости, поэтому 3 × 2 = 6.
  • Library - пакет; Vulnerability - номер CVE (или GHSA- у GitHub); Severity - уровень; Status: fixed - исправление есть.
  • Installed Version против Fixed Version - что стоит и с какой версии исправлено. Если они разные и статус fixed, лечение - обновить пакет, то есть пересобрать образ на свежей базе.
  • Раздел Python - это не твой код, а копии библиотек, вложенные в сам pip (msgpack и setuptools перечислены в файле pip/_vendor/vendor.txt). Опасность у них ограниченная: pip нужен при сборке, а не при работе сервиса.
  • Строка WARN Using severities from other vendors в логе значит, что для части записей уровень взят не из Debian, а из другого источника: обычная информационная строка.
  • Код выхода 0 при шести HIGH: Trivy не считает находки ошибкой без --exit-code.
  1. Сравни со старым образом и проверь код выхода шлюза (--exit-code 1). Здесь уровень только CRITICAL, флаг --exit-code 1 включает «шлюз»:
docker pull python:3.9.5-slim
docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v ~/.cache/trivy:/root/.cache/trivy \
  aquasec/trivy:0.74.0 image --severity CRITICAL --exit-code 1 --ignore-unfixed python:3.9.5-slim
echo "код выхода: $?"

Вывод (сокращённо):

2026-09-30T12:14:04Z	INFO	Detected OS	family="debian" version="10.10"
2026-09-30T12:14:04Z	WARN	This OS version is no longer supported by the distribution	family="debian" version="10.10"
2026-09-30T12:14:04Z	WARN	The vulnerability detection may be insufficient because security updates are not provided

python:3.9.5-slim (debian 10.10)
================================
Total: 21 (CRITICAL: 21)

┌──────────────┬────────────────┬──────────┬────────┬───────────────────┬─────────────────────────┐
│   Library    │ Vulnerability  │ Severity │ Status │ Installed Version │      Fixed Version      │
├──────────────┼────────────────┼──────────┼────────┼───────────────────┼─────────────────────────┤
│ dpkg         │ CVE-2022-1664  │ CRITICAL │ fixed  │ 1.19.7            │ 1.19.8                  │
├──────────────┼────────────────┼──────────┼────────┼───────────────────┼─────────────────────────┤
│ libc-bin     │ CVE-2021-33574 │          │        │ 2.28-10           │ 2.28-10+deb10u2         │
│              │ CVE-2021-35942 │          │        │                   │                         │
│              │ CVE-2022-23218 │          │        │                   │                         │
...
код выхода: 1

Как читать вывод: у старого образа 21 CRITICAL против нуля у свежего. Debian 10 (buster) давно снят с поддержки, и Trivy честно предупреждает: «исправления безопасности для этой версии не выходят, обнаружение может быть неполным». Это ещё одна причина обновлять базу: со временем не только копятся уязвимости, но и перестаёт выходить исправление. Код выхода 1: теперь скан работает как шлюз, CI на этом шаге остановился бы.

Вставь нейросети таблицу Trivy (без секретов) и попроси сгруппировать находки по пакетам и по тому, есть ли исправление. Проверь ответ: сравни число находок с исходной таблицей, нейросеть любит терять строки.

Объясни себе: чем Installed Version отличается от Fixed Version и что из этого делать? Почему --ignore-unfixed уместен в шлюзе CI? Почему скан notes:0.4.0 через месяц может дать другой результат?

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

  • permission denied while trying to connect to the Docker daemon socket: пользователь не в группе docker (урок 4.1): добавь себя в группу и перезайди.
  • FATAL Fatal error init error: DB error: failed to download vulnerability DB: нет доступа к интернету или скачивание прервано: повтори, проверь прокси и DNS.
  • unable to find the specified image "notes:0.4.0" in ["docker" "containerd" "podman" "remote"]: образа нет локально: собери его (docker build -t notes:0.4.0 ~/notes).

Задание 2. Hadolint: находим ошибки в Dockerfile

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

Предскажи: какие плохие практики ты бы нашёл в этом Dockerfile за 30 секунд? Сколько из них увидит Trivy по собранному образу?

Ответ

Нет тега у FROM, apt-get install без --no-install-recommends и без очистки кэша, pip install без версии и --no-cache-dir, ADD вместо COPY, CMD в shell-форме, нет USER. Trivy по образу увидит только последствия в виде уязвимых пакетов (и то не тех, что связаны со стилем), но не сами практики: он не читает Dockerfile.

Шаги:

  1. Создай намеренно плохой файл. Команда cat > файл <<'EOF' ... EOF записывает всё между EOF в файл (кавычки вокруг 'EOF' нужны, чтобы оболочка ничего не подставляла внутрь):
cd ~/lab48
cat > Dockerfile.bad <<'EOF'
FROM python
RUN apt-get update && apt-get install -y curl
RUN pip install flask
ADD app.py /app/app.py
WORKDIR /app
CMD python app.py
EOF
  1. Запусти линтер. Разбор: docker run --rm -i hadolint/hadolint:v2.15.1 запускает контейнер Hadolint, -i оставляет его стандартный ввод открытым, а < Dockerfile.bad отдаёт содержимое файла на этот ввод. Без -i контейнер получил бы пустой ввод. Здесь нужен не -v с файлом, а именно ввод: так же работает шаг в CI.
docker run --rm -i hadolint/hadolint:v2.15.1 < Dockerfile.bad
echo "код выхода: $?"

Результат (в терминале warning и error подсвечены цветом):

-:1 DL3006 warning: Always tag the version of an image explicitly
-:2 DL3008 warning: Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
-:2 DL3015 info: Avoid additional packages by specifying `--no-install-recommends`
-:2 DL3009 info: Delete the apt lists (/var/lib/apt/lists) after installing something
-:3 DL3042 warning: Avoid use of cache directory with pip. Use `pip install --no-cache-dir <package>`
-:3 DL3013 warning: Pin versions in pip. Instead of `pip install <package>` use `pip install <package>==<version>` or `pip install --requirement <requirements file>`
-:4 DL3020 error: Use COPY instead of ADD for files and folders
-:6 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments
код выхода: 1

Как читать вывод: -:2 - «файл - (стандартный ввод), строка 2»; DL3008 - код правила (ищи его в списке правил Hadolint); дальше уровень и объяснение. Каждая строка Dockerfile, кроме WORKDIR, получила замечание. Код выхода 1: даже info по умолчанию считается провалом (это можно поменять, ниже). Точный набор правил и формулировки зависят от версии Hadolint, ориентируйся на код правила и смысл.

Если непонятно, как исправить замечание Hadolint, попроси нейросеть переписать только проблемную инструкцию и объяснить правило. Проверь ответ: прогони hadolint по новому файлу, нейросеть может предложить версии пакетов, которых нет.

  1. Исправь: закрепи тег базы, добавь --no-install-recommends, очисти кэш apt в том же RUN, --no-cache-dir у pip и версию flask, замени ADD на COPY, добавь USER, CMD в exec-форму. Файл получится таким (комментарий объясняет, почему версия curl не закреплена):
FROM python:3.13-slim
# Версию curl закреплять не будем: в Debian она меняется с каждым обновлением (DL3008 отключено в .hadolint.yaml)
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir flask==3.1.0
WORKDIR /app
COPY app.py /app/app.py
USER 10001:10001
CMD ["python", "app.py"]

Разбор: python:3.13-slim - закреплённый тег (DL3006). && связывает команды в один RUN, а \ переносит строку: получается один слой. rm -rf /var/lib/apt/lists/* удаляет списки пакетов, которые нужны были только на время установки. COPY копирует файл, ничего больше. USER 10001:10001 - работать от обычного пользователя. CMD [...] в квадратных скобках - exec-форма.

Сохрани его как Dockerfile.good (снова через cat > Dockerfile.good <<'EOF', вставив содержимое выше и закончив строкой EOF). Для COPY app.py нужен файл app.py рядом: он нужен только линтеру для чтения инструкции, сама сборка тут не выполняется. Затем прогони линтер без конфигурации:

docker run --rm -i hadolint/hadolint:v2.15.1 < Dockerfile.good
echo "код выхода: $?"
-:3 DL3008 warning: Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
код выхода: 1

Осталось одно замечание. Закрепить версию curl в Debian мы не хотим (старые версии из репозитория исчезают), это осознанное исключение. Создай .hadolint.yaml и запиши там правило с причиной:

# Правила, отключённые осознанно. Каждое с причиной.
ignored:
  # DL3008: закрепление версий apt-пакетов. В python:3.13-slim (Debian) старые версии
  # быстро исчезают из репозитория, сборка ломалась бы без изменений в коде.
  # Вместо этого базу обновляет Dependabot, а состав проверяет Trivy.
  - DL3008

Разбор файла: строки с # - комментарии (они для людей). ignored: - список правил, которые линтер пропускает; - DL3008 - один элемент списка (дефис и пробел).

  1. Проверь исправленный файл. Hadolint ищет конфиг в нескольких местах, в том числе /.config/hadolint.yaml внутри контейнера, поэтому наш файл монтируем туда (-v файл:путь-в-контейнере), а $PWD - текущий каталог:
docker run --rm -i -v "$PWD/.hadolint.yaml:/.config/hadolint.yaml" \
  hadolint/hadolint:v2.15.1 < Dockerfile.good
echo "код выхода: $?"
код выхода: 0

Как читать вывод: пустой вывод и код 0 значат «замечаний нет». Отсутствие строк - нормальный признак успеха для линтеров: они говорят, только когда есть что сказать.

Объясни себе: почему rm -rf /var/lib/apt/lists/* стоит в том же RUN, что и install? (подсказка: слои из урока 4.2: удаление в отдельном RUN создаёт новый слой, а старый со списками остаётся в образе). Чем плох CMD python app.py (shell-форма)? (подсказка: PID 1 и сигналы, урок 1.4). Почему исключение лежит в файле с комментарием, а не в аргументе командной строки?

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

  • Пустой вывод там, где ждёшь замечания, или hadolint: ... requires input: не указан -i или нет < файл, Hadolint не получил Dockerfile: команда должна быть docker run --rm -i ... < Dockerfile.bad.
  • -:1 DL3006 warning: Always tag the version of an image explicitly: у FROM нет тега: укажи python:3.13-slim.
  • .hadolint.yaml игнорируется, DL3008 остаётся: файл не смонтирован туда, где его ищет Hadolint: смонтируй в /.config/hadolint.yaml или передай -c с путём внутри контейнера.
  • Ошибка no such file or directory для .hadolint.yaml: ты запустил команду не из ~/lab48, а $PWD указывает на другой каталог: cd ~/lab48.

Задание 3. SBOM: получаем опись образа

Цель: получить SBOM образа notes:0.4.0, найти в нём пакеты и просканировать опись без образа.

Предскажи: сколько компонентов будет в SBOM: несколько штук или больше сотни? Хранит ли SBOM твой app.py?

Ответ

Больше сотни: почти все компоненты это системные пакеты Debian и зависимости pip. Твой app.py - это просто файл, а не пакет, в описи его нет. Есть сам образ как контейнер-компонент верхнего уровня.

Шаги:

  1. Получи опись в формате CycloneDX. Флаг -q (quiet) выключает служебные строки в консоли, а > перенаправляет вывод (то есть саму опись) в файл:
cd ~/lab48
docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v ~/.cache/trivy:/root/.cache/trivy \
  aquasec/trivy:0.74.0 image -q --format cyclonedx notes:0.4.0 > sbom.cdx.json
ls -l sbom.cdx.json
-rw-r--r--  1 ubuntu  ubuntu  230215 Sep 30 15:15 sbom.cdx.json

Размер около 230 КБ, у тебя чуть отличается. Программе jq нужно установить: sudo apt-get install -y jq (на Ubuntu, если её ещё нет).

  1. Посмотри общие сведения и посчитай компоненты по типам. Разбор: jq -r '.bomFormat, .specVersion' печатает два поля; в .components | length - число элементов массива; в .components[].purl | split("/")[0] берётся часть purl до первой /, то есть менеджер пакетов; sort | uniq -c считает одинаковые строки.
jq -r '.bomFormat, .specVersion, (.components | length)' sbom.cdx.json
jq -r '.components[].purl // empty | split("/")[0]' sbom.cdx.json | sort | uniq -c
CycloneDX
1.7
128
  87 pkg:deb
  40 pkg:pypi

Как читать вывод: формат CycloneDX версии спецификации 1.7, 128 компонентов: 87 пакетов Debian, 40 Python-пакетов и сам образ как один компонент типа container. Сорок Python-пакетов - это не только psycopg, но и всё, что лежит в pip (вложенные библиотеки).

  1. Найди конкретные пакеты (как «понедельничный» поиск zlib):
jq -r '.components[] | select(.name | test("zlib|openssl|psycopg")) | "\(.name) \(.version)"' sbom.cdx.json
openssl-provider-legacy 3.5.7-1~deb13u2
openssl 3.5.7-1~deb13u2
zlib1g 1:1.3.dfsg+really1.3.1-1+b1
psycopg-binary 3.3.6
psycopg 3.3.6
  1. Просканируй опись, не трогая образ. Каталог с файлом монтируем в /w, путь в команде - внутри контейнера:
docker run --rm -v "$PWD:/w" -v ~/.cache/trivy:/root/.cache/trivy \
  aquasec/trivy:0.74.0 sbom -q --severity HIGH,CRITICAL --ignore-unfixed /w/sbom.cdx.json

Trivy найдёт те же 6 + 2 находки, что и при скане образа (в заголовке цель называется /w/sbom.cdx.json (debian 13.7)).

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

Объясни себе: зачем хранить SBOM рядом с образом, если его можно получить из образа в любой момент? (подсказка: образ из реестра могут удалить, а опись останется).

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

  • jq: command not found: не установлен jq: sudo apt-get install -y jq.
  • parse error: Invalid numeric literal: в файл попали служебные строки Trivy: добавь -q и убедись, что перенаправляешь только стандартный вывод (>, не &>).
  • sbom.cdx.json пустой: образа notes:0.4.0 нет локально: собери его.

Задание 4. Запуск с минимумом прав

Цель: запустить notes:0.4.0 под --read-only, --cap-drop ALL и no-new-privileges и убедиться, что приложение работает, а запись вне разрешённых мест блокируется.

Предскажи: (1) от какого пользователя работает процесс в контейнере? (2) запись в /app/x в режиме --read-only пройдёт? (3) а в /tmp без --tmpfs?

Ответ

(1) От uid 10001, как задано USER в Dockerfile. (2) Нет: корневая ФС только для чтения. (3) Тоже нет, /tmp часть корневой ФС; нужен --tmpfs /tmp. В нашем запуске --tmpfs будет, поэтому /tmp записать получится.

Шаги:

  1. Проверь пользователя в образе. Команда id печатает uid, gid и группы; docker run --rm образ команда запускает её вместо обычного CMD:
docker run --rm notes:0.4.0 id
uid=10001 gid=10001 groups=10001
  1. Запусти ограниченный контейнер. Разбор флагов: -d (в фоне), --name notes-hard (имя), --read-only (корень только для чтения), --tmpfs /tmp (память под /tmp), --cap-drop ALL (убрать все capabilities), --security-opt no-new-privileges (запрет поднятия прав), -v notes-data:/data (том для данных из урока 4.3), -p 127.0.0.1:8080:8080 (порт наружу только на localhost). Если порт 8080 занят Compose-стеком из урока 4.6, сначала docker compose down.
docker run -d --name notes-hard \
  --read-only \
  --tmpfs /tmp \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  -v notes-data:/data \
  -p 127.0.0.1:8080:8080 \
  notes:0.4.0
sleep 3
curl -fsS http://127.0.0.1:8080/healthz

Первая строка вывода docker run - длинный ID контейнера (у тебя другой), потом ответ приложения:

ok
  1. Проверь запись в запрещённое и в разрешённое место. docker exec контейнер команда запускает команду внутри работающего контейнера, python -c "..." - однострочная программа на Python:
docker exec notes-hard python -c "open('/app/x','w')"
docker exec notes-hard python -c "open('/tmp/x','w'); print('tmp ok')"
curl -fsS -X POST -d '{"text":"проверка"}' http://127.0.0.1:8080/notes
Traceback (most recent call last):
  File "<string>", line 1, in <module>
    open('/app/x','w')
    ~~~~^^^^^^^^^^^^^^
OSError: [Errno 30] Read-only file system: '/app/x'
tmp ok
{"id": 1}

{"id": 1} - заметка сохранена в /data (у тебя id может быть больше, если в томе notes-data уже были заметки).

  1. Посмотри, какие права остались у процесса, и убери контейнер. /proc/1/status - файл ядра с описанием процесса PID 1, строки Cap... - его capabilities, NoNewPrivs - флаг запрета повышения:
docker exec notes-hard grep -E 'Cap(Eff|Bnd)|NoNewPrivs' /proc/1/status
docker rm -f notes-hard
CapEff:	0000000000000000
CapBnd:	0000000000000000
NoNewPrivs:	1

Как читать вывод: uid=10001 - процесс не root. Ошибка Read-only file system на /app/x - защита сработала; tmp ok - в tmpfs писать можно, {"id": 1} - приложение сохраняет заметки в смонтированный том. Нули в CapEff и CapBnd - у процесса нет ни одной capability и получить их неоткуда. NoNewPrivs: 1 - запрет повышения прав включён.

Объясни себе: почему CapEff нулевой и приложение всё равно слушает порт 8080? (подсказка: порт 8080 больше 1024, а в Docker «привилегированных» портов нет вообще, мы это проверили в теории). Что изменилось бы для порта 80 в Docker? (ничего.) А на обычном сервере без контейнера? Зачем нужен no-new-privileges, если процесс и так не root? (подсказка: в образе восемь setuid-программ).

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

  • В логе и в ответе POST /notes ошибка {"error": "storage"} и OSError: [Errno 30] Read-only file system: '/data/notes.txt': том не смонтирован, /data часть корневой ФС: добавь -v notes-data:/data. Обрати внимание: приложение не падает, а отвечает 500 (посмотри docker logs notes-hard).
  • PermissionError: [Errno 13] Permission denied: '/data/notes.txt': том создан от root: владелец каталога должен быть 10001 (урок 4.3).
  • Error response from daemon: Conflict. The container name "/notes-hard" is already in use: остался старый контейнер: docker rm -f notes-hard.
  • Bind for 127.0.0.1:8080 failed: port is already allocated: порт занят Compose-стеком из урока 4.6: docker compose down.

Задание 5. Шаг проекта: проверки образа в CI

Цель: добавить в image.yml проверки Hadolint, Trivy и SBOM так, чтобы образ с серьёзными уязвимостями или замечаниями Dockerfile не попадал в ghcr.io.

Предскажи: в каком порядке должны идти шаги: сборка, скан, push? Что произойдёт, если сначала сделать push?

Ответ

Hadolint (до сборки, дёшево), сборка в локальный Docker без push, скан, и только затем push. Если push первым, уязвимый образ уже опубликован, и красный CI ничего не отменит: его успеют скачать.

Шаги:

  1. В ~/notes создай .hadolint.yaml для проекта. В Dockerfile «Заметок» нет apt-get, поэтому правило DL3008 не нужно (исключения без причины - мусор). Полезное для проекта: failure-threshold: warning (падать на warning и error, а на рекомендации info и style нет):
# Правила Hadolint для проекта «Заметки».
# Падаем на warning и error; info и style только подсказывают.
failure-threshold: warning
# Исключения добавлять сюда списком ignored: с причиной в комментарии, например:
# ignored:
#   # DL3008: причина, почему отключаем
#   - DL3008
  1. Прогони Hadolint по своему Dockerfile из урока 4.7 (тот же запуск, что будет в CI):
cd ~/notes
docker run --rm -i -v "$PWD/.hadolint.yaml:/.config/hadolint.yaml" hadolint/hadolint:v2.15.1 < Dockerfile
echo "hadolint: $?"

Скорее всего, ты увидишь одну строку:

-:20 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments
hadolint: 1

Это находка в нашем же Dockerfile из уроков 4.2 и 4.7: HEALTHCHECK ... CMD python -c "..." записан в shell-форме. Исправь на exec-форму: команда и аргументы списком в квадратных скобках, внутренние кавычки остаются:

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD ["python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/healthz')"]

Замени эти две строки в Dockerfile и повтори проверку:

hadolint: 0
  1. Проверь шлюз Trivy локально (те же флаги, что будут в CI) на образе, собранном из исправленного Dockerfile:
docker build -t notes:0.4.0 .
docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v ~/.cache/trivy:/root/.cache/trivy \
  aquasec/trivy:0.74.0 image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 notes:0.4.0
echo "trivy: $?"

На нашем запуске шлюз оказался красным: те же openssl (6 находок) и две находки внутри pip, trivy: 1. Это не ошибка урока, а как раз то, ради чего шлюз существует. Если у тебя чисто, переходи к шагу 6. Если красно, иди по списку из теории («Что делать, когда шлюз красный»):

  • Сначала docker build --pull -t notes:0.4.0 . и скан заново. У нас базовый образ ещё не был обновлён, --pull не помог.
  • Исправление в Debian уже вышло (Fixed Version заполнена), поэтому добавь в итоговую стадию Dockerfile (после второго FROM, перед WORKDIR) обновление пакетов:
FROM python:3.13-slim
# Обновляем системные пакеты: закрываем уязвимости, исправленные позже сборки базового образа
RUN apt-get update \
    && apt-get upgrade -y --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /app

Пересобери и просканируй ещё раз. На нашем запуске остались только две находки, обе внутри pip (msgpack и setuptools из вложенных библиотек), а Hadolint остался чистым:

Total: 2 (HIGH: 2, CRITICAL: 0)
  1. Для этих двух находок исправления в самом образе нет: они лежат внутри вложенных копий в pip, который нужен при сборке, а не в работе сервиса. Это случай для осознанного исключения. Создай .trivyignore.yaml (свои номера бери из своей таблицы, у тебя они другие; срок поставь на пару недель вперёд):
# Исключения Trivy: каждое с причиной и сроком пересмотра.
vulnerabilities:
  - id: GHSA-6v7p-g79w-8964
    statement: "msgpack вложен в pip, нужен только при сборке; ждём новый pip в базовом образе"
    expired_at: 2026-10-15
  - id: CVE-2025-47273
    statement: "setuptools вложен в pip, нужен только при сборке; ждём новый pip в базовом образе"
    expired_at: 2026-10-15

Разбор ключей: id - номер из колонки Vulnerability; statement - причина словами (её увидит следующий, кто откроет файл); expired_at - дата, после которой Trivy перестаёт учитывать исключение и находка возвращается. Мы проверили именно это: с датой в прошлом находки снова оказались в отчёте и Total вернулся к прежнему числу.

Проверь скан с исключениями (файл монтируем в /.trivyignore.yaml, путь указываем флагом --ignorefile):

docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v ~/.cache/trivy:/root/.cache/trivy \
  -v "$PWD/.trivyignore.yaml:/.trivyignore.yaml" \
  aquasec/trivy:0.74.0 image --ignorefile /.trivyignore.yaml \
  --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 notes:0.4.0
echo "trivy: $?"
trivy: 0

В сводке во всех строках стоит 0 находок. Если у тебя чисто без исключений, файл всё равно создай с пустым списком (vulnerabilities: []): Trivy принимает его, а CI ниже смонтирует его без ошибки.

  1. Замени .github/workflows/image.yml (версии действий из курса; шаги логина и сборки остались из урока 4.7, добавлены проверки). Разбор нового: шаг Hadolint читает Dockerfile через < Dockerfile, как ты делал локально; load: true кладёт собранный образ в Docker раннера, push: false не публикует; Trivy получает сокет Docker, исключения и --exit-code 1; SBOM сохраняется в файл и загружается как артефакт запуска; шаги login и Push идут последними. Файл целиком:
name: image

on:
  push:
    tags: ['v*']

permissions:
  contents: read
  packages: write

env:
  IMAGE: ghcr.io/${{ github.repository_owner }}/notes

jobs:
  image:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7.0.1

      # Версия образа = тег git без буквы v: v0.4.0 -> 0.4.0
      - name: Версия из тега
        run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"

      # 1. Линтер Dockerfile: падает при замечаниях, до сборки
      - name: Hadolint
        run: docker run --rm -i -v "$PWD/.hadolint.yaml:/.config/hadolint.yaml" hadolint/hadolint:v2.15.1 < Dockerfile

      - uses: docker/setup-buildx-action@v4.4.1

      # 2. Сборка в локальный Docker (push: false), чтобы просканировать до публикации
      - name: Сборка (без push)
        uses: docker/build-push-action@v7.4.0
        with:
          context: .
          load: true
          push: false
          tags: ${{ env.IMAGE }}:${{ env.VERSION }}

      # 3. Шлюз: HIGH и CRITICAL с доступным исправлением останавливают релиз.
      #    Исключения берутся из .trivyignore.yaml (с причиной и сроком).
      - name: Trivy - уязвимости
        run: |
          docker run --rm \
            -v /var/run/docker.sock:/var/run/docker.sock \
            -v "$PWD/.trivyignore.yaml:/.trivyignore.yaml" \
            aquasec/trivy:0.74.0 image --ignorefile /.trivyignore.yaml \
            --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
            "$IMAGE:$VERSION"

      # 4. Опись состава образа как артефакт релиза
      - name: SBOM
        run: |
          docker run --rm \
            -v /var/run/docker.sock:/var/run/docker.sock \
            aquasec/trivy:0.74.0 image -q --format cyclonedx \
            "$IMAGE:$VERSION" > sbom.cdx.json

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: sbom-${{ env.VERSION }}
          path: sbom.cdx.json

      # 5. Публикация только после зелёных проверок
      - uses: docker/login-action@v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Push
        run: docker push "$IMAGE:$VERSION"

Заметь имя шага Trivy - уязвимости без двоеточия внутри: запись name: Trivy: уязвимости в YAML не разбирается («mapping values are not allowed»), потому что второе двоеточие с пробелом считается началом нового значения.

  1. Закоммить через ветку и Pull Request (процесс из урока 3.2):
git checkout -b ci/image-scan
git add .hadolint.yaml .trivyignore.yaml Dockerfile .github/workflows/image.yml
git commit -m "ci: hadolint, trivy image и SBOM в image.yml"
git push -u origin ci/image-scan

Слей Pull Request, затем выпусти проверочный релиз по правилам урока 3.5 и следи за вкладкой Actions. Если тег v0.4.0 уже опубликован в ghcr.io, образ иммутабелен: используй следующий патч-тег (v0.4.1), а не перезаписывай старый.

Что должно получиться: локально hadolint: 0 и trivy: 0; в Actions шаги по порядку зелёные, в артефактах запуска есть sbom-0.4.1 (или твоя версия), образ появился в ghcr.io только после зелёного Trivy.

✓ Hadolint
✓ Сборка (без push)
✓ Trivy - уязвимости
✓ SBOM
✓ Push

Как читать вывод: галочки идут сверху вниз в порядке шагов. Если красный крестик на Trivy, шаги ниже (SBOM, Push) не запускаются: GitHub Actions по умолчанию останавливает задачу на первой ошибке. Образа в реестре при этом нет. Сам workflow в Actions в этом уроке не запускался (проверен только синтаксис), локальные шаги 2-4 запускались.

Объясни себе: почему в permissions остались только contents: read и packages: write? Что теряется, если убрать --ignore-unfixed? Чем проверка Trivy в этом workflow отличается от trivy fs из урока 3.4?

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

  • Error: Unable to resolve action docker/build-push-action@v7.4.0: опечатка в версии действия: сверь с разделом «Проверено на версиях».
  • denied: permission_denied: write_package: нет packages: write в permissions (см. урок 4.7).
  • Пустой вывод Hadolint или сообщение про отсутствие ввода: не указан -i при передаче Dockerfile через stdin: нужен docker run --rm -i.
  • Error: Process completed with exit code 1 на шаге Trivy: это не сбой, а сработавший шлюз: смотри таблицу выше в логе и иди по списку «Что делать, когда шлюз красный».
  • mount ... /.trivyignore.yaml: no such file или not a directory: файл .trivyignore.yaml не закоммичен, Docker создал вместо него каталог: добавь файл в репозиторий (git add .trivyignore.yaml).
  • yaml: mapping values are not allowed in this context в workflow: двоеточие внутри name: без кавычек: убери его или возьми имя в кавычки.

Сломай и почини

Скачай сценарии и запусти один. Скрипт не читай: цель в том, чтобы найти причину по симптомам. Понадобятся образ notes:0.4.0 и проект ~/notes из урока 4.7. Скрипт работает без sudo, создаёт только образ notes:break-1, контейнер notes-break-2 и файлы в ~/lab48/break.

curl -fsSL -o /tmp/break-4.8.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/4.8/break.sh
bash /tmp/break-4.8.sh 1

Вместо 1 можно взять 2 или 3. Вернуть всё как было: bash /tmp/break-4.8.sh fix.

Симптом

Скрипт печатает, какой контейнер, образ или файл подготовлен. Возможные симптомы (по одному за запуск):

  1. Шлюз Trivy красный по образу notes:break-1: CRITICAL в базовом образе.
  2. Контейнер notes-break-2 работает, но POST /notes отвечает 500 {"error": "storage"}, в логе Read-only file system.
  3. Hadolint и trivy config ругаются на запуск от root в файле ~/lab48/break/scenario3/Dockerfile.

Гипотезы

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

  • уязвим базовый образ, а Dockerfile ни при чём;
  • приложение пишет в место, которое закрыто --read-only;
  • в Dockerfile стоит USER root или нет USER;
  • ошибка в сети или в самом приложении (отбрасываем: симптом другой).

Проверки

# Сценарий 1: что показывает скан и какой у уязвимости статус исправления
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v ~/.cache/trivy:/root/.cache/trivy \
  aquasec/trivy:0.74.0 image --severity CRITICAL notes:break-1

# Сценарий 2: что пишет приложение и с какими флагами запущен контейнер
docker logs notes-break-2
docker inspect notes-break-2 | jq '.[0] | {ro: .HostConfig.ReadonlyRootfs, user: .Config.User, mounts: .Mounts}'

# Сценарий 3: что скажут линтеры про Dockerfile
docker run --rm -i hadolint/hadolint:v2.15.1 < ~/lab48/break/scenario3/Dockerfile
docker run --rm -v ~/lab48/break/scenario3:/w aquasec/trivy:0.74.0 config /w

Исправление

Разбор сценариев

1. CRITICAL в базе. В таблице Trivy в заголовке debian 10.10, а колонка Fixed Version заполнена: исправления есть, но база старая (образ python:3.9.5-slim 2021 года). Открой ~/lab48/break/scenario1/Dockerfile и замени обе строки FROM python:3.9.5-slim на FROM python:3.13-slim, затем пересобери: docker build --pull -f ~/lab48/break/scenario1/Dockerfile -t notes:break-1 ~/notes и просканируй снова, CRITICAL исчезнут. Если бы исправления не было (пустой Fixed Version, Status: affected), оценил бы, вызывается ли уязвимый код, и внёс исключение в .trivyignore.yaml с причиной и датой пересмотра. Профилактика: Dependabot для Dockerfile и регулярный скан по расписанию.

2. Read-only file system. В docker logs строка ошибка хранилища: [Errno 30] Read-only file system: '/data/notes.txt' называет путь, а в docker inspect видно ro: true и пустые mounts. Не снимай --read-only: замени контейнер на такой же, но с томом для этого пути: docker rm -f notes-break-2, затем запуск как в задании 4 (-v notes-data:/data --tmpfs /tmp). Проверка: повтор POST /notes отвечает 201.

3. Root. Hadolint показал DL3002 warning: Last USER should not be root, а trivy config показал DS-0002 (HIGH): Last USER command in Dockerfile should not be 'root'. Замени в Dockerfile строку USER root на USER 10001:10001 (каталог данных /data уже принадлежит этому uid, урок 4.2). Повтори оба линтера: чисто. Отдельно запомни: если строки USER нет совсем, Hadolint промолчит, а trivy config всё равно предупредит.

Убрать всё: bash /tmp/break-4.8.sh fix.

ИИ в помощь

Нейросеть хорошо объясняет отчёты сканеров и правила линтера, но не знает, достижим ли уязвимый код в твоём приложении. Общие правила: ИИ-помощник.

Задача: разобрать отчёт Trivy.

Вот таблица Trivy для моего образа (без секретов): <вставь>.
Сгруппируй находки по пакетам, отметь, где есть Fixed Version, и предложи порядок исправления.
Не придумывай CVE и версии, которых нет в таблице.

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

Задача: исправить замечания Hadolint.

Вот мой Dockerfile и вывод hadolint: <вставь>.
Перепиши Dockerfile так, чтобы убрать замечания, и объясни каждое правило.

Проверь ответ: запусти hadolint и docker build по результату. Типичная ошибка нейросети: закрепить версию пакета, которой нет в репозитории Debian.

Задача: оценить права запуска.

Вот моя команда docker run: <вставь>.
Какие защиты из списка (не root, read-only, cap-drop, no-new-privileges) уже есть, а каких нет? Что может сломаться при включении?

Проверь ответ: запусти контейнер с новыми флагами и посмотри docker logs. Типичная ошибка нейросети: советовать --privileged для «упрощения».

Словарик урока

Термин Простыми словами
CVE Публичный номер известной уязвимости, например CVE-2026-75804
GHSA Такой же номер из списка GitHub (GHSA-...)
CVSS Оценка серьёзности уязвимости от 0 до 10, из неё получают уровни LOW, MEDIUM, HIGH, CRITICAL
Fixed Version Версия пакета, в которой уязвимость уже исправлена
Дистрибутив Собранный набор ядра, программ и библиотек (Debian); его команда выпускает исправленные пакеты
Пакет Программа или библиотека в готовом для установки виде, с номером версии
Мейнтейнер Человек или команда, которая сопровождает пакет и выпускает исправления
Иммутабельность Свойство образа не меняться после сборки: старая находка остаётся в нём навсегда
Trivy Сканер: находит в образе пакеты и сверяет их с базой уязвимостей; ещё проверяет конфигурации
База уязвимостей Список известных CVE в машинном виде, Trivy скачивает и кэширует его
Код выхода Число, с которым программа завершается: 0 успех, иначе ошибка; на него смотрит CI
Шлюз (gate) Шаг CI, который останавливает выкладку при красном результате
--ignore-unfixed Флаг Trivy: скрыть находки, для которых ещё нет исправления
.trivyignore.yaml Файл исключений Trivy: номер уязвимости, причина, срок пересмотра
Линтер Программа, которая читает исходный файл и ищет плохие приёмы, ничего не запуская
Hadolint Линтер для Dockerfile, правила с кодами DL....
.hadolint.yaml Настройки Hadolint: исключённые правила (с причиной) и порог падения
SBOM Опись состава образа: пакеты, версии, лицензии
CycloneDX, SPDX Два стандартных формата SBOM
purl Единый формат ссылки на пакет, например pkg:pypi/psycopg@3.3.6
Capabilities «Кусочки» прав root: смена владельца, привязка низких портов и другие
--cap-drop ALL Убрать все capabilities у процесса контейнера
--read-only Корневая файловая система контейнера только для чтения
--tmpfs Каталог в оперативной памяти для временных файлов, исчезает с контейнером
setuid Особый бит файла: программа работает с правами владельца (обычно root)
no-new-privileges Запрет получать больше прав через setuid
Rootless Режим, где контейнеры работают не от root хоста (у Podman по умолчанию)
Podman Альтернатива Docker без постоянного демона
Supply chain Цепочка поставки: всё, из чего и как собран и доставлен образ

Вопросы с собеседований

Раздел для повторения: ответь вслух, потом открой ответ. Вопросы с пометкой «часто» задают почти на каждом собеседовании по теме урока: начни с них. Короткие вопросы с пометкой «на скорость» тренируй на время: ответ за 30 секунд.

1. [middle] [часто] Как сделать образ безопаснее? Назови основные меры.

Ответ

Беру минимальную базу (slim, distroless) с закреплённой версией. Использую multi-stage, чтобы в финальном образе не было компилятора и исходников. Запускаю не от root через USER. Не кладу секреты в слои. Сканирую Trivy в CI и пересобираю образы по расписанию, потому что новые CVE появляются без изменений в коде, а Dockerfile проверяю Hadolint. При запуске добавляю --read-only, --cap-drop=ALL и --security-opt no-new-privileges.

Что хотят услышать: минимальная база, multi-stage, не root, без секретов в слоях, скан и регулярная пересборка, ограничение прав при запуске.

Красный флаг: Только «поставить антивирус» или «сканер всё найдёт»; запуск от root.

2. [junior] [часто] Trivy нашёл CRITICAL в базовом образе. Что делаешь?

Ответ

Сначала смотрю, есть ли исправленная версия (Fixed Version). Если есть, пересобираю образ на обновлённой базе (docker build --pull) и сканирую снова. Если исправления нет, оцениваю, достижим ли уязвимый код из нашего приложения, и решаю: сменить базу, смягчить (--read-only, ограничить сеть) или принять риск с записью и сроком пересмотра.

Что хотят услышать: проверка Fixed Version, пересборка на свежей базе, исключение с обоснованием и сроком, а не молчаливое игнорирование.

Красный флаг: «добавлю CVE в игнор, чтобы пайплайн позеленел».

3. [junior] [часто] Почему контейнер нельзя запускать от root, если он всё равно изолирован?

Ответ

Изоляция не абсолютная: ядро общее с хостом. Уязвимость в приложении даёт атакующему права того пользователя, от которого работает процесс. Root в контейнере плюс ошибка настройки (смонтированный сокет Docker, лишние capabilities) заметно ближе к root на хосте. Непривилегированный пользователь делает такую цепочку сложнее.

Что хотят услышать: общее ядро, USER в Dockerfile, uid не 0, глубокая защита (несколько слоёв: не root, --read-only, --cap-drop ALL).

Красный флаг: «контейнер изолирован, значит безопасно».

4. [junior] Что такое SBOM и когда он реально выручает?

Ответ

Это машиночитаемая опись состава образа (SBOM, Software Bill of Materials): пакеты, версии, лицензии, в формате CycloneDX или SPDX. Выручает, когда выходит новая громкая CVE: по хранимым описям за минуты видно, в каких сервисах уязвимый пакет, без пересборки и скана всего парка.

Что хотят услышать: пример с новой CVE (Log4Shell), хранение SBOM с релизом, форматы CycloneDX и SPDX, скан по SBOM (trivy sbom).

Красный флаг: «это то же самое, что Dockerfile».

5. [junior] [на скорость] Чем docker run --read-only помогает и что ломается первым?

Ответ

Корневая файловая система становится только для чтения: нельзя записать вредоносный файл или изменить код приложения. Ломается всё, что пишет в неожиданные места: /tmp, кэши, PID-файлы, данные. Решение: точечно давать --tmpfs /tmp для временных файлов и тома для данных, диагностика по строке Read-only file system в логе.

Что хотят услышать: tmpfs и том вместо отключения защиты, диагностика по логу.

Красный флаг: «просто не буду её включать».

6. [junior] [на скорость] Пайплайн со сканом зелёный, но в логе видны CRITICAL. Как так?

Ответ

Trivy по умолчанию завершается с кодом 0 (успех), даже когда нашёл уязвимости. Чтобы шаг падал, нужен --exit-code 1 вместе с --severity. Без него сканер только печатает отчёт и ничего не блокирует.

Что хотят услышать: --exit-code, фильтр severity, --ignore-unfixed, что «сканер запущен» не равно «шлюз работает».

Красный флаг: «значит, уязвимости не критичны».

7. [middle] Образ месяц назад проходил скан, сегодня красный, код не менялся. Разбери.

Ответ

Образ неизменяем, но база CVE обновляется: новые уязвимости публикуют уже после сборки. Смотрю таблицу: есть ли Fixed Version, пересобираю на свежей базе. Чтобы это не было сюрпризом на релизе, настраиваю плановый скан (cron в CI) уже опубликованных образов и уведомление, а базу обновляет бот (Dependabot).

Что хотят услышать: обновление базы знаний, плановый скан, автообновление базы бота, различие «регресс кода» и «новая CVE».

Красный флаг: «кто-то поменял образ».

8. [middle] Образ прошёл скан, но на проде контейнер захватили. Что в цепочке ты мог пропустить?

Ответ

Скан ищет известные CVE в пакетах. Он не ловит неизвестные уязвимости (0-day), ошибки логики приложения, секреты в переменных, избыточные права запуска (root, --privileged, сокет Docker, лишние capabilities) и открытые наружу порты. Проверяю флаги запуска (docker inspect), сеть и логи, а не только отчёт сканера.

Что хотят услышать: пределы сканера, права запуска, no-new-privileges, --cap-drop, --read-only, сетевые ограничения.

Красный флаг: «сканер сказал, что чисто, значит, дело не в образе».

9. [middle] Как убедиться, что в CI не публикуется образ, который не прошёл проверки?

Ответ

Порядок шагов: сборка в локальный Docker (load: true, без push), скан с --exit-code 1, и только затем push. Публикация выполняется только после зелёного шага-шлюза. Дополнительно: права токена GITHUB_TOKEN минимальны (packages: write только в этом workflow), теги иммутабельны (не перезаписываются), деплой берёт образ по digest (хеш содержимого образа, он не меняется).

Что хотят услышать: «сначала скан, потом push», минимальные permissions, digest.

Красный флаг: «пушим, а потом сканируем, чтобы быстрее».

10. [junior] Чем Podman отличается от Docker и что для тебя изменится при переходе?

Ответ

Podman не использует постоянный демон и по умолчанию запускает контейнеры без root от обычного пользователя (rootless). CLI почти тот же (podman run, podman build), Dockerfile и образы совместимы, поэтому Trivy, Hadolint и флаги прав работают так же. Отличия ловлю на сетях, портах ниже 1024 и на Compose.

Что хотят услышать: daemonless, rootless, совместимость образов, знание, что в семействе RHEL это стандарт.

Красный флаг: «это другая технология, всё придётся переучивать».

11. [middle] Как не хранить секрет в слоях образа при сборке?

Ответ

Секрет в ENV, ARG или COPY остаётся в истории слоёв, и docker history его покажет: удаление файла следующей командой не помогает, потому что предыдущий слой остаётся в образе. Использую RUN --mount=type=secret (секрет доступен только на время одного шага и не попадает в слой), а в рантайме передаю через переменные окружения или менеджер секретов. Trivy тоже ищет секреты в слоях, но лучше не допускать их появления. Если секрет всё же попал в образ, его считают скомпрометированным и меняют.

Что хотят услышать: --mount=type=secret, docker history, разница между сборкой и запуском, ротация просочившегося секрета.

Красный флаг: «удалю файл следующей командой RUN rm».

12. [middle] Что такое Linux capabilities и зачем --cap-drop=ALL?

Ответ

Capabilities делят всемогущество root на отдельные привилегии: например, привязка к портам ниже 1024 или изменение сетевых настроек. Docker по умолчанию даёт контейнеру урезанный набор. Я иду дальше: docker run --cap-drop=ALL, и добавляю только нужное, например --cap-add=NET_BIND_SERVICE. Ещё ставлю --security-opt no-new-privileges, чтобы процесс не мог получить больше прав через setuid. Так даже при компрометации приложения у атакующего меньше возможностей.

Что хотят услышать: привилегии root по частям, --cap-drop=ALL и точечный --cap-add, no-new-privileges.

Красный флаг: Включить --privileged, потому что «иначе не запускается».

13. [middle] Чем опасны --privileged и монтирование /var/run/docker.sock в контейнер?

Ответ

--privileged даёт контейнеру почти все привилегии и доступ к устройствам хоста, изоляция практически пропадает. Сокет /var/run/docker.sock это доступ к API Docker: кто им владеет, может запустить новый контейнер с монтированием / хоста, то есть получить root на хосте. Поэтому эти опции не использую без крайней необходимости. Если нужна сборка образов в CI, смотрю на rootless-варианты или отдельные сборщики, а не на проброс сокета.

Что хотят услышать: почти полный доступ к хосту, сокет эквивалентен root, альтернативы.

Красный флаг: Пробрасывать docker.sock в контейнеры приложений «для удобства».

Проверено на версиях

Прогонялось 30 сентября 2026 на Docker Desktop (Docker Engine 29.6.2, Apple Silicon), образ собран из эталонного app.py v4 и Dockerfile из урока 4.7:

  • Trivy: v0.74.0 (образ aquasec/trivy:0.74.0): image, config, sbom, --format cyclonedx, --ignorefile с expired_at (прогнано, выводы в уроке настоящие). Набор CVE в них соответствует базе на день прогона, у тебя он будет другим
  • Hadolint: v2.15.1 (образ hadolint/hadolint:v2.15.1): все выводы настоящие, цвета в терминале не показаны
  • Docker Engine: 29.6.2 (запуск с --read-only, --tmpfs, --cap-drop ALL, no-new-privileges прогнан)
  • Python: 3.13 (python:3.13-slim, Debian 13.7), Python 3.9.5 у старого образа
  • actions/checkout v7.0.1, docker/setup-buildx-action v4.4.1, docker/build-push-action v7.4.0, docker/login-action v4.6.0, actions/upload-artifact v7.0.1: версии сверены с последними релизами на GitHub на 30.09.2026
  • image.yml: проверен actionlint 1.7.12 (запуск в GitHub Actions не проверялся)
  • break.sh для урока: прогнан в Docker, shellcheck без замечаний
  • Podman: не прогонялось, версия не закреплена

Итог урока: ты умеешь

  • умею просканировать образ Trivy и прочитать таблицу: пакет, серьёзность, исправленная версия
  • умею превратить скан в шлюз CI через --exit-code 1, --severity и --ignore-unfixed
  • умею оформить исключение Trivy с причиной и сроком в .trivyignore.yaml
  • умею проверить Dockerfile Hadolint и оформить исключение в .hadolint.yaml с причиной
  • умею получить SBOM (CycloneDX), найти в нём пакет через jq и объяснить, зачем его хранить
  • умею запустить контейнер с --read-only, --tmpfs, --cap-drop ALL и no-new-privileges
  • умею расположить шаги в image.yml так, чтобы push шёл только после сканирования
  • умею объяснить, чем Podman отличается от Docker (daemonless, rootless)

Дальше: Урок 4.9: отладка контейнеров и уборка диска

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 4 урок 4.8 4 ч курс 0/0 ← → уроки