Programação Por Equipe DevBras

Capa do artigo: Segredo na imagem Docker: como achar e tirar de vez

Segredo na imagem Docker: como achar e tirar de vez

Segredo na imagem Docker testado: o token do ARG no history, o .env apagado que segue na camada, secret mount no build e /run/secrets com o dono certo.

Para ver o que uma imagem Docker carrega, montamos uma com três vícios que aparecem em projeto de verdade. Ela recebe o token do npm por ARG, define a senha do banco num ENV e faz COPY . . numa pasta com um .env, apagado logo depois com RUN rm -f .env. Os valores eram falsos, mas as marcas que ficaram são as mesmas de um projeto real. Rodando o container, o ls de /app mostra só o Dockerfile e o server.js, e o .env parece ter sumido. Não sumiu, e o token e a senha também estavam lá, legíveis para qualquer um com a imagem e sem acesso ao servidor.

Este é o roteiro que a Equipe DevBras usou para achar cada vazamento e o que fez cada um desaparecer, testado no Docker Engine 29.1.4, BuildKit 0.29.0 e Compose 5.6.1.

A imagem que vaza

FROM alpine:3.22
ARG NPM_TOKEN
ENV DATABASE_URL=postgres://loja:SenhaDeProducao123@db/loja
WORKDIR /app
COPY . .
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && wc -c .npmrc && rm .npmrc
RUN rm -f .env
CMD ["sh", "-c", "ls -la /app"]

Construída com docker build --build-arg NPM_TOKEN=npm_tokenDeTeste777, ela parece limpa por dentro. O histórico conta outra história. Este é o trecho de docker history --no-trunc que contém os segredos:

|1 NPM_TOKEN=npm_tokenDeTeste777 /bin/sh -c rm -f .env
|1 NPM_TOKEN=npm_tokenDeTeste777 /bin/sh -c echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && wc -c .npmrc && rm .npmrc
/bin/sh -c #(nop)  ENV DATABASE_URL=postgres://loja:SenhaDeProducao123@db/loja

O ARG foi parar em cada instrução RUN depois dele, inclusive numa que nem usa o token. O ENV aparece no histórico e também em docker image inspect, junto com o PATH. O rm .npmrc na mesma instrução funcionou para o arquivo, que não ficou em camada nenhuma, mas não adiantou nada para o token, que já estava no histórico. A documentação de segredos de build do Docker diz isso sem rodeio: build args e variáveis de ambiente não servem para segredo, porque persistem na imagem final.

O .env é o caso mais traiçoeiro. Extraindo as camadas com docker save, ele estava inteiro na camada do COPY . .. O RUN rm -f .env criou uma camada nova que só esconde o arquivo. Quem baixa a imagem baixa as duas.

Um script para procurar segredo em qualquer imagem

Antes de corrigir, descubra o que já vazou, porque cada segredo encontrado precisa ser trocado. O script recebe a imagem e alguns trechos dos seus segredos, como o começo do token ou um pedaço da senha, e procura nos três lugares: histórico, variáveis de ambiente e arquivos de cada camada, inclusive os apagados depois:

#!/bin/sh
# uso: ./verifica.sh imagem trecho1 [trecho2 ...]
IMG="$1"; shift
PADRAO=$(echo "$@" | tr ' ' '|')
echo "== history"
docker history --no-trunc --format '{{.CreatedBy}}' "$IMG" | grep -E "$PADRAO" || echo "(nada)"
echo "== env"
docker image inspect --format '{{json .Config.Env}}' "$IMG" | grep -E "$PADRAO" || echo "(nada)"
echo "== camadas"
TMP=$(mktemp -d)
docker save "$IMG" -o "$TMP/img.tar" && tar -xf "$TMP/img.tar" -C "$TMP"
for camada in "$TMP"/blobs/sha256/*; do
  tar -tf "$camada" >/dev/null 2>&1 || continue
  mkdir "$TMP/c" && tar -xf "$camada" -C "$TMP/c" 2>/dev/null
  grep -rlaE "$PADRAO" "$TMP/c" | sed "s|$TMP/c|camada $(basename "$camada" | cut -c1-12): |"
  rm -rf "$TMP/c"
done
rm -rf "$TMP"

Contra a imagem do teste, ele achou o token e a senha no histórico, a senha no ENV e dois arquivos com segredo na mesma camada: o .env e o próprio Dockerfile, que o COPY . . também levou e que tinha a senha escrita no ENV. Rode o script na imagem que está em produção, baixada do registry numa máquina que não tem acesso ao servidor. É a visão de quem conseguiu a imagem por fora.

O build que não grava nada

A versão corrigida tem duas peças. A primeira é um .dockerignore, para que arquivo sensível nem chegue ao build. Esta é a documentação do contexto de build:

.env
.env.*
*.pem
*.key
segredos/
.git

A segunda é o secret mount do BuildKit para o que o build realmente precisa, como o .npmrc com o token do registry de pacotes:

# syntax=docker/dockerfile:1
FROM alpine:3.22
WORKDIR /app
COPY server.js ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    wc -c /root/.npmrc && grep -c _authToken /root/.npmrc
COPY . .
CMD ["sh", "-c", "ls -la /app"]

No teste, o RUN enxergou o arquivo, que tinha 53 bytes e uma linha com _authToken. Com o Docker atual, o build fica assim:

docker build --secret id=npmrc,src=$HOME/.npmrc -t loja/api:2.3.0 .

Rodando o script de verificação na imagem nova, com os mesmos três trechos, nada apareceu no histórico, no ENV nem nas camadas. O ls de /app mostrou Dockerfile, .dockerignore e server.js, sem o .env. Num projeto Node de verdade, o RUN com o mount é onde entra o npm ci --omit=dev.

O segredo da aplicação chega quando o container sobe

A senha do banco não precisa existir no build. Ela entra quando o container sobe. Com Compose, um arquivo do servidor vira /run/secrets/database_url dentro do container, como mostra o guia de segredos no Compose:

services:
  api:
    image: loja/api:2.3.0
    environment:
      DATABASE_URL_FILE: /run/secrets/database_url
    secrets:
      - database_url
secrets:
  database_url:
    file: ./segredos/database_url.txt

Subimos dois serviços lado a lado, um com o segredo por arquivo e outro com a senha numa variável DATABASE_URL. No docker inspect, o segundo mostrou a senha inteira em Config.Env. O primeiro mostrou só o caminho DATABASE_URL_FILE=/run/secrets/database_url, e o arquivo apareceu como bind mount somente leitura. Variável de ambiente ainda é melhor que senha na imagem, mas qualquer pessoa com acesso ao Docker do servidor a lê, todo processo filho a herda, e ela costuma aparecer em relatório de erro que despeja o ambiente inteiro.

O teste também pegou uma armadilha de permissão. O arquivo do servidor tinha modo 600 e dono root, e o container o recebeu exatamente assim. Rodando como o usuário 1000, que é o node das imagens oficiais do Node, a leitura deu Permission denied. Depois de chown 1000:1000 no arquivo do servidor, mantendo o modo 600, a aplicação leu normalmente. Se a imagem tem USER node, o dono do arquivo de segredo no servidor precisa combinar com ele.

Do lado da aplicação, aceite as duas formas e falhe alto quando nenhuma existir:

const fs = require('fs');

function lerSegredo(nome) {
  const arquivo = process.env[nome + '_FILE'];
  if (arquivo) return fs.readFileSync(arquivo, 'utf8').trim();
  if (process.env[nome]) return process.env[nome];
  throw new Error('segredo ausente: ' + nome);
}

const databaseUrl = lerSegredo('DATABASE_URL');

No Node 24, a função leu o segredo pelo arquivo e pela variável. Sem nenhum dos dois, falhou com segredo ausente: DATABASE_URL. Com um caminho errado, falhou com ENOENT. É melhor o container não subir do que subir e tentar conectar sem senha.

A pasta segredos/ fica no servidor, fora do git. Quem sobe as stacks pelo Dockyard, o painel Docker da DevBras, usa o mesmo compose.yaml com a seção secrets, e o arquivo continua morando no host onde o agent roda.

Se a imagem com segredo já foi publicada

Apagar a tag do registry não resolve, porque você não sabe quem já puxou a imagem: servidores, CI, o notebook de quem testou. A ordem que funciona é:

  1. Troque o segredo na origem: token novo e senha nova no banco.
  2. Confirme que o valor antigo parou de funcionar.
  3. Publique a imagem corrigida e atualize os servidores.
  4. Só então remova as tags antigas.

O raciocínio é o mesmo de um segredo que vazou no git, e a troca sem derrubar quem usa segue a rotação em dois passos. Republicar com a mesma tag também não apaga nada, como mostra o teste de que a tag latest não é versão: a imagem antiga continua no cache de cada servidor que a baixou.

Avalie este artigo

Referência: https://docs.docker.com/build/building/secrets/