Лучшие практики при написании безопасного Dockerfile

?v=1

В данной статье мы рассмотрим небезопасные варианты написания собственного Dockerfile, а также лучшие практики, включая работу с секретами и встраивание инструментов статического анализа. Тем не менее для написания безопасного Dockerfile наличия документа с лучшими практиками мало. В первую очередь требуется организовать культуру написания кода. К ней, например, относятся формализация и контроль процесса использования сторонних компонентов, организация собственных Software Bill-of-Materials (SBOM), выстраивание принципов при написании собственных базовых образов, согласованное использование безопасных функций, и так далее. В данном случае отправной точкой для организации процессов может служить модель оценки зрелости BSIMM. Однако в этой статьей пойдет речь именно о технических аспектах.

Безопасное написание Dockerfile

Задавать LABEL и не использовать тег latest

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

FROM redis@sha256:3479bbcab384fa343b52743b933661335448f816
LABEL version 1.0
LABEL description "Test image for labels"

Для описания собственного образа также рекомендуется использовать инструкцию LABEL, чтобы исключить ошибку со стороны тех, кто будет использовать ваш образ в дальнейшем. Хорошей практикой также является определение LABEL securitytxt.

LABEL securitytxt="https://www.example.com/.well-known/security.txt"

Ссылка в ярлыке security.txt позволяет предоставить контактную информацию для исследователей, которые могли обнаружить проблему безопасности в образе. Особенно это актуально, если образ является публично доступным. Больше деталей по этому ярлыку можно найти тут.

Не использовать автоматическое обновление компонентов

Использование apt-get upgrade, yum update может привести к тому, что внутри вашего контейнера будет произведена установка неизвестного вам ранее ПО, либо ПО уязвимой версии. Чтобы избежать этого, устанавливайте пакеты, четко указывая версию для каждого из них. Каждая версия должна проверяться на наличие в ней уязвимостей до того, как компонент окажется внутри вашего контейнера. Версия компонента может быть проверена с помощью инструментов класса Software Composition Analysis (SCA).

Пример установки компонента с точностью до версии:

RUN apt-get install cowsay=3.03+dfsg1-6

Производить скачивание пакетов безопасным образом

Использование curl и wget без мер предосторожности позволяет злоумышленнику выполнять скачивание нежелательных компонентов с неизвестных ресурсов (атака «человек-посередине», при которой злоумышленник может перехватить незащищенный трафик и подменить скачиваемый нами пакет на зловредный). Это полностью разрушает концепцию Zero trust, согласно которой необходимо проверять любое подключение или действие до предоставления доступа (или в данном случае установки компонента с неизвестного ресурса). Соответственно следующий сценарий скачивания будет являться грубейшей ошибкой, так как происходит выполнение скрипта, полученного из недоверенного источника по небезопасному каналу без должных проверок:

RUN wget http://somesite.com/some-packet/install.sh | sh

Чтобы убедиться, что скаченный компонент будет являться действительно тем, что мы ожидаем, в качестве решения может подойти использование GNU Privacy Guard (GPG). Разберемся, как это работает.

В большинстве случаев вендоры вместе с библиотекой или ПО предоставляют также хеш-сумму, которую мы можем проверить при скачивании. Данная хеш-сумма подписывается вендорами с помощью закрытого ключа в рамках GPG, а открытые ключи помещаются в репозитории. Следующий пример демонстрирует, как может выглядеть безопасное скачивание компонентов Node.js:

RUN gpg --keyserver pool.sks-keyservers.net \
--recv-keys 7937DFD2AB06298B2293C3187D33FF9D0246406D \
            114F43EE0176B71C7BC219DD50A3051F888C628D

ENV NODE_VERSION 0.10.38
ENV NPM_VERSION 2.10.0
RUN curl -SLO "http://nodejs.org/dist/v$NODE_VERSION/node-v \
$NODE_VERSION-linux-x64.tar.gz" \
&& curl -SLO "http://nodejs.org/dist/v$NODE_VERSION/\SHASUMS256.txt.asc" \
&& gpg --verify SHASUMS256.txt.asc \
&& grep " node-v$NODE_VERSION-linux-x64.tar.gz$" SHASUMS256.txt.asc | sha256sum -c -

Давайте разберемся, что здесь происходит:

  1. Получение открытых GPG-ключей

  2. Скачивание Node.js пакета

  3. Скачивание хеш-суммы Node.js пакета на базе алгоритма SHA256

  4. Использование GPG-клиента для проверки, что хеш-сумма подписана тем, кто владеет закрытыми ключами

  5. Проверяем, что вычисленная хеш-сумма от пакета совпадает со скаченной с помощью sha256sum

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

Иногда разработчикам бывает необходимо использовать сторонние репозитории для установки компонента с помощью deb или rpm. В данном случае мы также можем воспользоваться GPG, а проверка хеш-суммы будет проводиться менеджерами пакетов при скачивании.

Пример безопасного добавления GPG-ключей вместе с источником пакетов.

RUN apt-key adv --keyserver hkp://pgp.mit.edu:80 \
--recv-keys 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62
RUN echo "deb http://nginx.org/packages/mainline/debian/\
jessie nginx" >> /etc/apt/sources.list

В случае, если для пакетов цифровая подпись в явном виде не предоставляется вендором, ее необходимо предварительно создать и сравнить в рамках сборки.

Пример для SHA256:

RUN curl -sSL -o redis.tar.gz \
http://download.redis.io/releases/redis-3.0.1.tar.gz \
&& echo "0e21be5d7c5e6ab6adcbed257619897db59be9e1ded7ef6fd1582d0cdb5e5bb7 \
*redis.tar.gz" | sha256sum -c -

Не использовать ADD

Инструкция ADD, получив в качестве параметра путь к архиву, автоматически распакует этот архив при своем выполнении. Это может привести, в свою очередь, к появлению zip-бомбы внутри контейнера. При распаковке zip-бомба может вызвать отказ в обслуживании (DoS) приложения, путем заполнения всего выделенного свободного места.

Еще одна небольшая особенность ADD команды заключается в том, что вы можете передать ей URL в качестве параметра, и она будет извлекать контент во время сборки, что также может привести к атаке «человек-посередине»:

ADD https://cloudberry.engineering/absolutely-trust-me.tar.gz

Аналогично предыдущей рекомендации стоит добавлять компоненты в образ инструкцией COPY, так как она работает с локальными данными, предварительно проверяя их при помощи SCA инструментов.

Задавать USER в конце Dockerfile

В случае, если злоумышленнику удастся заполучить shell внутри вашего контейнера, он может оказаться root’ом, что сильно упростит дальнейшую атаку с выходом за пределы контейнера. Чтобы избежать этого, указывайте пользователя в явном виде через инструкцию USER. Однако это работает только в том случае, если вашему приложению не требуется привилегии root после сборки.

RUN groupadd -r user_grp && useradd -r -g user_grp user
USER user

Использовать gosu вместо sudo в процессе инициализации

Утилита gosu будет полезна, когда необходимо предоставлять root доступ после сборки Dockerfile во время инициализации, но при этом приложение должно запускаться в непривилегированном режиме.

В примере ниже выполняется команда chown в рамках entrypoint-скрипта, требующая права root, после чего приложение продолжает выполняться из-под пользователя redis.

#!/bin/bash
set -e
if [ "$1" = 'redis-server' ];
  then
    chown -R redis . 
    exec gosu redis "$@"
  fi
exec "$@"

Основная цель инструмента — запуск процессов от определенного пользователя, но в отличие от sudo и su, gosu не делает fork процессов, как показано ниже:

$ docker run -it --rm ubuntu:trusty su -c 'exec ps aux'
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0  46636  2688 ?        Ss+  02:22   0:00 su -c exec ps a
root         6  0.0  0.0  15576  2220 ?        Rs   02:22   0:00 ps aux
$ docker run -it --rm ubuntu:trusty sudo ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  3.0  0.0  46020  3144 ?        Ss+  02:22   0:00 sudo ps aux
root         7  0.0  0.0  15576  2172 ?        R+   02:22   0:00 ps aux
$ docker run -it --rm -v $PWD/gosu-amd64:/usr/local/bin/gosu:ro ubuntu:trusty gosu root ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   7140   768 ?        Rs+  02:22   0:00 ps aux

Это сохраняет концепцию, согласно которой контейнер ассоциируется с единственным процессом. Тем не менее, из слов разработчиков gosu не является заменой sudo, так как в рамках данного fork’а происходит взаимодействие с Linux PAM через функции pam_open_session() и pam_close_session(). Использование gosu вместо sudo за пределами процесса инициализации может привести к некорректной работе приложения.

Distroless images и минимальные образы

Можно сильно сократить поверхность атаки злоумышленника отказавшись от базовых образов Linux-дистрибутивов (Ubuntu, Debian, Alpine) и перейдя на Disroless-образы. Это образы, которые содержат только само приложение и необходимые для него зависимости без использования лишних системных компонентов (например, bash). Одним из явных преимуществ, помимо сокращения поверхности атаки и возможностей злоумышленника, является снижение размера образа. Это в свою очередь уменьшает «шум» в результатах сканирования такими инструментами как Trivy, Clair и так далее.

Подобная стратегия имеет много преимуществ с точки зрения безопасности, но сильно затрудняет работу разработчиков, которым необходимо заниматься отладкой внутри контейнера. Альтернативным вариантом может являться использование минимального образа, например, на базе UNIX-сборки Alpine. При ответственном соблюдении данного подхода, образ разработчика будет содержать минимум зависимостей, что позволит сканерам образов сформировать меньший объем результатов, среди которых будет проще отсеять ложные срабатывания.

Цикл статей по созданию минимального образа:

Одним из вариантов, как можно эффективно сократить размер образа является multi-stage сборка, которая ко всему прочему поможет безопасно работать с секретами. Об этом будет в следующем подразделе.

Полезным инструментом также является Docker-slim, позволяющий уменьшить размер написанного образа.

Безопасная работа с секретами

Секреты могут по ошибке помещаться в качестве параметра инструкции ENV или передаваться внутрь образа как текстовый файл. Также секреты могут скачиваться через wget. Подобные сценарии недопустимы, так как злоумышленник может с легкостью получить доступ к секретам. Это можно сделать, например, получив доступ к API Docker:

# docker inspect ubuntu -f {{json .Config.Env}}
["SECRET=mypassword", ...]

Также злоумышленник может получить доступ к секретам через логи, директорию /proc или при утечки файлов исходных кодов. В данном случае лучше всего может подойти использование решений класса Vault, например, HashiCorp Vault или Conjur, однако рассмотрим и другие методы.

Соблюдать принцип многоэтапных сборок

Многоэтапная (multi-stage) сборка поможет не только сократить размер вашего образа, но еще и организовать эффективное управление секретами. Основной принцип — извлекать и управлять секретами на промежуточном этапе сборки образа, который позже удалится. В итоге конфиденциальные данные не попадут в сборку конечного образа.

#builder
FROM ubuntu as intermediate

WORKDIR /app
COPY secret/key /tmp/
RUN scp -i /tmp/key build@acme/files .

#runner
FROM ubuntu
WORKDIR /app
COPY --from=intermediate /app .

Один из минусов этого сценария — сложное кэширование, что ведет к замедлению сборки.

Работа с секретами через BuildKit

С версии Docker 18.09 появляется экспериментальная интеграция со сборщиком BuildKit, которая позволит кроме увеличения производительности, эффективно работать с секретами.

Пример синтаксиса:

# syntax = docker/dockerfile:1.0-experimental
FROM alpine

# shows secret from default secret location
RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecre

# shows secret from custom secret location
RUN --mount=type=secret,id=mysecret,dst=/foobar cat /foobar

После выполнения сборки с помощью buildkit с указанием ключа --secret секретная информация не сохранится в конечном образе.

Пример:

$ docker build --no-cache --progress=plain --secret id=mysecret,src=mysecret.txt .

Подробнее можно прочитать в официальной документации Docker.

Остерегаться рекурсивного копирования

Команда из примера ниже может привести к тому, что файл секрета случайно окажется внутри вашего образа, если находится внутри той же директории. Если вы имеете подобные файлы, не забывайте использовать .dockerignore. Среди файлов внутри образа могут оказаться .git, .aws, .env.

COPY . .

Один из примеров подобных ошибок — кейс с утечкой исходных кодов Twitter Vine. Так в 2016 году специалист по информационной безопасности обнаружил на DockerHub образ vinewww, внутри которого обнаружились исходные коды сервиса Vine, ключи API и секреты сторонних сервисов.

Анализаторы Dockerfile

Используйте анализаторы Dockerfile, которые можно встроить в ваш пайплайн сборки. Это могут быть:

Hadolint — простой линтер Dockerfile. Большая часть проверок не относится к security и основана на официальных рекомендациях Docker (ссылка). Однако для наиболее распространенных ошибок таких проверок будет достаточно.

Conftest — анализатор файлов конфигураций, в том числе Dockerfile. Для проверки Dockerfile требуется предварительно написать правила на языке Rego, который, помимо этого, используется в быстро набирающей технологии Open Policy Agent для защиты облачных сред в процессе эксплуатации. Это позволит сохранить вариативность, возможность кастомизации и не потребует изучения ранее неизвестных языков. Conftest не содержит встроенный набор правил, поэтому подразумевается, что вы будете писать их самостоятельно. В качестве отправной точки можно использовать эту статью.

Для автоматизации процесса проверки можно встроить эти инструменты в пайплайн разработки. Пример встраивания:

Способы и примеры внедрения утилит для проверки безопасности Docker.

Практика

Вы можете попробовать проэксплуатировать распространенные уязвимости при написании Dockerfile с помощью образа Pentest-in-Docker. Пошаговое описание можно найти в репозитории. Одна из главных ошибок — использование debian:wheazy, старого образа Debian, на котором поддерживается не требующийся для работы приложения Bash, содержащий в себе уязвимость удаленного выполнения кода (RCE). Таким образом злоумышленник может получить доступ к сервисной учетной записи www-data, отправив запрос на подключение к reverse-shell. Вторая ошибка — использование sudo, что позволяет злоумышленнику повысить привилегии от www-data до root внутри контейнера. И наконец отсутствие USER в конце Dockerfile из-за чего злоумышленник может выполнять действия из-под root в случае, если получит доступ к API Docker.

Дополнительные ресурсы

© Habrahabr.ru