Security checklist
Проверки для образа, запуска, Compose и конвейера. У каждой — команда и уровень критичности.
Container — граница между вашими сервисами, а не между вами и злоумышленником. Ни один пункт этого списка не делает его пригодным для недоверенного кода (урок 19.2).
Уровни
| Уровень | Значение |
|---|---|
| Блокирующий | Работа не принимается; исправить до выкатки |
| Высокий | Исправить в ближайшую итерацию |
| Средний | Запланировать |
| Низкий | По возможности |
Образ
| ☐ | Проверка | Команда | Уровень |
|---|---|---|---|
| ☐ | Секретов нет в слоях | docker history --no-trunc ОБРАЗ | grep -iE 'password|secret|token|api_key' | Блокирующий |
| ☐ | Секретов нет в переменных | docker image inspect ОБРАЗ --format '{{json .Config.Env}}' | Блокирующий |
| ☐ | Пользователь задан | docker image inspect ОБРАЗ --format '{{.Config.User}}' | Блокирующий |
| ☐ | Пользователь числовой | значение соответствует ^[1-9][0-9]*(:[0-9]+)?$ | Высокий |
| ☐ | Базовый образ закреплён | grep '^FROM' Dockerfile — тег или digest, не latest | Высокий |
| ☐ | Инструментов сборки нет | docker run --rm ОБРАЗ which gcc make — ничего | Высокий |
| ☐ | Оболочки нет либо она оправдана | docker run --rm --entrypoint sh ОБРАЗ -c 'exit 0' | Средний |
| ☐ | Пакетный менеджер не оставил кэш | docker run --rm ОБРАЗ ls /var/lib/apt/lists — пусто | Средний |
| ☐ | .dockerignore исключает .git и секреты | cat .dockerignore | Высокий |
| ☐ | Образ просканирован | trivy image --severity HIGH,CRITICAL ОБРАЗ | Высокий |
| ☐ | Результат сканирования прочитан | Отчёт с решением по каждой находке | Средний |
| ☐ | SBOM создан | syft ОБРАЗ -o spdx-json | Средний |
Про первую строку. Отсутствие находок в docker history не доказывает отсутствия секрета: он мог попасть в слой файлом, а не аргументом инструкции. Надёжная проверка:
docker save ОБРАЗ -o /tmp/образ.tar
mkdir -p /tmp/распакованное && tar -xf /tmp/образ.tar -C /tmp/распакованное
grep -rl -E 'BEGIN (RSA|OPENSSH|EC) PRIVATE KEY' /tmp/распакованное/ || echo "ключей не найдено"
rm -rf /tmp/образ.tar /tmp/распакованное
Про числовой пользователь. USER app защищает не хуже USER 10001, но Kubernetes с runAsNonRoot: true такой pod не запустит: имя снаружи не разрешается (урок 18.2).
Запуск
| ☐ | Проверка | Команда | Уровень |
|---|---|---|---|
| ☐ | Нет --privileged | docker inspect ИМЯ --format '{{.HostConfig.Privileged}}' — false | Блокирующий |
| ☐ | Сокет Docker не смонтирован | docker inspect ИМЯ --format '{{json .Mounts}}' | grep docker.sock | Блокирующий |
| ☐ | Корень хоста не смонтирован | тот же вывод, поиск "Source":"/" | Блокирующий |
| ☐ | Процесс не от root | docker exec ИМЯ id -u — не 0 | Блокирующий |
| ☐ | Capabilities сняты | docker inspect ИМЯ --format '{{json .HostConfig.CapDrop}}' | Высокий |
| ☐ | Запрет повышения привилегий | docker inspect ИМЯ --format '{{json .HostConfig.SecurityOpt}}' | Высокий |
| ☐ | Корень только для чтения | docker inspect ИМЯ --format '{{.HostConfig.ReadonlyRootfs}}' | Высокий |
| ☐ | seccomp не отключён | SecurityOpt не содержит seccomp=unconfined | Высокий |
| ☐ | Ограничения ресурсов заданы | docker inspect ИМЯ --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}' | Высокий |
| ☐ | PID namespace свой | docker inspect ИМЯ --format '{{.HostConfig.PidMode}}' — пусто | Высокий |
| ☐ | Сеть не host | docker inspect ИМЯ --format '{{.HostConfig.NetworkMode}}' | Средний |
| ☐ | Порты публикуются осознанно | docker port ИМЯ | Средний |
Три конфигурации, эквивалентные root на хосте:
| Что | Почему |
|---|---|
--privileged | Снимает seccomp, AppArmor, возвращает все capabilities |
Монтирование /var/run/docker.sock | Позволяет создать container с любыми параметрами |
cap_add: [SYS_ADMIN] | Монтирование и значительная часть операций root |
Вторая опаснее прочих тем, что выглядит безобидно — «просто файл».
Проверка одной командой
docker inspect ИМЯ --format '
privileged: {{.HostConfig.Privileged}}
readonly: {{.HostConfig.ReadonlyRootfs}}
pid: {{.HostConfig.PidMode}}
network: {{.HostConfig.NetworkMode}}
capdrop: {{json .HostConfig.CapDrop}}
capadd: {{json .HostConfig.CapAdd}}
secopt: {{json .HostConfig.SecurityOpt}}
memory: {{.HostConfig.Memory}}'
Compose
| ☐ | Проверка | Команда | Уровень |
|---|---|---|---|
| ☐ | Паролей нет в открытом виде | grep -riE 'password\s*[:=]\s*[^$/]' compose*.yaml | Блокирующий |
| ☐ | Файлы секретов в .gitignore | git check-ignore secrets/* | Блокирующий |
| ☐ | Секреты передаются файлом | _FILE-переменные, а не значения | Высокий |
| ☐ | Образы по конкретным тегам | grep 'image:' compose.yaml | grep -v latest | Высокий |
| ☐ | Внутренняя сеть для данных | docker network inspect ПРОЕКТ_backend --format '{{.Internal}}' | Высокий |
| ☐ | База и кэш не публикуют порты | docker compose ps --format '{{.Service}} {{.Ports}}' | Высокий |
| ☐ | Ограничения у всех сервисов | см. команду ниже | Высокий |
| ☐ | Ротация логов настроена | logging.options.max-size у всех | Средний |
| ☐ | Публикация только на 127.0.0.1 в разработке | grep 'ports:' -A 2 compose.dev.yaml | Средний |
docker compose config | python3 -c '
import sys, yaml
d = yaml.safe_load(sys.stdin)
for name, svc in d["services"].items():
limits = (svc.get("deploy") or {}).get("resources", {}).get("limits", {})
log = (svc.get("logging") or {}).get("options", {})
print(f"{name:<12} лимиты={limits or \"НЕТ\"} ротация={log.get(\"max-size\", \"НЕТ\")}")'
Конвейер
| ☐ | Проверка | Уровень |
|---|---|---|
| ☐ | Секреты не печатаются в журнал сборки | Блокирующий |
| ☐ | Отсутствие сканера отличимо от чистого результата | Высокий |
| ☐ | Публикация только при полностью пройденном конвейере | Высокий |
| ☐ | Тег неизменяемый, по SHA коммита | Высокий |
| ☐ | Развёртывание по digest | Высокий |
| ☐ | Базовые образы обновляются регулярно | Высокий |
| ☐ | Секреты сборки через --secret, не ARG | Блокирующий |
| ☐ | Права токена минимальны | Средний |
| ☐ | Образ подписан | Низкий |
Про маскирование секретов. Механизм маскирования в CI подменяет точное совпадение строки. Он не сработает, если значение преобразовано:
| Преобразование | Маскирование |
|---|---|
| Вывод как есть | Работает |
| Base64 | Не работает |
| Часть строки | Не работает |
| Обратный порядок | Не работает |
| Другой регистр | Не работает |
Отсюда правило: полагаться на маскирование нельзя, секрет не должен попадать в вывод вообще (урок 16.2).
Организационные проверки
Инструментами не проверяются. Ответы записываются словами.
| ☐ | Вопрос |
|---|---|
| ☐ | Кто, кроме автора, может собрать этот образ? |
| ☐ | Когда последний раз обновлялся базовый образ? |
| ☐ | Кто читает отчёты сканирования и что делает с находками? |
| ☐ | Что произойдёт при утечке образа наружу? |
| ☐ | Какие секреты пришлось бы отозвать? |
| ☐ | Кто имеет права на выгрузку в registry? |
| ☐ | Есть ли процедура отзыва скомпрометированного ключа? |
Ноль находок инструментами не отвечает ни на один из этих вопросов. Они наносят наибольший ущерб и не обнаруживаются никаким линтером (урок 19.4).
Граница применимости
Полезно назвать прямо, чтобы список не читался шире, чем он есть.
| От чего защищает | От чего нет |
|---|---|
| Ошибки приложения, влияющие на хост | Уязвимости ядра |
| Влияние сервисов друг на друга | Недоверенный код |
| Случайное раскрытие секретов | Вредоносную зависимость в requirements.txt |
| Исчерпание ресурсов одним сервисом | Побочные каналы |
Для недоверенного кода — user namespace, gVisor, Kata, микро-ВМ или отдельная машина. Выбор зависит от того, кто именно недоверенный (урок 19.2).
Навигация
Вернуться к справочникам
Production checklist
Каталог антипаттернов
Раздел 12. Security
Главное оглавление