Skip to Content
Container Security[001] Construindo imagens de container seguras

Construindo imagens de container seguras

Disclaimer: Neste tutorial usamos ferramentas open-source para maximizar a aplicabilidade e permitir que você replique o conteúdo sem custos. Existem soluções pagas e, em muitos cenários, elas oferecem recursos adicionais e maior conveniência, mas os princípios e controles apresentados aqui continuam válidos.

Introdução

Uma imagem de container é um artefato de produção. Se ela carrega dependências desnecessárias, vulnerabilidades conhecidas, segredos vazados ou roda com permissões excessivas, você está promovendo risco direto para o runtime, e isso costuma acontecer de forma silenciosa, via pipeline.

Neste tutorial, vamos construir uma base prática para reduzir esse risco aplicando controles de segurança desde o build até a publicação no registry. A jornada segue uma ordem proposital: primeiro garantimos a fundação, depois melhoramos o processo de build, em seguida aplicamos gates de segurança e, por fim, fechamos com assinatura e um pipeline completo.

Capítulos (roteiro)

  1. Imagens base confiáveis: como definir e padronizar a fundação das suas imagens.
  2. Multi-stage build: como separar build e runtime para reduzir tamanho e superfície de ataque.
  3. Vulnerabilidades HIGH/CRITICAL: como bloquear a publicação de imagens com CVEs relevantes.
  4. Segredos: como evitar chaves e credenciais em arquivos e variáveis de ambiente dentro da imagem.
  5. Usuário root: como garantir execução com menor privilégio no container.
  6. Assinatura: como assinar imagens e aumentar rastreabilidade e confiança no supply chain.
  7. Pipeline seguro: como juntar tudo em um workflow prático de CI/CD (build → gates → push → sign).

Conceito rápido: Registry

Registry é o serviço onde imagens de container são armazenadas e distribuídas. Em geral, registries seguem o padrão Docker Registry V2 e podem ser:

  • Públicos (ex.: Docker Hub);
  • Privados (com autenticação e controle de acesso);
  • Internos (restritos à rede da empresa).

Importante: registries públicos também hospedam imagens não oficiais. Sempre valide a origem e a procedência das imagens usadas no build.

Fluxo sugerido do CI/CD (visão geral)

Caso de uso

Para demonstrar os controles de segurança, vamos usar uma API simples em Node.js com Fastify que atua como proxy para o serviço de previsão do tempo Tomorrow.io.

A aplicação recebe requisições HTTP com coordenadas geográficas (latitude e longitude) via query parameters e retorna dados como temperatura mínima/máxima e probabilidade de precipitação.

Exemplo de chamada (GET)

curl -s "http://localhost:3000/weather?lat=42.3478&lon=-71.0466"

Nosso ambiente de referência estará no GitHub: vamos hospedar o código da aplicação, os Dockerfiles (incluindo imagens base), os workflows do GitHub Actions e as imagens publicadas no registry.

1. Imagens de container devem ser construídas a partir de imagens base confiáveis

Vetor de ataque: Supply Chain Attack
Estágio de correção: Build

Para construir imagens de aplicação mais seguras, o primeiro passo é padronizar o uso de imagens base confiáveis. A imagem base funciona como a fundação do container: é a partir dela que adicionamos dependências e o artefato final do aplicativo para gerar a imagem que será executada em produção.

Referenciando uma imagem base no Dockerfile

Usar uma imagem base é simples: basta referenciá-la no Dockerfile com a instrução FROM, seguindo o padrão <registry>/<repositório>/<imagem>:<tag>.

# Padrão de referência: # FROM <registry>/<repositório>/<imagem>:<tag> FROM node:20-alpine ...

Fontes confiáveis para imagens base

Antes de disponibilizarmos imagens base para os times de desenvolvimento, precisamos responder a uma pergunta central: quais imagens são confiáveis o suficiente para entrarem no nosso processo de build? Para os times de desenvolvimento, nós (plataforma/segurança) precisamos ser a origem confiável. Para nós, o desafio é garantir que as imagens que usamos como ponto de partida tenham procedência clara, manutenção consistente e risco reduzido de supply chain. Na prática, isso é mais um tema de processo e governança do que apenas ferramenta.

Algumas opções comuns:

1) Usar imagens oficiais

Projetos como Ubuntu, Alpine Linux e Node.js mantêm imagens amplamente adotadas e reconhecidas como oficiais, o que reduz consideravelmente o risco em comparação com imagens aleatórias de terceiros. Ainda assim, é importante entender a limitação: essas imagens são mantidas por terceiros e podem não estar alinhadas ao mesmo nível de exigência de segurança da organização. Um ataque de supply chain continua sendo possível, apenas tende a ser menos provável do que em fontes não oficiais.

Aqui, ‘oficiais’ significa imagens mantidas por projetos reconhecidos e/ou pelo programa Docker Official Images.

Exemplos estão no registry público do Docker Hub:

Docker Hub, imagens oficiais

2) Usar provedores de imagens “hardened” (pagos)

Alternativas pagas como RapidFort e Chainguard entregam imagens com foco explícito em segurança, geralmente com hardening e redução agressiva de CVEs. Para organizações que querem maximizar previsibilidade e confiança, esse modelo costuma ser mais consistente, já que a segurança é parte central do produto e do processo de manutenção.

Chainguard, site e catálogo de imagens


Construção e distribuição das imagens base

Depois de definir a origem confiável, o próximo passo é transformar essa decisão em algo prático: construir e publicar imagens base internas, para que os times de desenvolvimento consumam sempre a mesma fundação, já alinhada aos requisitos da empresa.

No nosso caso de uso, precisamos de imagens de Node confiáveis para dois propósitos distintos:

  • build: com toolchain para compilar dependências nativas; e
  • runtime: mínima, com apenas o necessário para executar a aplicação de forma segura.

Vamos definir essas imagens com os arquivos:

  • 001-containers-cicd/code/base-images/node/Dockerfile.20-alpine-build
  • 001-containers-cicd/code/base-images/node/Dockerfile.20-alpine-runtime

Base image de build

# Base image (build): Node.js 20 no Alpine com toolchain para dependências nativas. FROM node:20-alpine LABEL org.opencontainers.image.title="node20-alpine-build" \ org.opencontainers.image.description="Build base image (Node.js 20 + Alpine) with toolchain for native dependencies" \ org.opencontainers.image.source="internal" \ org.opencontainers.image.vendor="Black Ravine" \ org.opencontainers.image.authors="Black Ravine Security <support-internal.cloudsec@blackravine.com>" \ com.blackravine.maintainer.team="security" \ com.blackravine.support.email="support-internal.cloudsec@blackravine.com" \ com.blackravine.repo.github="blrvio/base-images" # Toolchain para compilar dependências nativas (ex.: node-gyp) RUN apk add --no-cache \ python3 \ make \ g++ \ git \ ca-certificates \ && update-ca-certificates # Security hardening: atualiza npm para versão específica (resolve CVEs em deps bundled) ARG NPM_VERSION=11.6.4 RUN set -eux; \ node -v; \ node -e "const [maj,min]=process.versions.node.split('.').map(Number); if(maj<20 || (maj===20 && min<17)) { console.error('Node too old for npm v11:', process.versions.node); process.exit(1) }"; \ npm i -g "npm@${NPM_VERSION}" --no-fund --no-audit; \ npm -v; \ npm cache clean --force # Corepack (yarn/pnpm), se necessário RUN corepack enable # Menos ruído em CI (opcional) ENV NPM_CONFIG_UPDATE_NOTIFIER=false \ NPM_CONFIG_FUND=false \ NPM_CONFIG_AUDIT=false # Diretório de trabalho padrão WORKDIR /src # Build como usuário não-root RUN chown -R node:node /src USER node

Base image de runtime

# Base image (runtime): Node.js 20 no Alpine, com tini e certificados (execução em não-root). FROM node:20-alpine LABEL org.opencontainers.image.title="node20-alpine-runtime" \ org.opencontainers.image.description="Runtime base image (Node.js 20 + Alpine) with tini, CA certs and tzdata" \ org.opencontainers.image.source="internal" \ org.opencontainers.image.vendor="Black Ravine" \ org.opencontainers.image.authors="Black Ravine Security <support-internal.cloudsec@blackravine.com>" \ com.blackravine.maintainer.team="security" \ com.blackravine.support.email="support-internal.cloudsec@blackravine.com" \ com.blackravine.repo.github="blrvio/base-images" # Pacotes mínimos para runtime estável RUN apk add --no-cache \ ca-certificates \ tini \ tzdata \ && update-ca-certificates # Corepack (yarn/pnpm), se necessário RUN corepack enable ENV NODE_ENV=production \ TZ=UTC # Diretório de trabalho padrão WORKDIR /app # Garante permissões para o usuário não-root RUN mkdir -p /app \ && chown -R node:node /app # Runtime como usuário não-root USER node # Tini como PID 1 (shutdown limpo, sem zumbis) ENTRYPOINT ["/sbin/tini", "--"]

Essas imagens incluem apenas o necessário para cumprir sua função (build e runtime) e adicionam labels para facilitar rastreabilidade e identificação da origem. Como elas não carregam código de aplicação, podem ser reutilizadas como base padronizada para múltiplos projetos.

Build das imagens base

Com os Dockerfiles definidos, podemos construir as imagens base localmente (ou via pipeline) e, em seguida, publicá-las no registry interno:

docker build -f Dockerfile.20-alpine-build -t blrvio/base-images/node:20-build . docker build -f Dockerfile.20-alpine-runtime -t blrvio/base-images/node:20-runtime .

Conclusão

Ao padronizar imagens base confiáveis e distribuí-las internamente, você cria uma fundação única para todos os builds da organização: com procedência definida, manutenção previsível e requisitos de segurança já embutidos. Isso reduz o risco de supply chain, diminui variações entre projetos e tira do time de desenvolvimento a responsabilidade de “escolher a imagem certa” a cada novo serviço.

Mas padronizar a fundação é apenas o primeiro passo. Mesmo com uma base segura, ainda é comum que as imagens finais carreguem ferramentas, dependências e artefatos de build que não deveriam existir em produção, aumentando superfície de ataque e dificultando auditoria.

No próximo capítulo, vamos dar o passo natural dessa história: como construir imagens de aplicação usando multi-stage build, separando claramente o que é build do que é runtime, e garantindo imagens finais menores, mais previsíveis e mais seguras.

2. Imagens de container devem ser construídas com multi-stage build

Quando escrevemos uma aplicação, precisamos “empacotá-la” para que possa ser executada de forma consistente em qualquer ambiente (servidor, container, serverless, etc.). No mundo de containers, isso normalmente significa criar uma imagem que contenha o runtime e o artefato final da aplicação (ex.: dist/), pronta para subir em produção.

O problema é que, por muito tempo, foi comum usar um single-stage (estágio único), onde tudo vai parar na imagem final: código-fonte, ferramentas de build e dependências de desenvolvimento. Isso tende a gerar imagens maiores, mais lentas para subir e com maior superfície de ataque (mais pacotes instalados = mais CVEs possíveis).

Dockerfile single-stage (estágio único) (exemplo não recomendado)

Exemplo clássico onde a imagem final acaba carregando devDependencies e código-fonte TypeScript desnecessários para o runtime.

FROM node:20-alpine WORKDIR /app # ❌ Anti-padrão: copia o repositório inteiro, quebra cache e aumenta o risco de incluir arquivos sensíveis (se não houver .dockerignore) # - .env e configs locais acabam dentro da imagem # - tests, docs e arquivos irrelevantes vão junto COPY . . # ❌ Anti-padrão: usa npm install (não determinístico) e garante devDependencies # - sem lockfile = versões podem variar # - --include=dev força deps de desenvolvimento no runtime RUN npm install --include=dev # Compila o TypeScript RUN npm run build # ❌ Anti-padrão: NÃO remove devDependencies nem tooling após o build # Resultado: imagem final fica com: # - node_modules gigante (inclui TypeScript, tsx, @types/*, etc.) # - src/ TypeScript + configs + testes # - ferramentas (git, python, make, g++) que não deveriam existir em produção # ❌ Anti-padrão: define NODE_ENV depois (não influencia a instalação) ENV NODE_ENV=production \ HOST=0.0.0.0 \ PORT=3000 # ❌ Anti-padrão: roda como root (pior cenário em caso de exploração) # (sem USER node) EXPOSE 3000 CMD ["node", "dist/server.js"]

Principais impactos desse modelo:

  • Imagem maior (mais lenta para buildar, puxar e subir).
  • Mais custo (armazenamento, tráfego, tempo de pipeline).
  • Mais superfície de ataque (dependências e ferramentas de build que não precisam existir em produção).
  • Menos eficiência de cache (mudanças no código podem invalidar camadas desnecessariamente).

O que é multi-stage build?

O multi-stage build é uma técnica para separar o processo em dois estágios:

  1. Build: instala dependências (incluindo dev) e compila o app.
  2. Runtime: recebe apenas o que é essencial para executar (sem ferramentas de build, sem src/, sem devDependencies).

Isso mantém a imagem final menor e mais segura.

Stage 1, build (instala tudo e compila)

Este estágio contém ferramentas e dependências de desenvolvimento (necessárias apenas para compilar). Ao final, removemos devDependencies.

# Base: Node.js 20 em Alpine Linux (imagem leve, ~40MB) FROM blrvio/base-images/node:20-build AS build WORKDIR /app # Copia apenas manifestos primeiro para aproveitar cache COPY package.json ./ COPY package-lock.json ./ # Instala dependências (inclui devDependencies para compilar) # (Com package-lock.json, use npm ci para builds reprodutíveis. RUN npm ci # Agora copia o código (mudou o código, não precisa reinstalar deps) COPY tsconfig.json ./ COPY src ./src # Compila o TypeScript: transforma src/*.ts em dist/*.js RUN npm run build # Remove devDependencies após o build. Isso garante que o runtime receba apenas deps de produção, reduzindo CVEs e tamanho. RUN npm prune --omit=dev

Stage 2, runtime (leva só o necessário para produção)

Imagem final “limpa”: sem toolchain, sem TypeScript, sem src/ e apenas com dependências de produção.

FROM blrvio/base-images/node:20-runtime AS runtime WORKDIR /app # Variáveis de ambiente padrão ENV NODE_ENV=production # HOST=0.0.0.0 permite que o servidor aceite conexões de fora do container ENV HOST=0.0.0.0 ENV PORT=3000 # Copia apenas o necessário do stage de build: # - package.json: útil para metadados e algumas libs (opcional, mas comum) # - node_modules: apenas dependências de produção (sem TypeScript, etc) # - dist/: código JavaScript compilado (não precisamos mais do src/) COPY --from=build --chown=node:node /app/package.json ./package.json COPY --from=build --chown=node:node /app/node_modules ./node_modules COPY --from=build --chown=node:node /app/dist ./dist # Executa como usuário não-root (menor privilégio possível) USER node EXPOSE 3000 # Roda o servidor Fastify compilado CMD ["node", "dist/server.js"]

Benefícios práticos

Essa abordagem, apesar de simples, costuma gerar ganhos bem diretos:

  • Imagens menores (mais rápidas para distribuir e iniciar).
  • Menos superfície de ataque (menos pacotes = menos risco).
  • Builds mais previsíveis (especialmente usando npm ci + lockfile).
  • Pipelines mais eficientes (melhor uso de cache e menos tráfego).

Veja no print comparando os tamanhos:

Comparação de tamanho: single-stage vs multi-stage

Conclusão

Multi-stage build não “remove CVEs por mágica”, mas reduz a superfície (menos pacotes) e torna o runtime mais previsível, o que facilita aplicar políticas de scan e bloquear vulnerabilidades altas e críticas.

Ao criar Dockerfiles para aplicações (como Fastify + TypeScript), use multi-stage build por padrão. É um esforço pequeno com impacto grande em segurança, custo e eficiência, e também o primeiro passo para reduzir a presença de dependências desnecessárias que viram vulnerabilidades no runtime. No próximo capítulo, vamos definir como validar isso na prática: imagens não podem ser publicadas se tiverem vulnerabilidades altas e críticas.

3. Imagens de container não podem conter vulnerabilidades altas e críticas

Vetor de ataque: Vulnerable Components / Supply Chain
Estágio de correção: Build (CI/CD)

Agora que temos um Dockerfile definido e padronizado (imagens base + multi-stage build), o próximo passo é validar a segurança da imagem final antes de publicá-la. Aqui, o foco é simples: bloquear a entrega de imagens com vulnerabilidades de severidade alta (HIGH) e crítica (CRITICAL).

Scan de vulnerabilidades (CVE)

Precisamos verificar se a imagem de container que estamos construindo possui CVEs em:

  • pacotes do sistema operacional (OS packages); e
  • dependências da aplicação (libs).

Mas antes de rodar qualquer ferramenta, precisamos definir nosso nível de tolerância ao risco. Em geral, scanners permitem configurar:

  • Tipo de finding: OS, libs, SBOM (entre outros);
  • Níveis de criticidade: Informational, Low, Medium, High, Critical;
  • Ignorar vulnerabilidades sem correção disponível (unfixed): true/false.

Para o nosso cenário, vamos adotar tolerância moderada ao risco:

  • ignorar vulnerabilidades sem fix (para evitar bloqueio por itens sem ação imediata); e
  • bloquear HIGH e CRITICAL (quality gate obrigatório no pipeline).

Principais ferramentas

Ferramentas pagas (plataformas completas):

  • Prisma Cloud (Palo Alto): ampla cobertura para containers e Kubernetes, com políticas e governança em escala.
  • Aqua Security Platform (Aqua): plataforma para segurança de container/Kubernetes; o ecossistema Aqua também mantém ferramentas open source.

Ferramentas gratuitas / open source (muito usadas em CI/CD):

  • Grype (Anchore): scanner focado em CVEs para imagem/filesystem/SBOM, com bom suporte a “gating” por severidade (--fail-on).
  • Trivy (Aqua): scanner “canivete suíço”. Aqui, vamos usá-lo com foco em vulnerabilidades.

Comparando resultados com Grype (exemplo)

Abaixo, um scan de duas imagens Node:

  1. uma imagem vinda diretamente do registry público; e
  2. uma imagem construída a partir das nossas imagens base.

O objetivo aqui é mostrar que padronizar base images e reduzir o runtime (capítulos 1 e 2) tende a melhorar o perfil de risco e diminuir o volume de CVEs no resultado final.

Resultado do scan do Grype mostrando duas imagens Node: a primeira vinda diretamente do registry oficial e a segunda construída a partir da imagem base, demonstrando que não há vulnerabilidades detectadas

Observação: “zero CVEs” não é uma garantia permanente, o resultado depende do momento do scan e do banco de vulnerabilidades. O ponto é que reduzir superfície e padronizar base images melhora bastante a consistência.

Implementando o scan com Trivy (gating no CI/CD)

No nosso caso, vamos usar o Trivy para bloquear a publicação de imagens com severidade HIGH ou CRITICAL.

# A imagem precisa estar buildada e acessível ao Trivy # --scanners vuln: foca apenas em vulnerabilidades (CVE) # --exit-code 1: falha o comando se encontrar findings no filtro trivy image app:simple \ --scanners vuln \ --ignore-unfixed \ --severity HIGH,CRITICAL \ --exit-code 1

Como resultado, recebemos um relatório com as vulnerabilidades encontradas. Se houver qualquer finding HIGH/CRITICAL, o comando falha, o que permite quebrar o pipeline e impedir a publicação da imagem.

Relatório do scan do Trivy mostrando vulnerabilidades detectadas na imagem de container

Dica: aplique esse gate desde o início. Se você adiciona o scan depois, o backlog de CVEs cresce rápido e vira débito técnico difícil de pagar.

Conclusão

Com um scanner de vulnerabilidades rodando no CI/CD, você transforma um requisito de segurança em uma regra objetiva: imagens com CVEs HIGH/CRITICAL não passam. Isso reduz o risco de exploração e dá previsibilidade para o processo de entrega.

No próximo capítulo, vamos tratar de outro problema comum no build de containers: secrets e arquivos sensíveis, como detectar vazamentos, evitar falso positivo e definir políticas sem gerar ruído desnecessário.

4. Imagens de container devem evitar armazenar chaves e segredos (em arquivos ou variáveis de ambiente)

Vetor de ataque: Credential/Secret Leakage
Estágio de correção: Build (CI/CD)

É comum que equipes de desenvolvimento acabem incluindo segredos em imagens de container, às vezes por conveniência (testes rápidos), às vezes por acidente (um COPY . . mal colocado). O problema é simples: se a imagem vazar (registry, cache, logs, backup, máquina comprometida), o atacante pode extrair credenciais e escalar acesso para bancos, filas, cloud e serviços externos.

Dica: mantenha um .dockerignore bem definido para impedir que arquivos desnecessários ou sensíveis (como .env, credentials.txt, chaves e dumps) sejam enviados no contexto de build e acabem copiados para a imagem por engano.

A regra aqui é direta: segredos não pertencem dentro da imagem, nem como arquivo, nem como ENV.

Preparando um caso de teste (Dockerfile.fail)

Para validar o pipeline e garantir que nossos scanners estão funcionando, vamos usar um Dockerfile propositalmente inseguro:

  • 001-containers-cicd/code/Dockerfile.fail

Esse Dockerfile existe apenas para testes e contém exemplos do que não deve acontecer:

  • arquivos com credenciais (ex.: config.json, secrets.env, credentials.txt);
  • variáveis de ambiente suspeitas (GITHUB_TOKEN, DB_PASSWORD, AWS_SECRET_ACCESS_KEY, STRIPE_API_KEY, JWT_SECRET, etc.).

O objetivo do Dockerfile.fail é virar um “teste de fumaça” do seu CI/CD: se ele passar, sua detecção de secrets está falhando.

Scan de secrets com Trivy (foco em arquivos)

O Trivy consegue identificar secrets no filesystem da imagem (arquivos copiados para dentro do container). Para rodar o scan, primeiro garanta que a imagem foi buildada:

docker build -f 001-containers-cicd/code/Dockerfile.fail -t app:fail 001-containers-cicd/code

Depois execute o Trivy focando em secrets:

trivy image app:fail --scanners secret --exit-code 1

O resultado abaixo mostra exemplos de secrets detectados em arquivos dentro da imagem (ex.: config.json, secrets.env e credentials.txt):

Resultado do scan do Trivy detectando secrets em arquivos dentro da imagem

Repare que o Trivy consegue apontar o arquivo, o tipo de secret e até a origem (por exemplo, quando o arquivo entrou por um COPY no Dockerfile).

Scan de secrets em variáveis de ambiente com Dockle

Nem todo scanner tem boa cobertura para secrets em variáveis de ambiente definidas no Dockerfile. Para complementar esse cenário, usamos o Dockle, que aplica checks baseados em CIS e também sinaliza chaves suspeitas em ENV.

Execute o Dockle:

dockle app:fail

O resultado abaixo exemplifica como ele identifica variáveis de ambiente com nomes típicos de credenciais (por exemplo: GITHUB_TOKEN, DB_PASSWORD, DB_CONNECTION_STRING, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, STRIPE_API_KEY, JWT_SECRET, MONGODB_URI, etc.):

Resultado do scan do Dockle detectando possíveis credenciais em variáveis de ambiente

Observação: o Dockle permite suprimir chaves específicas com --accept-key quando houver falso positivo, mas essa exceção deve ser tratada como política (com justificativa), não como “jeitinho”.

Por que combinar Trivy + Dockle?

Na prática, eles se complementam bem:

  • Trivy: forte para detectar secrets em arquivos dentro da imagem.
  • Dockle: útil para detectar padrões perigosos em ENV e outras más práticas comuns.

O objetivo não é rodar “mais ferramentas por rodar”, e sim cobrir os dois vetores mais comuns de vazamento em imagens: filesystem e environment.

Conclusão

Remover secrets de imagens de container deixa seu ambiente mais seguro e reduz drasticamente o impacto de um vazamento de imagem. Fazer isso manualmente é inviável em escala, por isso, o caminho correto é transformar essa exigência em regra de pipeline.

Com os scans (Trivy + Dockle), você consegue automatizar a detecção e impedir que imagens inseguras avancem no fluxo.

5. Imagens de container não devem rodar com o usuário root

Container é uma tecnologia excelente: melhora portabilidade, consistência de execução e velocidade de entrega. Em segurança, porém, o ganho só se sustenta quando seguimos alguns princípios básicos, e um dos mais importantes é não executar a aplicação como root dentro do container.

Rodar como root costuma acontecer por padrão (ou por conveniência) e vira um risco desnecessário, principalmente quando somamos: dependências de terceiros, CVEs inevitáveis ao longo do tempo e falhas de configuração no runtime.

“Root no container” pode virar risco real no host

Quando um processo roda como root dentro do container, ele tem privilégio máximo dentro daquele namespace. Em ambientes mal configurados (ou com vulnerabilidades exploráveis), isso pode ampliar o impacto de um incidente, desde leitura de arquivos sensíveis montados como volume, até tentativas de escape (container breakout), movimentação lateral e negação de serviço.

Em outras palavras: root aumenta a superfície de ataque e o impacto potencial. E, na maioria dos casos, não há motivo para a aplicação precisar disso.

Regra prática: se sua aplicação só precisa escutar uma porta alta (ex.: 3000) e ler/escrever em /app (ou /tmp), ela provavelmente não precisa de root.

Como testar se a imagem roda como root

Para validar isso rapidamente, podemos usar a ferramenta Dockle. Ao rodar dockle app:simple, ele executa diversos checks (CIS) e inclui a verificação de usuário final.

dockle app:simple

Veja um exemplo de finding quando a imagem roda como root:

Resultado do Dockle indicando execução como root

Como implementar execução com usuário não-root

A forma mais simples é criar um usuário/grupo dedicado e garantir permissões no diretório de trabalho. Exemplo em Alpine:

# 1) Criar usuário/grupo não-root RUN addgroup -S app && adduser -S app -G app # 2) Definir diretório de trabalho e garantir permissões WORKDIR /app RUN mkdir -p /app && chown -R app:app /app # 3) (Recomendado) copiar arquivos já com ownership correto COPY --chown=app:app . . # 4) Fixar execução como não-root USER app

Após rebuildar a imagem, rode o Dockle novamente para validar que o finding desapareceu:

dockle app:simple

Resultado do Dockle após ajustar para não-root

Dica: se você já usa uma imagem base que vem com um usuário não-root (por exemplo, node), o Dockerfile pode ser ainda mais simples, basta ajustar permissões e definir USER node.

Conclusão

Trocar a execução para um usuário não-root é uma mudança pequena no Dockerfile, mas com impacto grande: reduz superfície de ataque e limita o estrago em caso de exploração. Essa é uma daquelas regras que devem virar padrão, e idealmente ser validada automaticamente no CI/CD (Dockle, OPA/Conftest ou policy-as-code). Vamos abordar isso nos próximos capítulos.

6. Imagens de container devem ser assinadas

Até aqui, construímos imagens com boas práticas: imagem base confiável, multi-stage build, gate de vulnerabilidades, detecção de secrets e execução como usuário não-root. O próximo passo para fortalecer a supply chain é garantir integridade e procedência: a imagem publicada precisa ser a mesma que o runtime vai executar.

É aí que entra a assinatura.

Por que assinar imagens?

Diagrama simples de fluxo de build de imagem com assinatura

Ao assinar uma imagem, criamos um mecanismo de confiança que permite:

  • provar a origem (quem/qual pipeline publicou);
  • evitar adulteração (detectar imagens com “troca de conteúdo” no caminho);
  • bloquear imagens não confiáveis no runtime (policy no Kubernetes).

Em outras palavras: assinatura transforma “eu espero que essa imagem seja segura” em “eu consigo validar que ela veio do lugar certo”.

Ferramentas para assinatura

Existem várias opções open source para assinatura e verificação de artefatos OCI. Duas bem conhecidas:

  • Notary / Notation: ecossistema voltado a assinatura e verificação de artefatos OCI com políticas em registry.
  • Cosign (Sigstore): ferramenta para assinar e verificar imagens OCI direto no registry, com suporte a keyless (OIDC) e transparência (Rekor).

Neste guia, vamos usar Cosign por causa do modo keyless, que simplifica a operação: em vez de gerir chaves manualmente, usamos a identidade do pipeline via OIDC.

Assinando imagens no CI/CD (GitHub Actions + Cosign keyless)

A seguir, um workflow simples que faz build, push no GHCR e assina por digest:

name: 001-containers-cicd (build, push e sign) on: push: branches: [ "main" ] paths: - "001-containers-cicd/code/Dockerfile" - ".github/workflows/001-containers-cicd.yaml" permissions: contents: read packages: write id-token: write jobs: build_push_sign: runs-on: ubuntu-latest env: REGISTRY: ghcr.io IMAGE_REPO: blrvio/001-containers-cicd IMAGE_TAG: sha-${{ github.sha }} BUILD_CONTEXT: 001-containers-cicd/code DOCKERFILE_PATH: 001-containers-cicd/code/Dockerfile steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image id: build uses: docker/build-push-action@v6 with: context: ${{ env.BUILD_CONTEXT }} file: ${{ env.DOCKERFILE_PATH }} push: true platforms: linux/arm64 tags: ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:${{ env.IMAGE_TAG }} - name: Install cosign uses: sigstore/cosign-installer@v3 # Assinatura keyless: sempre por digest (@sha256...), não por tag. - name: Sign image (keyless) run: | cosign sign --yes \ ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}@${{ steps.build.outputs.digest }}

Note como a assinatura fica simples: instalar o cosign e assinar por digest no final do pipeline.

Depois de rodar o workflow, a imagem é publicada e assinada:

print github actions workflow running and signing print github artifact container with hash

Verificando a assinatura localmente

Você pode validar a assinatura no terminal com cosign verify. Um exemplo:

IMAGE_TAG="ghcr.io/blrvio/001-containers-cicd:sha-d4dc9c8e58cd18d9c32de85509ca6e0a09866e3b" DIGEST="$(docker image inspect "$IMAGE_TAG" --format '{{index .RepoDigests 0}}' | cut -d@ -f2)" cosign verify \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ --certificate-identity "https://github.com/blrvio/cloudsecurity-content/.github/workflows/001-containers-cicd.yaml@refs/heads/main" \ "ghcr.io/blrvio/001-containers-cicd@$DIGEST"

Importante: ajuste --certificate-identity para o path do workflow do seu repositório.

A saída inclui metadados que comprovam a identidade do workflow que assinou a imagem:

[ { "critical": { "...": "..." }, "optional": { "...": "..." } } ]

Bloqueando imagens não assinadas no Kubernetes (Kyverno)

Assinar não adianta se o cluster aceitar qualquer imagem. Aqui entra o runtime gate: só executar imagens assinadas.

O Kyverno atua como admission controller: intercepta recursos antes de serem admitidos no cluster e aplica as políticas.

A policy abaixo exige assinatura keyless emitida pelo GitHub Actions:

apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: require-cosign-keyless-ghcr spec: validationFailureAction: Enforce background: false rules: - name: verify-001-containers-cicd match: any: - resources: kinds: ["Pod"] namespaces: ["poc-cosign-keyless"] verifyImages: - imageReferences: - "*" required: true mutateDigest: true attestors: - entries: - keyless: issuer: "https://token.actions.githubusercontent.com" subject: "https://github.com/blrvio/cloudsecurity-content/.github/workflows/001-containers-cicd.yaml@refs/heads/main" rekor: url: "https://rekor.sigstore.dev" additionalExtensions: githubWorkflowRepository: "blrvio/cloudsecurity-content" githubWorkflowRef: "refs/heads/main" githubWorkflowTrigger: "push" githubWorkflowName: "001-containers-cicd (build, push e sign)"

Agora, ao tentar subir uma imagem sem assinatura, o Kyverno bloqueia:

kubectl -n poc-cosign-keyless run poc-nginx \ --image=nginx:alpine \ --restart=Never \ --labels="team=security"

Kyverno bloqueando Pod com imagem não assinada

E ao subir uma imagem assinada conforme a policy, ele permite:

Kyverno permitindo Pod com imagem assinada

Conclusão

Assinar imagens transforma o build em uma cadeia de confiança verificável: a imagem publicada no registry passa a ter proveniência e pode ser validada automaticamente no runtime. Com um passo extra no CI/CD e uma policy simples no cluster, você reduz drasticamente o risco de supply chain e impede a execução de imagens não confiáveis.

No próximo capítulo, vamos juntar tudo: um pipeline de CI/CD que integra build, testes, scans e assinatura de ponta a ponta.

7. Montando um pipeline seguro

Agora que cobrimos os principais requisitos de segurança para imagens de container, vamos montar um pipeline de CI/CD para construir, validar e assinar a imagem antes de publicá-la.

Para isso, vamos reutilizar o workflow apresentado no capítulo 6, adicionando gates (bloqueios) de segurança que impedem a publicação de imagens inseguras no registry.

Estrutura

Nota: Imagens base e multi-stage build são decisões implementadas no Dockerfile.
O pipeline não “ativa” essas práticas diretamente, ele valida o resultado final, garantindo que a imagem construída atende aos requisitos (por exemplo: sem vulnerabilidades altas/críticas, sem segredos vazados e sem configurações perigosas).

O fluxo do pipeline é:

  1. Build local (sem push) da imagem para permitir análise.
  2. Gate de CVEs (bloqueia se houver HIGH/CRITICAL).
  3. Gate de secrets e hardening checks (Trivy + Dockle).
  4. Push apenas se todos os gates passarem.
  5. Assinatura keyless da imagem publicada (Cosign, por digest).

Análise de vulnerabilidades (CVE gate)

O primeiro gate valida se a imagem contém vulnerabilidades altas ou críticas. Se encontrar, o workflow falha e a imagem não é publicada.

#----------------------------------------------------------------------- # TESTE-CVE (VULNERABILIDADES) - Trivy # - scanners: vuln -> somente CVEs (sem secrets aqui) # - Gate: falha se encontrar HIGH/CRITICAL # - ignore-unfixed: evita bloquear por itens sem correção disponível #----------------------------------------------------------------------- - name: Trivy (CVE gate - HIGH/CRITICAL) uses: aquasecurity/trivy-action@0.24.0 with: image-ref: localbuild:${{ github.sha }} scanners: vuln severity: HIGH,CRITICAL ignore-unfixed: true exit-code: "1" format: table

Análise de secrets e de padrões inseguros (root, ENV, CIS)

Aqui usamos duas camadas:

  • Trivy secrets: detecta vazamento de credenciais dentro do filesystem da imagem (por exemplo: chaves copiadas por engano).
  • Dockle: valida práticas inseguras comuns (incluindo possíveis credenciais em ENV, e checks alinhados a recomendações de hardening/CIS).
#----------------------------------------------------------------------- # TESTE-SECRETS (VAZAMENTO DE CREDENCIAIS) - Trivy + Dockle # - Trivy: detecta secrets em arquivos dentro da imagem # - Dockle: detecta padrões perigosos, incluindo possíveis credenciais em ENV #----------------------------------------------------------------------- - name: Trivy (secrets gate) uses: aquasecurity/trivy-action@0.24.0 with: image-ref: localbuild:${{ github.sha }} scanners: secret exit-code: "1" format: table - name: Dockle (ENV + CIS checks) run: | docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ goodwithtech/dockle:v0.4.14 \ --exit-code 1 \ localbuild:${{ github.sha }}

Dica prática: secrets scanning é “última linha de defesa”. O ideal é impedir o problema antes, com .dockerignore, boas práticas de build e uso correto de secrets no CI/CD.


Workflow completo

name: 001-containers-cicd (build, push e sign) on: push: branches: [ "main" ] # Roda somente se o Dockerfile deste módulo (ou o próprio workflow) mudar. paths: - "001-containers-cicd/code/Dockerfile" - ".github/workflows/001-containers-cicd.yaml" # Permissões mínimas: # - contents:read -> checkout do repositório # - packages:write -> publicar imagem no GitHub Container Registry (GHCR) # - id-token:write -> OIDC (necessário para cosign keyless) permissions: contents: read packages: write id-token: write jobs: build_push_sign: runs-on: ubuntu-latest env: REGISTRY: ghcr.io IMAGE_REPO: blrvio/001-containers-cicd IMAGE_TAG: sha-${{ github.sha }} # Importante: NÃO usar "DOCKER_CONTEXT" (conflita com Docker CLI). BUILD_CONTEXT: 001-containers-cicd/code DOCKERFILE_PATH: 001-containers-cicd/code/Dockerfile steps: #----------------------------------------------------------------------- # BUILD #----------------------------------------------------------------------- - name: Checkout uses: actions/checkout@v4 - name: Setup QEMU (for arm64) uses: docker/setup-qemu-action@v3 - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # Build local para permitir scan antes de publicar - name: Build image (local, no push) id: build uses: docker/build-push-action@v6 with: context: ${{ env.BUILD_CONTEXT }} file: ${{ env.DOCKERFILE_PATH }} push: false load: true platforms: linux/arm64 tags: localbuild:${{ github.sha }} #----------------------------------------------------------------------- # TESTE-CVE (VULNERABILIDADES) - Trivy #----------------------------------------------------------------------- - name: Trivy (CVE gate - HIGH/CRITICAL) uses: aquasecurity/trivy-action@0.24.0 with: image-ref: localbuild:${{ github.sha }} scanners: vuln severity: HIGH,CRITICAL ignore-unfixed: true exit-code: "1" format: table #----------------------------------------------------------------------- # TESTE-SECRETS (VAZAMENTO DE CREDENCIAIS) - Trivy + Dockle #----------------------------------------------------------------------- - name: Trivy (secrets gate) uses: aquasecurity/trivy-action@0.24.0 with: image-ref: localbuild:${{ github.sha }} scanners: secret exit-code: "1" format: table - name: Dockle (ENV + CIS checks) run: | docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ goodwithtech/dockle:v0.4.14 \ --exit-code 1 \ localbuild:${{ github.sha }} #----------------------------------------------------------------------- # PUSH (PUBLICAÇÃO) - GHCR # - Só acontece se todos os gates passarem #----------------------------------------------------------------------- - name: Build and push image (after gates) id: push uses: docker/build-push-action@v6 with: context: ${{ env.BUILD_CONTEXT }} file: ${{ env.DOCKERFILE_PATH }} push: true platforms: linux/arm64 tags: ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:${{ env.IMAGE_TAG }} #----------------------------------------------------------------------- # SIGNING (ASSINATURA) - Cosign (keyless) #----------------------------------------------------------------------- - name: Install cosign uses: sigstore/cosign-installer@v3 # Assina por DIGEST (@sha256:...), não por TAG - name: Sign image (keyless) run: | cosign sign --yes \ ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}@${{ steps.push.outputs.digest }}

Conclusão

No fim, um pipeline seguro não é “mais um job no CI”. Ele é um controle de qualidade obrigatório para artefatos que vão para produção.

Com esse workflow, garantimos que:

  • a imagem só é publicada se passar por gates de segurança;
  • vulnerabilidades altas e críticas bloqueiam a entrega;
  • vazamento de credenciais e padrões inseguros (como execução como root) são detectados cedo;
  • o artefato publicado é assinado (keyless) e passa a ter rastreabilidade forte por digest.

A partir daqui, o passo natural é levar essa garantia para o cluster: exigir imagens assinadas e bloquear deploys que não atendam aos gates. Mas o fundamento está feito, e é isso que separa um build “que funciona” de um build realmente pronto para produção.

Checklist

Este checklist resume os controles do tutorial e serve como guia rápido para revisar um Dockerfile e um pipeline de CI/CD antes de publicar e executar uma imagem em produção.

Fase 1: Fundação e Preparação (Dockerfile)

1. Defina e padronize imagens base

  • Selecione uma origem confiável para suas imagens (ex.: imagens oficiais do Docker Hub ou imagens “hardened”, como Chainguard).
  • Crie e publique imagens base internas separadas para Build (com ferramentas de compilação) e Runtime (mínima, apenas para execução).

2. Implemente multi-stage build

  • Refatore o Dockerfile para usar múltiplos estágios (FROM ... AS build e FROM ... AS runtime).
  • No estágio de runtime, copie apenas os artefatos compilados/necessários (ex.: COPY --from=build /app/dist ./dist), descartando código-fonte e dependências de desenvolvimento.

3. Configure o menor privilégio (non-root)

  • Crie um usuário e grupo dedicados dentro da imagem (ex.: RUN addgroup -S app && adduser -S app -G app) caso a imagem base não forneça um.
  • Defina a instrução USER <usuario> no final do Dockerfile para garantir que a aplicação não rode como root.

4. Higiene de arquivos

  • Configure um arquivo .dockerignore para excluir arquivos sensíveis ou desnecessários (como .env, .git, credenciais locais) do contexto de build.

Fase 2: Gates de Segurança (CI/CD)

5. Gate de vulnerabilidades (CVEs)

  • Integre um scanner (como Trivy ou Grype) no pipeline de CI.
  • Configure o scanner para falhar o build (--exit-code 1) se encontrar vulnerabilidades de severidade HIGH ou CRITICAL.
  • (Opcional recomendado) Configure para ignorar vulnerabilidades que ainda não possuem correção (--ignore-unfixed) para evitar bloqueios sem solução.

6. Gate de segredos e hardening

  • Execute um scan de segredos (ex.: trivy image --scanners secret) para verificar se chaves ou credenciais foram copiadas para o sistema de arquivos da imagem.
  • Execute um scan de más práticas e variáveis de ambiente (ex.: Dockle) para detectar chaves em ENV e execução como root.

Fase 3: Cadeia de Confiança (Supply Chain)

7. Publicação e assinatura

  • Realize o push da imagem para o registry apenas se todos os gates de segurança anteriores (CVEs e Segredos) passarem com sucesso.
  • Assine a imagem publicada utilizando Cosign (preferencialmente no modo keyless com OIDC para simplificar a gestão de chaves).
  • Assine utilizando o digest da imagem (@sha256:...) em vez da tag para garantir imutabilidade.

Fase 4: Validação no Runtime (Kubernetes)

8. Admissão segura

  • Implemente um Admission Controller no cluster (como Kyverno).
  • Crie uma política que bloqueie o deploy de imagens que não possuam uma assinatura válida verificada pelo Cosign.
Last updated on