По данным отчета «Cloud-Native Security & Usage 2024» 8.2% контейнерных образов, используемых в production-средах, содержат высокие или критические уязвимости в пакетах, для которых уже доступны исправления. Используя некоторые уязвимости, злоумышленники могут выполнять произвольный код, получать доступ к секретам, повышать свои привилегии внутри контейнера, а затем и на хосте.
Поэтому безопасное и своевременное обновление компонентов контейнерных образов — ключевой этап в жизненном цикле конвейера CI/CD. Мы различными подходами регулярно обновляем контейнерные образы, используемые в нашем продукте Apsafe — платформе непрерывного анализа защищенности приложений. Далее я расскажу подробнее про один из подходов обновления образов, позволяющий автоматизировать данный процесс с использованием инструмента Copacetic.
Что такое Copacetic?
Copacetic — инструмент для точечного патчинга уязвимостей в контейнерных образах, основанный на Go и Buildkit. Он позволяет быстро обновлять контейнерные образы без полной пересборки.
Copacetic выполняет следующие функции:
Анализирует результаты сканирования уязвимостей (например, отчет Trivy).
На основе отчетов сканирования определяет, какие пакеты в образе нуждаются в обновлении.
Загружает и устанавливает обновлённые пакеты через системные менеджеры пакетов, формируя дополнительный слой патча.
Чтобы предотвратить накопление слоев, Copacetic сбрасывает предыдущий слой исправления с каждым новым исправлением.
Я буду использовать Trivy, так как Copacetic по умолчанию работает с его отчетами. Также Copacetic поддерживает расширяемость с помощью плагинов для интеграции с другими сканерами уязвимостей.
Подробнее в документации Copacetic.
Модификация Copacetic
По умолчанию Copacetic обращается к локальному демону Docker через docker.sock для сохранения пропатченного Docker-образа. Однако доступ к этому сокету считается небезопасным, так как даёт почти полный доступ к Docker-демону и всем контейнерам. Поговорим о том, как обойтись без docker.sock, используя модифицированную версию Copacetic.
Мной был изменен исходный код Copacetic — заменены участки кода, которые отвечают за сохранение образа. В результате перекомпилированный Copacetic не имеет зависимости от docker.sock и сохраняет обновленный образ в виде tar-архива.
Ниже показаны ключевые отрывки кода, которые были изменены в файлах, отвечающих за патч образов.
patch.go:
{
Type: client.ExporterDocker,
Attrs: map[string]string{
"name": patchedImageName,
},
Output: func(_ map[string]string) (io.WriteCloser, error) {
// Сохраняем результат в tar-архив
tarFile, err := os.Create(tarOutputPath)
if err != nil {
return nil, fmt.Errorf("failed to create tar file: %w", err)
}
return tarFile, nil
},
},
},
cmd.go:
return Patch(context.Background(), ua.timeout, ua.appImage, ua.reportFile, ua.patchedTag, ua.suffix, ua.workingFolder, ua.scanner, ua.format, ua.output, ua.ignoreError, bkopts, "output.tar")
Полный код patch.go и cmd.go, а также профили apparmor и seccomp, которые будут обсуждаться позднее, находятся на гит.
Передача образа с помощью Skopeo
Теперь, когда Docker-образы сохраняются в tar-архив, их нужно передавать в registry. Для этой цели воспользуемся Skopeo — утилитой для работы с контейнерными образами, которая позволяет копировать их между различными реестрами.
Применяем возможность Skopeo для передачи tar-архива в Docker Registry:
$ skopeo copy --dest-creds {login}:{password} docker-archive:output.tar docker://{registry}/{image}:{tag}
Сборка образа Copacetic на Alpine
Для запуска Copacetic в GitLab следует собрать собственный Docker-образ, включающий Trivy, Docker, Skopeo и сам Copacetic. За основу возьмем Alpine и воспользуемся многоэтапной сборкой для создания минималистичного образа. Перед сборкой образа в директорию с Dockerfile необходимо добавить модифицированные файлы cmd.go и patch.go — для перекомпиляции Copacetic.
Dockerfile
FROM golang:alpine3.21 AS builder
RUN apk add --no-cache git make
WORKDIR /build
RUN git clone https://github.com/project-copacetic/copacetic.git -b v0.10.0 && \
cd copacetic && rm ./pkg/patch/cmd.go ./pkg/patch/patch.go
COPY patch.go cmd.go ./copacetic/pkg/patch/
RUN cd copacetic && make && mv dist/linux_amd64/release/copa /copa
FROM alpine:3.21
RUN apk add --no-cache curl docker skopeo \
&& curl -sSL https://github.com/aquasecurity/trivy/releases/download/v0.57.1/trivy_0.57.1_Linux-64bit.tar.gz \
| tar -xz -C /usr/local/bin \
&& chmod +x /usr/local/bin/trivy \
&& rm -rf /var/cache/apk/* && apk update && apk upgrade
RUN addgroup -S copgroup && adduser -S copuser -G copgroup
COPY --from=builder /copa /usr/local/bin/copa
RUN rm -rf /var/cache/apk/*
WORKDIR /workspace
RUN chown -R copuser:copgroup /workspace
USER copuser
CMD ["sh"]
Этот образ будем запускать в паре с BuildKit, теперь перейдем к нему.
BuildKit в режиме rootless
Copacetic применяет BuildKit для сборки контейнеров. Мы будем использовать rootless BuildKit, который даёт возможность запускать процесс сборки без привилегий суперпользователя, повышая безопасность и ограничивая потенциальное влияние сборки на систему.
Для использования BuildKit в rootless-режиме по умолчанию нужно отключить профили seccomp и apparmor. Однако есть возможность задать свои профили для ограничения потенциально опасных и ненужных действий, что мы и сделаем.
Создание профиля apparmor
Для гибкой работы с apparmor был установлен apparmor-utils. В директории /etc/apparmor.d/ был создан пустой профиль profile, который будем применять к контейнеру BuildKit. Импортируем созданный профиль и переведем его в режим обучения:
$ sudo apparmor_parser -r -W /etc/apparmor.d/profile
$ sudo aa-complain profile
Запускаем контейнеры BuildKit и Copacetic в одной сети, чтобы выяснить, какие операции требуется разрешить. В контейнере Copacetic выполняем команды для обновления образа. На основе логов apparmor формируем финальный профиль, позволяющий BuildKit корректно работать без лишних привилегий. Необходимо разрешить операции для корректной работы BuildKit.
$ sudo aa-logprof
Переведем профиль profile в режим ограничений.
$ sudo aa-enforce profile
Профиль готов к использованию.
Создание профиля seccomp
При создании профиля seccomp был использован стандартный Docker-профиль с добавлением следующих системных вызовов в список разрешенных: arch_prctl, clone, unshare, mount, umount, umount2, keyctl, setns, pivot_root, sethostname.
Далее перейдем к запуску контейнера и применим профили.
Параметры запуска контейнера rootless BuildKit
$ docker run --rm --name buildkit --security-opt seccomp=/path/to/profile.json --security-opt apparmor=profile moby/buildkit:rootless --oci-worker-no-process-sandbox --addr tcp://0.0.0.0:1234
--security-opt seccomp=/path/to/profile.json
— использование собственного профиля seccomp.
--security-opt apparmor=profile
— использование собственного профиля apparmor.
--oci-worker-no-process-sandbox
— отключает механизм изоляции процессов в OCI-воркере BuildKit.
--addr tcp://0.0.0.0:1234
— адрес, на который будет обращаться Copacetic.
Теперь у нас есть все необходимое, чтобы обновить первый образ.
Запуск контейнеров и тестирование
Запустим контейнер BuildKit командой, приведенной выше. Перед этим необходимо создать Docker-сеть типа bridge.
Далее запустим контейнер Copacetic и провернем обновление образа.
$ docker run --rm --network buildkit --name copacetic -it copacetic:latest sh
Выполним сканирование образа debian:oldstable-20241016 и генерацию отчета debian.json с помощью Trivy.
$ trivy image --vuln-type os --ignore-unfixed -f json -o debian.json debian:oldstable-20241016
Выполним патч образа на основе сгенерированного отчета.
$ copa patch -i debian:oldstable-20241016 -r debian.json -t debian-patched -a tcp://buildkit:1234
Полученный tar-архив можно скопировать на хост и загрузить в Docker-образы.
$ docker load < output.tar
При повторном сканировании Trivy не обнаружит уязвимостей. При повторной попытке обновить уже пропатченный образ Copacetic вернёт ошибку вида "Error: no patchable vulnerabilities found". Это означает, что новый слой патча не создаётся, так как нечего обновлять.
Выполнять обновление вручную неудобно и непрактично, поэтому будем использовать Copacetic в Gitlab.
Использование Copacetic в Gitlab
Конфигурация Gitlab-runner с Docker executor
Добавим профиля apparmor и seccomp для более безопасной работы контейнера BuildKit и отключим привилегированный режим. В конфиге Gitlab-runner (config.toml) добавим параметр services_security_opt, предназначенный для применения профилей сервисов — основные контейнеры будут использовать стандартные профили. Отмечу, что Gitlab-runner не поддерживает импорт профиля seccomp в формате json, что, на мой взгляд, является проблемой. Спустя время экспериментов и поисков мною была обнаружена issue на github, где пользователь поделился способом импорта профиля seccomp в конфиг Gitlab-runner. Необходимо скопировать содержимое профиля seccomp — profile.json, заменить все переносы строк на пробелы и поместить в конфигурацию раннера. В поле apparmor указывается уже импортированный профиль.
config.toml
[runners.docker]
privileged = false
services_security_opt = ['apparmor:buildkit', 'seccomp:{ "defaultAction": "SCMP_ACT_ERRNO", "defaultErrnoRet": 1, "archMap": [ { "architecture": "SCMP_ARCH_X86_64", "subArchitectures": [ "SCMP_ARCH_X86", "SCMP_ARCH_X32" ] }, { "architecture":
...
"perf_event_open" ], "action": "SCMP_ACT_ALLOW", "includes": { "caps": [ "CAP_PERFMON" ] } } ] }']
Раннер сконфигурирован – можно приступать к написанию пайплайнов.
Первая джоба
Минимальный пайплайн с сохранением tar-архива в артефакты и отправкой образа в реестр.
patch_image:
stage: patch
image: $REGISTRY/copacetic:latest
services:
- name: moby/buildkit:rootless
alias: buildkit
command:
- "--oci-worker-no-process-sandbox"
- "--addr"
- "tcp://0.0.0.0:1234"
script:
- trivy image --vuln-type os --ignore-unfixed -f json -o report.json $IMAGE:$TAG
- copa patch -i $IMAGE:$TAG -r report.json -t patched -a tcp://buildkit:1234
- skopeo copy --dest-creds $LOGIN:$PASSWORD docker-archive:output.tar docker://$REGISTRY/$IMAGE:patched
artifacts:
paths:
- output.tar
Если образ находится в приватном реестре, то можно воспользоваться trivy login или skopeo login.
Обновляем и тестируем образ на примере semgrep
Напишем пайплайн, который будет обновлять образ semgrep. Затем удостоверимся, что его работоспособность сохранилась. В случае успеха повторно просканируем образ и перезальем его в реестр с исходным тегом.
Скрытый текст
stages:
- patch
- test
- scan
- registry
variables:
IMAGE: semgrep
TAG: rules-1.74.0
patch_image:
stage: patch
image: $REGISTRY/copacetic:test-new-patch
services:
- name: moby/buildkit:rootless
alias: buildkitd
command:
- "--oci-worker-no-process-sandbox"
- "--addr"
- "tcp://0.0.0.0:1234"
script:
- trivy image --vuln-type os --ignore-unfixed -f json -o report.json $REGISTRY/$IMAGE:$TAG
# вывод результатов сканирования в гитлаб
- trivy image --vuln-type os --ignore-unfixed $REGISTRY/$IMAGE:$TAG
- copa patch -i $REGISTRY/$IMAGE:$TAG -r report.json -t patched -a tcp://buildkitd:1234
- skopeo copy --dest-creds $LOGIN:$PASSWORD docker-archive:output.tar docker://$REGISTRY/$IMAGE:patched
test_semgrep:
stage: test
image: $REGISTRY/$IMAGE:patched
script:
- semgrep --config=auto $CI_PROJECT_DIR # сканируем код репозитория
allow_failure: false
dependencies:
- patch_image
scan_image:
stage: scan
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy image --vuln-type os --ignore-unfixed $REGISTRY/$IMAGE:patched
dependencies:
- test_semgrep
rules:
- when: on_success
push_patched_image:
stage: registry
image:
name: quay.io/skopeo/stable
entrypoint: [""]
script:
- skopeo copy --dest-creds $LOGIN:$PASSWORD docker://$REGISTRY/$IMAGE:patched docker://$REGISTRY/$IMAGE:$TAG
dependencies:
- test_semgrep
rules:
- when: on_success
Результат сканирования исходного образа:
Total: 41 (UNKNOWN: 0, LOW: 4, MEDIUM: 25, HIGH: 10, CRITICAL: 2)
Исходя из результатов сканирования semgrep, можно сделать вывод, что его работоспособность сохранилась:
Ran 320 rules on 3 files: 2 findings.
Результат сканирования пропатченного образа:
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
Далее пропатченный образ сохранился в реестре с исходным тегом.
Подобный пайплайн можно интегрировать в основной, где после обновления и проверки будет использоваться данный образ, либо настроить пайплайн по расписанию.
Подведем итоги
В этой статье я продемонстрировал, как можно безопасно использовать Copacetic для патчинга контейнерных образов без предоставления доступа к Docker-сокету. Также применил собственные профили seccomp и apparmor для безопасной работы BuildKit в режиме rootless.
Интеграция Copacetic в GitLab CI/CD пайплайны с другими инструментами позволяет автоматизировать процесс обновления образов, что значительно упрощает управление контейнерной инфраструктурой. Благодаря механизму перезаписи слоев Copacetic экономит занимаемое образом пространство. Кроме того, утилита обновляет лишь необходимые зависимости, что снижает риск нарушения работоспособности приложения и дополнительно уменьшает общий объём образа.
Автор: Владислав Коврижных, младший инженер по инфраструктурной безопасности