Берегите очко смолоду
Часть 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 или на почту — помогу разобрать контейнеры, сети, права, секреты и обновления так, чтобы инфраструктура не превращала один баг в полный доступ к серверу.