Берегите очко смолоду
Часть 5 — про Docker-слой и рантайм-хардненинг.
Где здесь риск для владельца сервиса#
Docker часто продаёт ощущение порядка: всё лежит в контейнерах, значит «изолировано». На практике контейнер — это упаковка процесса, а не автоматическая безопасность. Ошибки в правах, volume, сети и переменных окружения всё равно остаются вашими.
Для продукта это означает простую вещь: compose-файл, Dockerfile и доступы к контейнерам нужно читать как часть инфраструктуры, а не как вспомогательные файлы для запуска.
Контейнеры дали нам удивительное чувство ложной безопасности. «У меня же всё в Docker, оно изолировано» — фраза, которую я слышал столько раз, что начал вздрагивать. Изолировано оно ровно настолько, насколько ты сам это настроил. А по умолчанию Docker заботится об удобстве запуска, а не о том, чтобы тебя не разорвало при первой же RCE. Эта часть — самая инфраструктурная в серии, так что местами полезем чуть глубже. Но по-человечески, без занудства.
База, которая смотрит в интернет#
Начнём с того, что я вижу чаще всего. В docker-compose.yml у базы данных проброшен порт на хост, а рядом — логин и пароль root открытым текстом.
services:
db:
image: postgres
ports:
- "5432:5432"
environment:
POSTGRES_PASSWORD: supersecret123
Эти три строчки ports означают, что любой, кто знает IP сервера, может постучаться в базу напрямую. А креды лежат тут же, в репозитории, в истории git, в бэкапах. Датасторы не публикуются наружу — точка. Приложение ходит в базу по внутренней docker-сети, по имени сервиса. Наружу — ничего.
services:
db:
image: postgres@sha256:<digest>
env_file:
- ./secrets/db.env
networks:
- internal
Креды — в env-файле с правами chmod 600 или в секретах, не инлайн. И на всякий случай — фаервол с allow-list (ufw), чтобы даже при случайном пробросе порт не торчал в мир.
Сокет, который раздаёт root#
А вот история, от которой у меня до сих пор холодок. В основной сервис — тот самый, что ходит в интернет и принимает запросы пользователей — был примонтирован /var/run/docker.sock.
volumes:
- /var/run/docker.sock:/var/run/docker.sock
Перевожу с инженерного на человеческий: код, до которого может дотянуться злоумышленник, получил полный контроль над Docker-демоном. А Docker-демон — это root на хосте. То есть RCE в вебе автоматически означает root на всей машине. Хуже комбинации придумать сложно.
Если доступ к сокету реально нужен (например, чтобы пересоздавать контейнер при деплое), его выносят в отдельный крошечный прокси с единственным эндпоинтом — «пересоздай вот это». Сокет монтируется только туда и только в режиме чтения, прокси сидит во внутренней сети, без выхода наружу.
docker-proxy:
image: minimal-recreate-proxy@sha256:<digest>
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
mem_limit: 128m
networks:
- internal
Если без этого компромисса совсем никак — задокументируй его явно, чтобы через год кто-то (возможно, ты сам) понимал, почему тут торчит сокет и какие ограничения его держат.
Образ под root с полноценным shell внутри#
Большинство рантайм-образов собирают на жирной базе, гоняют под root и тащат внутрь весь Linux — shell, пакетный менеджер, утилиты. Удобно отлаживаться. И удобно атаковать: при RCE у злоумышленника под рукой bash, curl, apt — полный набор, чтобы скачать и запустить пейлоад.
Distroless-образ под non-root меняет расклад. Фиксированный uid, никакого shell, никакого пакетного менеджера. Нет shell в рантайме — значит, даже при удачной RCE нечем запустить полезную нагрузку. Это не панацея, но огромный кусок типовых атак отваливается сам.
FROM node:20-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["server.js"]
Отдельная стадия для прод-зависимостей заодно выкидывает из финального образа всё, что нужно было только при сборке.
Рантайм без тормозов#
Даже хороший образ можно запустить нараспашку. По умолчанию контейнер пишет в свою файловую систему, держит полный набор capabilities и не ограничен по памяти. Базовый хардненинг стоит включать в compose как привычку, а не как реакцию на инцидент.
read_only: true
tmpfs:
- /tmp:uid=65532,gid=65532
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
mem_limit: 512m
pids_limit: 256
read_only не даст записать пейлоад на диск, cap_drop: ALL отбирает лишние привилегии, no-new-privileges запрещает их повышение, лимиты памяти и pids спасают от того, чтобы один свихнувшийся контейнер не утянул за собой весь хост.
read_only режет запись в overlay, но процессу часто нужен хотя бы /tmp — или каталог под кэш и pid-файлы. Тогда вешают tmpfs. Подводный камень, о котором редко пишут: tmpfs по умолчанию создаётся от root. Контейнер под non-root (а после distroless — всегда) туда не запишет, и вы ловите EACCES при старте без очевидной причины. В tmpfs надо явно передать uid и gid того пользователя, под которым бежит процесс — те же, что в USER в Dockerfile или в поле user: compose.
:latest — это не версия#
Базовый образ по тегу :latest означает, что сегодня и завтра ты собираешь буквально разный софт, а воспроизвести вчерашнюю сборку невозможно. Пиннинг по digest на всех стадиях фиксирует, что именно ты запускаешь. А обновлять это сознательно помогает Dependabot или аналог — он принесёт PR с новым digest, и ты сам решишь, когда катить.
Тихая деградация при пересоздании#
Самый коварный баг в этой статье. Автоматика пересоздавала контейнер при деплое и переносила только часть конфигурации — порты, переменные. А CapDrop, SecurityOpt, read_only тихо терялись. Снаружи всё работает, контейнер бежит, а защиты на нём уже нет. Никто не заметит, пока не клюнет.
При пересоздании переносится весь HostConfig/Config, а не выборочные поля. Иначе хардненинг живёт ровно до первого редеплоя.
Служебный сервис, который светит порт#
И инфраструктурный аналог IDOR из прошлой части. Auth-сервис (или любой служебный компонент) опубликовал свой порт наружу. В итоге клиент может ходить мимо reverse-proxy — напрямую. А раз напрямую, то можно подделать заголовок с IP-адресом, на который завязан rate-limit, и обойти ограничение.
Служебные компоненты — только internal. Реальный IP клиента берём исключительно из доверенного хопа прокси, а не из заголовка, который клиент сам себе нарисовал.
И отдельная оговорка: секреты, утёкшие в build-ARG и слои образа — тема для своей части серии. Тут просто держим в голове, что слой образа помнит всё, что ты в него положил.
Коротко#
Docker не делает вас безопасными — он даёт инструменты, которыми вы либо пользуетесь, либо нет. База во внутренней сети, сокет за прокси, distroless под non-root, хардненинг по умолчанию, пиннинг по digest и перенос всей конфигурации при редеплое. Скучный чеклист. Но именно он стоит между «у меня RCE в одном контейнере» и «у меня root на всём сервере».
Если проект уже живёт в Docker, но compose-файл давно никто не проверял глазами безопасности, это нормальная точка для аудита. Напишите мне в Telegram или на почту — помогу разобрать контейнеры, сети, права, секреты и обновления так, чтобы инфраструктура не превращала один баг в полный доступ к серверу.



