✻ Урок 4.8 · Тема 4: Docker и Compose
Безопасность образов: Trivy, Hadolint, SBOM
Содержание урока
Зачем это нужно
Образ - это не только твой 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 проходит эти проверки.
Что нужно знать
- Урок 1.3: пользователи и права: у каждого процесса есть владелец (uid, числовой номер пользователя), а root (uid 0, администратор) может всё.
- Урок 1.4: процессы и сигналы: контейнер - это обычный процесс, а первый процесс (PID 1) получает сигналы остановки.
- Урок 3.4: качество и безопасность в CI: там ты уже запускал
trivy fsпо репозиторию и слышал про идею shift-left (проверять как можно раньше). - Урок 4.2: Dockerfile: слои,
USER 10001:10001,HEALTHCHECK, exec-формаCMD. - Урок 4.3: тома и сети: именованный том
notes-data, смонтированный в/data. - Урок 4.7: multi-stage, теги и ghcr.io: образ
notes:0.4.0и workflowimage.yml, который мы сейчас расширяем.
Все инструменты урока (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, хочется спросить: «А кто вообще должен это чинить, я или кто-то другой?» Ответ определяет, что ты делаешь дальше: обновляешь образ, ждёшь исправления или меняешь библиотеку.
Продуктовый магазин и поставщики. Ты не выращиваешь овощи, а берёшь их у поставщика, который отвечает за качество. Если в партии нашли проблему, поставщик присылает замену, а тебе остаётся заменить товар на полке. Аналогия перестаёт работать в том, что в магазине ты видишь новую партию сразу, а образ сам не обновляется: пока ты не пересоберёшь, на «полке» лежит старое.
Устроено это так.
- Debian это дистрибутив (distribution): собранный кем-то набор из ядра, программ и библиотек. Его команда (мейнтейнеры, maintainers) выбирает версии и отвечает за исправления.
- Каждая программа в дистрибутиве это пакет с номером версии. Когда в программе находят уязвимость, мейнтейнер готовит исправленный пакет с новой версией.
- Базовый образ
python:3.13-slimпериодически пересобирается с уже исправленными пакетами. - Твой образ содержит слой с пакетами на момент твоей сборки. Пока ты не пересоберёшь и не выложишь заново, в нём остаётся то, что было тогда.
- Поэтому сканер находит «новые» уязвимости в старом образе: сам образ не менялся, изменилась база знаний о дырах.
Вот как это выглядит на деле. Ты собрал образ 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, Fixed1.10.0-1. Исправление уже стоит?
Ответ
Нет: 10 больше 9, значит, 1.10.0 новее 1.9.4. Версии сравнивают по частям как числа, а не как строки. Чтобы получить исправление, нужно обновить пакет до 1.10.0-1 или новее.
Теперь посмотрим, как сканер делает это за нас.
Trivy: сканер образов
Проверить руками сотни пакетов на сотни тысяч записей в списке уязвимостей невозможно. Сканер делает это за десятки секунд и одинаково каждый раз.
Санитарный инспектор с базой запрещённых добавок: он не пробует еду на вкус, а читает состав и сверяет со списком. Как и у инспектора, у сканера есть предел: чего нет в списке, он не заметит.
Как устроено, по шагам. Trivy (проект компании Aqua Security, курс использует v0.74.0) делает вот что:
- Скачивает базу уязвимостей (vulnerability database): свежий список CVE в удобном для машины виде. Делает это при первом запуске и потом обновляет. Файлы хранит в кэше, чтобы не качать заново.
- Распаковывает слои образа и определяет операционную систему по файлу
/etc/os-release(например Debian 13.7). - Ищет списки установленных пакетов. У Debian это файл
/var/lib/dpkg/status, у Python - каталоги*.dist-infoс файламиMETADATA. Каждый пакет - пара «имя и версия». - Сверяет каждую пару с базой: есть ли для этого пакета этой версии известная уязвимость и в какой версии она исправлена.
- Печатает таблицу и завершается с кодом выхода.
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.
- Не root. В образе есть
USER 10001:10001(урок 4.2): процесс работает под обычным uid, а не под 0. Проверка:docker run --rm notes:0.4.0 id. Это самая дешёвая защита и самая важная. --read-only. Корневая файловая система контейнера (то, что лежит в самом образе:/app,/usr,/etc) становится только для чтения. Атакующий не может дописать вредоносный файл или подменитьapp.py. Приложению нужно писать данные и временные файлы, поэтому для этого явно дают отдельные места: том (-v notes-data:/data) для данных и--tmpfs /tmpдля временных файлов.tmpfs- каталог в оперативной памяти, он исчезает вместе с контейнером.--cap-drop ALL. Capabilities (возможности) - это «права root, нарезанные на кусочки». Вместо «может всё или ничего» ядро Linux делит полномочия root на десятки отдельных:CHOWN(менять владельца файлов),NET_BIND_SERVICE(слушать порты меньше 1024),SYS_ADMIN(почти всё остальное) и так далее. Docker по умолчанию оставляет процессам контейнера около 14 из них.--cap-drop ALLубирает все, а нужную можно вернуть точечно:--cap-add NET_BIND_SERVICE.--security-opt no-new-privileges. В Linux есть файлы с особым битом setuid: программа, запущенная обычным пользователем, работает с правами владельца файла (обычно root). Так устроеныsu,passwd,mount. Это нужно системе, но если атакующий найдёт ошибку в такой программе, он поднимется до root. Флаг запрещает процессу и его потомкам получать больше прав через setuid, ядро просто игнорирует бит.- Не
--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 сканирует его оттуда, а опубликован он будет только последним шагом.
Что делать, когда шлюз красный. Красный шлюз - не сбой, а сообщение «посмотри». Порядок действий:
- Открой таблицу и найди
Fixed Version. Есть исправление - иди к шагу 2. Нет - к шагу 4. - Пересобери на свежей базе:
docker build --pull(флаг--pullзаставляет Docker заново проверить базовый образ, а не брать из локального кэша старый). Если мейнтейнеры образаpythonуже выпустили обновление, находка исчезнет. - Если база ещё не обновлена, а исправление уже есть в репозитории Debian, добавь в итоговую стадию Dockerfile
apt-get upgrade(обновить установленные пакеты). Цена: сборка перестаёт быть строго воспроизводимой (завтра обновится другое), зато закрыто окно между выходом исправления и пересборкой базового образа. - Исправления нет или оно не про нас: оцени, вызывается ли уязвимый код, и запиши исключение в
.trivyignore.yamlс причиной и сроком пересмотра. Это последний вариант, а не первый. - Профилактика: бот вроде 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. А если образ уже опубликован, считать пароль утёкшим и заменить.
Приоритеты: какие находки чинить первыми
Через месяц работы у тебя будут десятки находок в разных образах. Если считать каждую срочной, команда выгорит и перестанет смотреть отчёты. Значит, нужны способы отличить «горит» от «подождёт».
Приёмный покой больницы. Врач не лечит по порядку записи, а сортирует по тяжести: кровотечение раньше насморка. Но и тяжесть определяется не только диагнозом, но и обстоятельствами: кровотечение у пациента, который уже под наблюдением хирурга, не то же самое, что на улице.
Оценка приоритета складывается из нескольких вопросов:
- Уровень по CVSS. Первое приближение. CRITICAL и HIGH разбираем всегда, MEDIUM и LOW пачкой, по расписанию.
- Есть ли исправление (
Fixed Version). Без исправления мы можем только смягчить. - Достижим ли уязвимый код. Уязвим ли компонент, который наше приложение действительно использует? DTLS-уязвимость в
opensslне задевает приложение, которое DTLS не включает. Это решает человек, вооружённый знанием приложения. Иногда для этого смотрят описание уязвимости и ищут, какой функции она касается. - Где работает контейнер. Сервис, доступный из интернета, важнее внутреннего инструмента. Контейнер с ограниченными правами (
--read-only, без capabilities) хуже поддаётся эксплуатации. - Используется ли уязвимость злоумышленниками. Есть публичные списки активно эксплуатируемых уязвимостей (например, каталог 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 не считает находки ошибкой.
Шаги:
- Подготовь каталог и каталог для кэша базы уязвимостей (чтобы не качать её при каждом запуске).
mkdir -pсоздаёт каталоги, и не жалуется, если они уже есть;~- твой домашний каталог:
mkdir -p ~/lab48 ~/.cache/trivy
cd ~/lab48
- Просканируй свой образ. Сначала разбор команды:
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.
- Сравни со старым образом и проверь код выхода шлюза (
--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.
Шаги:
- Создай намеренно плохой файл. Команда
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
- Запусти линтер. Разбор:
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по новому файлу, нейросеть может предложить версии пакетов, которых нет.
- Исправь: закрепи тег базы, добавь
--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 - один элемент списка (дефис и пробел).
- Проверь исправленный файл. 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 - это просто файл, а не пакет, в описи его нет. Есть сам образ как контейнер-компонент верхнего уровня.
Шаги:
- Получи опись в формате 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, если её ещё нет).
- Посмотри общие сведения и посчитай компоненты по типам. Разбор:
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 (вложенные библиотеки).
- Найди конкретные пакеты (как «понедельничный» поиск
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
- Просканируй опись, не трогая образ. Каталог с файлом монтируем в
/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 записать получится.
Шаги:
- Проверь пользователя в образе. Команда
idпечатает uid, gid и группы;docker run --rm образ командазапускает её вместо обычногоCMD:
docker run --rm notes:0.4.0 id
uid=10001 gid=10001 groups=10001
- Запусти ограниченный контейнер. Разбор флагов:
-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
- Проверь запись в запрещённое и в разрешённое место.
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 уже были заметки).
- Посмотри, какие права остались у процесса, и убери контейнер.
/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 ничего не отменит: его успеют скачать.
Шаги:
- В
~/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
- Прогони 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
- Проверь шлюз 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)
- Для этих двух находок исправления в самом образе нет: они лежат внутри вложенных копий в
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 ниже смонтирует его без ошибки.
- Замени
.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»), потому что второе двоеточие с пробелом считается началом нового значения.
- Закоммить через ветку и 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.
Симптом
Скрипт печатает, какой контейнер, образ или файл подготовлен. Возможные симптомы (по одному за запуск):
- Шлюз Trivy красный по образу
notes:break-1: CRITICAL в базовом образе. - Контейнер
notes-break-2работает, ноPOST /notesотвечает 500{"error": "storage"}, в логеRead-only file system. - 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: проверенactionlint1.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.