Seguridad en Docker: evita fugas de secretos con este esquema de archivos

¿Te fue útil?

La mayoría de las fugas de secretos en entornos Docker no son hackeos al estilo Hollywood. Son aburridas: un .env perdido incluido en una imagen, un artefacto de CI subido “para depuración”, o un desarrollador que ejecuta docker inspect y pega accidentalmente la salida en un ticket.

Arreglar esto no requiere un nuevo equipo de plataforma, migrar a un vault, ni un ritual de comité de doce semanas. Requiere un esquema de archivos que haga que la ruta segura sea la ruta fácil — y que haga la ruta insegura lo bastante molesta como para que la gente deje de usarla.

El esquema de archivos que lo cambia todo

Este es el esquema. No es bonito. No es ingenioso. Es el tipo de cosa que adoptas una vez y luego no quieres volver a hablar de ella, que es exactamente lo que quieres respecto a secretos.

cr0x@server:~$ tree -a -L 3 .
.
├── .dockerignore
├── .gitignore
├── Dockerfile
├── README.md
├── compose.yaml
├── scripts
│   ├── bootstrap-dev.sh
│   └── verify-no-secrets.sh
├── src
│   └── app.py
├── config
│   ├── app.example.yaml
│   └── logging.yaml
└── secrets
    ├── README.md
    ├── dev
    │   ├── app.env.example
    │   └── tls.example
    └── runtime
        ├── DO_NOT_COMMIT
        └── .keep

Qué significa cada directorio (y qué prohíbe)

  • src/: solo código de la aplicación. Nunca secretos. Si el código necesita un secreto, lo lee en tiempo de ejecución desde una ruta de archivo o desde una variable de entorno inyectada. No empaqueta el secreto.
  • config/: configuración no secreta comprometida en git. Proporciona plantillas *.example* para que los ingenieros no inventen nombres ad-hoc como prod.env.
  • secrets/: este es el truco. Creas un lugar para secretos para que la gente deje de esparcirlos por todas partes. Pero también haces imposible comprometerlos por política y herramientas:
    • secrets/dev/ contiene solo ejemplos para desarrollo local. Los archivos ejemplo enseñan la forma, no el contenido.
    • secrets/runtime/ es donde los secretos reales aterrizan en una máquina o en el espacio de trabajo de un pipeline. Nunca se compromete. Se monta en tiempo de ejecución.
  • .dockerignore: tu primera barrera rígida. Docker solo puede copiar lo que envías en el contexto de build. No envíes secretos.
  • compose.yaml: define montajes/secretos en tiempo de ejecución, no copiado en tiempo de build.
  • scripts/verify-no-secrets.sh: automatización que hace fallar builds cuando alguien intenta ser creativo.

El .dockerignore que hace el trabajo pesado

Si solo tomas una cosa de este artículo, toma esto: tu contexto de build es una superficie de ataque. El daemon de Docker (local o remoto) recibe un tar de tu contexto. Si tu .env está ahí, ya perdiste.

cr0x@server:~$ cat .dockerignore
# Never send secrets to the build context
secrets/
**/*.pem
**/*.key
**/*id_rsa*
**/*.p12
**/*.jks
**/.env
**/.env.*
**/*credentials*
**/*token*
**/*secret*
**/kubeconfig
**/.npmrc
**/.pypirc
**/.netrc

# Common junk that bloats builds and leaks internals
.git/
.gitignore
Dockerfile*
compose*.yml
compose*.yaml
node_modules/
__pycache__/
*.log

Sí, aún puedes filtrar un secreto con un nombre de archivo que no coincida con esos patrones. Por eso el esquema también incluye escaneo y barreras. Defensa en profundidad es solo una manera elegante de decir “supón que alguien eventualmente hará lo incorrecto”.

Patrón Dockerfile: construir sin secretos, ejecutar con secretos

Tu Dockerfile no debe aceptar secretos como ARG. No debe copiar .env. No debe RUN echo "$TOKEN". Si necesitas secretos en tiempo de build (registros privados de dependencias), usa montajes de secretos de BuildKit para que el secreto nunca se convierta en una capa.

cr0x@server:~$ sed -n '1,160p' Dockerfile
# syntax=docker/dockerfile:1.6
FROM python:3.12-slim AS runtime

WORKDIR /app

# Install deps first to leverage caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy only the application
COPY src/ ./src/
COPY config/ ./config/

# Run as non-root
RUN useradd -r -u 10001 appuser && chown -R appuser:appuser /app
USER appuser

# Secrets are loaded at runtime from /run/secrets or mounted files
ENV APP_CONFIG=/app/config/logging.yaml
CMD ["python", "-m", "src.app"]

Patrón Compose: inyección en tiempo de ejecución, no horneado en build

Compose es donde la gente se vuelve descuidada porque “es solo dev”. Y luego dev se convierte en staging, staging en prod, y prod en una revisión de incidente.

cr0x@server:~$ sed -n '1,200p' compose.yaml
services:
  app:
    build:
      context: .
    image: acme/app:dev
    environment:
      # Non-secret values only
      - LOG_LEVEL=info
    volumes:
      # Runtime config is fine if it is not secret
      - ./config:/app/config:ro
      # Real secrets: mounted from secrets/runtime (not in git)
      - ./secrets/runtime:/run/secrets:ro
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    ports:
      - "8080:8080"

Decisión: si tu equipo depende de archivos .env, consérvalos — pero almacénalos bajo secrets/runtime y móntalos en solo lectura. La idea es evitar que entren en el historial de git o en capas de Docker.

Broma 1: Si pones secretos en variables de entorno, eventualmente acabarán en una captura de pantalla. Los humanos hacen capturas de pantalla como si fuera una estrategia de respaldo.

Por qué se filtran secretos en Docker (modos de fallo reproducibles)

Las fugas en Docker ocurren porque Docker es bueno moviendo bytes y malo entendiendo la intención. Encogerá tu contexto de build en un tar, almacenará en caché capas para siempre y registrará metadatos que no imaginaste. Mientras tanto, los ingenieros optimizan para “funciona ahora” porque estar de guardia es una experiencia formadora de carácter que nadie pidió.

Ruta de fuga #1: el contexto de build incluye secretos

docker build envía el directorio completo de contexto al daemon. Si el daemon es remoto (común en CI o al usar un builder compartido), acabas de enviar secretos por la red. Incluso si el Dockerfile nunca los copia, la transmisión del contexto en sí puede ser registrada, cacheada o inspeccionada de maneras no deseadas.

Ruta de fuga #2: las capas de Dockerfile preservan historial

Cuando haces RUN export TOKEN=... && some-command o RUN echo "$TOKEN" > /tmp/token, creas una capa. Incluso si eliminas el archivo después, la capa puede preservarlo. El historial de la imagen también puede exponer args de build y cadenas de comandos.

Ruta de fuga #3: las variables de entorno son descubribles

Las variables de entorno aparecen en docker inspect. Pueden aparecer en volcados de fallos, bundles de soporte, listas de procesos y logs. También tienden a copiarse en etiquetas de monitoreo (“solo por conveniencia”), y así terminas con una clave de API en un backend de métricas.

Ruta de fuga #4: montajes y permisos desajustados

Montar un archivo secreto está bien. Montarlo escribible y ejecutar como root es como terminas con secretos mutados, commits accidentales y sesiones de depuración a las 2 a.m. preguntando “¿por qué el contenedor reescribió mi config?”.

Ruta de fuga #5: artefactos de CI y logs de depuración

Los sistemas de CI adoran los artefactos. A la gente le encantan los artefactos cuando depuran. Si tu pipeline sube docker inspect, la salida de env o el tarball completo del workspace, los secretos escaparán. No porque alguien sea malintencionado. Porque alguien está cansado.

Algunos datos y contexto histórico (porque nuestros errores son antiguos)

  • Hecho 1: El modelo de capas de imágenes de Docker se basa en sistemas de archivos en unión; eliminar un archivo en una capa posterior no lo quita de capas anteriores. Por eso “eliminé el secreto después” no es una solución.
  • Hecho 2: Las variables de entorno han sido un mecanismo estándar de configuración desde Unix antiguo. Son convenientes — y históricamente terribles para la confidencialidad.
  • Hecho 3: El proceso original de build de Docker enviaba todo el contexto de build como un stream tar al daemon. Ese comportamiento moldeó años de exposiciones accidentales en builders de CI.
  • Hecho 4: BuildKit introdujo montajes de secretos específicamente porque la gente seguía metiendo credenciales en build args y capas de imagen para acceder a registros privados.
  • Hecho 5: docker history puede revelar comandos usados para construir una imagen. Si un secreto aparece en un comando RUN, puede ser visible incluso si el archivo no lo es.
  • Hecho 6: Muchos runtimes y orquestadores de contenedores persisten variables de entorno en stores de metadatos (y a veces en logs), multiplicando el radio de impacto de “simplemente ponlo en ENV”.
  • Hecho 7: La adopción temprana de contenedores promovió la idea de “artefacto único”: hornear todo en la imagen. La práctica de seguridad fue en la dirección opuesta: imágenes inmutables, secretos mutables inyectados en tiempo de ejecución.
  • Hecho 8: El diseño de Git hace que eliminar secretos sea difícil: aun si borras un archivo, permanece en el historial a menos que reescribas. La mejor fuga es la que nunca comites.
  • Hecho 9: Muchas brechas de alto perfil empezaron con descubrimiento de credenciales en repositorios o artefactos, no con una cadena de exploits novedosa. A los atacantes les encantan las búsquedas de tesoros.

Reglas básicas: dónde pueden vivir los secretos y dónde nunca

Lo que haces

  • Mantén los secretos fuera del contexto de build. Usa .dockerignore agresivamente y trátalo como un control de seguridad, no como una optimización de rendimiento.
  • Inyecta secretos en tiempo de ejecución mediante archivos montados. Prefiere /run/secrets (ruta convencional) o un montaje de solo lectura bajo un directorio dedicado.
  • Usa montajes de secretos de BuildKit para autenticación en tiempo de build. Esta es la forma menos mala de obtener dependencias privadas sin filtrar credenciales en capas.
  • Haz que el layout “seguro” sea el predeterminado. Los desarrolladores seguirán la ruta de menor resistencia; tu repo debe hacer que la ruta segura sea la más corta.
  • Escanea continuamente. No confíes en los humanos. Tampoco confíes en tu yo de hace seis meses.

Lo que no haces

  • No colocar secretos en ENV o en environment de Compose. Si es un secreto, debe estar en un archivo o en una integración de store de secretos, no en metadatos.
  • No copiar archivos secretos en Dockerfile. Ni siquiera “por un segundo.” Las capas son para siempre.
  • No imprimir “output de depuración” que muestre env. Si tu script de depuración empieza con env | sort, bórralo y discúlpate con el futuro.
  • No comprometer secretos reales, ni siquiera “solo para pruebas”. Las pruebas son cómo las fugas se vuelven permanentes.

Una cita para mantenerte honesto

Idea parafraseada de Gene Kim (autor sobre DevOps/operaciones): “La mejor manera de mejorar la fiabilidad es hacer los problemas visibles y corregirlos de forma sistemática.”

Tareas prácticas: 14 comandos para auditar y corregir fugas

Estas son tareas aptas para producción. Cada una incluye: el comando, qué significa la salida y qué decisión tomar. Ejecútalas en una máquina de desarrollador, en CI y en un servidor de build. Entornos distintos filtran de maneras distintas.

Tarea 1: Confirma que tu contexto de build no está enviando secretos

cr0x@server:~$ docker build --no-cache --progress=plain -t acme/app:check .
#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 612B done
#2 [internal] load .dockerignore
#2 transferring context: 2B done
#3 [internal] load metadata for docker.io/library/python:3.12-slim
#4 [internal] load build context
#4 transferring context: 48.35kB 0.0s done
...

Significado de la salida: “transferring context” muestra el tamaño enviado al daemon. Si ves megabytes que no esperabas, probablemente estés enviando basura — o secretos.

Decisión: Si el contexto es grande o sospechoso, ajusta .dockerignore y vuelve a ejecutar hasta que el tamaño del contexto coincida con lo que te sentirías cómodo enviando por correo a un desconocido.

Tarea 2: Verifica que .dockerignore se aplica realmente

cr0x@server:~$ docker build -t acme/app:ignore-test --no-cache --progress=plain .
#4 [internal] load build context
#4 transferring context: 48.35kB done
#4 DONE 0.1s

Significado de la salida: Si colocas temporalmente un archivo grande en secrets/runtime y el tamaño del contexto no cambia, tus patrones de ignore funcionan. Si crece, no funcionan.

Decisión: Arregla los patrones hasta que el contexto se mantenga estable incluso cuando secrets/ contenga archivos reales.

Tarea 3: Escanea el repositorio en busca de nombres comunes de archivos secretos

cr0x@server:~$ find . -maxdepth 4 -type f \( -name ".env" -o -name ".env.*" -o -name "*.pem" -o -name "*.key" -o -name "*kubeconfig*" \) -print
./secrets/dev/app.env.example

Significado de la salida: Cualquier cosa fuera de secrets/dev que parezca un secreto es un problema esperando para ser commiteado.

Decisión: Mueve archivos reales que parezcan secretos a secrets/runtime y asegúrate de que estén ignorados por git y Docker.

Tarea 4: Asegura que git no aceptará secretos bajo secrets/runtime

cr0x@server:~$ cat .gitignore
# runtime secrets must never be committed
secrets/runtime/*
!secrets/runtime/.keep

# local env files
.env
.env.*

Significado de la salida: La línea de negación mantiene un archivo marcador para que el directorio exista. Todo lo demás se ignora.

Decisión: Si secrets/runtime no está ignorado, corrígelo ahora; de lo contrario, alguien lo commiteará por accidente durante una corrección urgente.

Tarea 5: Detecta secretos ya rastreados (la trampa “ignorado pero commiteado”)

cr0x@server:~$ git ls-files | grep -E '(^|/)\.env(\.|$)|secrets/runtime|\.pem$|\.key$' || true

Significado de la salida: Si algo se imprime, ya está en el historial de git o rastreado en el índice.

Decisión: Elimínalo del índice y rota la credencial. Ignorar no des-hace la fuga.

Tarea 6: Elimina archivos secretos rastreados accidentalmente (sin borrar copias locales)

cr0x@server:~$ git rm --cached -r secrets/runtime
fatal: pathspec 'secrets/runtime' did not match any files

Significado de la salida: “did not match” es bueno: nada rastreado allí. Si elimina archivos, tenías un problema.

Decisión: Si eliminó archivos, commit la eliminación y rota inmediatamente cualquier secreto que estuviera presente.

Tarea 7: Inspecciona el historial de la imagen en busca de build args o comandos filtrados

cr0x@server:~$ docker history --no-trunc acme/app:check | head
IMAGE          CREATED          CREATED BY                                      SIZE      COMMENT
a1b2c3d4e5f6   2 minutes ago    CMD ["python" "-m" "src.app"]                   0B        buildkit.dockerfile.v0
<missing>      2 minutes ago    ENV APP_CONFIG=/app/config/logging.yaml        0B        buildkit.dockerfile.v0
<missing>      3 minutes ago    RUN /bin/sh -c useradd -r -u 10001 appuser...  1.2MB     buildkit.dockerfile.v0

Significado de la salida: Buscas tokens, contraseñas, URLs privadas o instrucciones echo que escribieron secretos.

Decisión: Si aparece algo parecido a un secreto, reconstruye con un Dockerfile corregido y revoca/rota credenciales. También purga imágenes antiguas del registro si es posible.

Tarea 8: Inspecciona el sistema de archivos de la imagen en busca de archivos “ups”

cr0x@server:~$ docker run --rm acme/app:check sh -lc 'ls -la /run /run/secrets || true; find /app -maxdepth 3 -type f -name ".env" -o -name "*.pem" -o -name "*.key" 2>/dev/null || true'
ls: /run/secrets: No such file or directory

Significado de la salida: En una imagen, /run/secrets típicamente no existirá a menos que se cree. Eso está bien. Lo que no está bien es encontrar .env, claves o certificados dentro de la imagen.

Decisión: Si hay archivos secretos en la imagen, trátalos como comprometidos y reconstruye limpio.

Tarea 9: Confirma que los contenedores no estén ejecutándose con variables env secretas

cr0x@server:~$ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' $(docker ps -q --filter name=app) | grep -Ei 'pass|token|secret|key' || true

Significado de la salida: No tener salida es la meta. Si ves variables con pinta de secreto, las estás exponiendo vía inspección y posiblemente en logs.

Decisión: Mueve esos secretos a archivos montados o secretos del orquestador y elimina su configuración en environment.

Tarea 10: Confirma que los archivos secretos montados existen y son de solo lectura

cr0x@server:~$ docker exec -it $(docker ps -q --filter name=app) sh -lc 'mount | grep /run/secrets; ls -la /run/secrets'
/dev/sda1 on /run/secrets type ext4 (ro,relatime)
total 8
drwxr-xr-x 2 root root 4096 Feb  4 10:22 .
drwxr-xr-x 1 root root 4096 Feb  4 10:22 ..
-r--r----- 1 root root   64 Feb  4 10:22 db_password

Significado de la salida: El montaje muestra (ro,...) y el archivo secreto no es legible por todos.

Decisión: Si es rw o los permisos son laxos, corrige los manifests de Compose/Kubernetes. Los secretos deben ser legibles solo por el usuario de la app, no por todo el contenedor.

Tarea 11: Comprueba la propiedad de archivos vs. el usuario del contenedor (diagnóstico de permisos)

cr0x@server:~$ docker exec -it $(docker ps -q --filter name=app) sh -lc 'id; stat -c "%U %G %a %n" /run/secrets/db_password'
uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)
root root 440 /run/secrets/db_password

Significado de la salida: Si el contenedor corre como appuser pero el archivo es propiedad de root con 440, tu app podría no poder leerlo a menos que los permisos de grupo se alineen.

Decisión: Ejecuta secretos con propiedad de grupo apropiada, usa 0444 para solo lectura cuando sea aceptable, o configura la proyección de secretos del orquestador con UID/GID correctos.

Tarea 12: Escanea la imagen en busca de cadenas de alta entropía (detector barato de fugas)

cr0x@server:~$ docker save acme/app:check | tar -xOf - | strings | grep -E '[A-Za-z0-9+/]{32,}={0,2}' | head

Significado de la salida: Esto es ruidoso, pero detecta blobs con pinta de base64 que a veces indican tokens embebidos.

Decisión: Si ves algo sospechoso, acótalo exportando el filesystem y grepando rutas específicas. Trata las positivas con seriedad.

Tarea 13: Usa correctamente el montaje de secretos de BuildKit (ejemplo de dependencia privada)

cr0x@server:~$ DOCKER_BUILDKIT=1 docker build \
  --secret id=pypi_token,src=./secrets/runtime/pypi_token \
  --progress=plain -t acme/app:with-private-deps .
#6 [runtime 2/6] RUN --mount=type=secret,id=pypi_token ...
#6 DONE 8.7s

Significado de la salida: Verás un paso RUN --mount=type=secret. Eso indica que no estás copiando el token en la imagen.

Decisión: Si el Dockerfile usa ARG en su lugar, para y refactoriza. Los build args no son almacenamiento seguro de secretos.

Tarea 14: Prueba que el secreto no acabó en la imagen resultante

cr0x@server:~$ docker run --rm acme/app:with-private-deps sh -lc 'grep -R "pypi" -n / 2>/dev/null | head'

Significado de la salida: Buscas cadenas de token o archivos de configuración de autenticación. Idealmente esto no devuelve nada significativo (puede devolver coincidencias irrelevantes; investiga si lo hace).

Decisión: Si el token o configuración existe en el filesystem final, el build lo filtró. Rota y arregla el Dockerfile.

Tres mini-historias corporativas del “funcionó en mi portátil”

Mini-historia 1: El incidente causado por una suposición equivocada

Un equipo cercano a pagos tenía una imagen Docker que se construyó bien durante meses. Usaban un registro privado de paquetes y asumían que su build arg era “lo suficientemente seguro” porque se pasaba solo en CI. El Dockerfile aceptaba ARG NPM_TOKEN, lo escribía en ~/.npmrc, instalaba dependencias y luego borraba el archivo. ¿Limpio, verdad?

No fue limpio. El token ya había vivido en una capa. Alguien ejecutó más tarde un job interno de escaneo de imágenes que guardó imágenes en un store de artefactos compartido para análisis. Otro equipo — intentando depurar un problema de build — tiró el artefacto y desempaquetó el tar. Ese token no estaba “en prod”, pero seguía siendo una credencial válida.

El radio de impacto fue embarazoso, no catastrófico: acceso a paquetes internos, una exposición menor en la cadena de suministro y una semana de rotación forzada entre proyectos. El daño real fue la moral. Ingenieros que habían sido cautelosos sobre “no commitear secretos” aprendieron que las capas de Docker son su propio tipo de historial.

La solución fue aburrida: montajes de secretos de BuildKit, un .dockerignore estricto y una política que prohibía tokens en instrucciones de Dockerfile. El equipo también dejó de subir imágenes crudas como “artefactos de depuración”. Subían logs de build con scrubbing y solo la metadata necesaria.

Cuando todo se calmó, alguien preguntó por qué no se detectó antes. La respuesta fue simple: todos asumieron que “eliminar el archivo” significa “se fue”. Esa suposición es cómo las capas te atrapan.

Mini-historia 2: La optimización que salió mal

Un grupo de plataforma optimizó tiempos de build en CI habilitando caching agresivo de capas Docker en runners compartidos. Los builds se aceleraron, lo que dejó a todos felices por unas dos sprints. Luego empezaron a ver comportamientos “fantasma”: una rama de feature podía construir con éxito incluso después de quitar acceso a un secreto, porque la capa que usaba el secreto estaba cacheada.

Lo peor: un desarrollador añadió una línea temporal de depuración imprimiendo un valor de configuración que por casualidad incluía un token. Los logs de build se conservaron más tiempo del debido. Mientras tanto, capas cacheadas que contenían configuraciones de descarga de dependencias se compartían entre proyectos. Eso no debería pasar, pero “debería” no es un mecanismo de control de acceso.

El incidente no fue una brecha clásica. Fue una exposición. Seguridad lo archivó como “fallo en manejo de credenciales.” El equipo de plataforma lo consideró “malconfiguración del scope de cache.” Todos coincidieron en que la causa raíz fue una optimización que redujo fricción para builds y aumentó fricción para contención.

Mantuvieron el caching — pero lo hicieron disciplinado: caches aislados por proyecto, sin compartir entre tenants, y reglas explícitas que cualquier paso de build que use secretos no sea cacheable o use montajes de secretos que no contaminen capas. También acortaron la retención de logs y limpiaron patrones de claves conocidos.

El rendimiento es una característica. La seguridad también lo es. Si optimizas una robando silenciosamente de la otra, acabarás pagando intereses después.

Mini-historia 3: La práctica aburrida pero correcta que salvó el día

Una compañía del ámbito salud tuvo un estándar simple: secrets/runtime existe en cada repositorio de servicio, está ignorado por git y Docker, y las pipelines lo montan en tiempo de ejecución. Sin excepciones. Los ingenieros se quejaron un mes. Luego dejaron de notarlo.

Un viernes, alguien copió por accidente una credencial de producción en un archivo local llamado prod.env en la raíz del repo. El desarrollador estaba a punto de commitear. El hook pre-commit chilló. CI también chilló. El contexto de build se mantuvo pequeño porque .dockerignore ignoraba patrones .env*, y el escaneo a nivel de repo señaló el blob de alta entropía.

La credencial nunca aterrizó en git. Nunca aterrizó en la imagen. Nunca llegó al registro. La persona la rotó de todos modos, porque fueron entrenados para tratar un “casi accidente” como “posiblemente comprometido.” Esa rotación tomó minutos, no días, porque tenían un procedimiento documentado y una vía consistente de inyección de secretos.

Seguridad no tuvo que jugar a ser detective. SRE no tuvo que despertar a nadie. El sistema no salvó el día con criptografía elegante; lo salvó con colocación predecible de archivos y múltiples pequeñas barreras.

Broma 2: El mejor secreto es el que nunca filtras. El segundo mejor secreto es el que rotas antes de que alguien note que lo filtraste.

Guion de diagnóstico rápido (revisa primero/segundo/tercero)

Cuando alguien dice “filtramos un secreto” o “el escáner dice que la imagen contiene credenciales”, no debatas teoría. Haz triage rápido. Aquí está el guion que uso.

Primero: confirma si el secreto está en git history, en la imagen o solo en runtime

  • Revisa tracking en git: git ls-files + patrones de grep; si está trackeado, trátalo como comprometido.
  • Revisa historial de la imagen: docker history --no-trunc para líneas de comando visibles.
  • Revisa filesystem: ejecuta un contenedor y find en busca de tipos comunes de archivos secretos.

Por qué primero: la remediación difiere. El historial de git es permanente salvo que se reescriba. Los registros de imagen se replican. La exposición solo en runtime puede limitarse a un único host.

Segundo: identifica el mecanismo de inyección

  • Vars de env: docker inspect y revisa manifests del orquestador.
  • Archivos montados: verifica montajes en /run/secrets y permisos.
  • Secretos en build: busca ARG, --build-arg y archivos de auth de gestores de paquetes (.npmrc, .netrc).

Por qué segundo: necesitas prevenir recurrencias. Rotar el secreto es el paso cero; arreglar la vía es el trabajo real.

Tercero: acota el radio de impacto y rota de forma decisiva

  • Busca en logs y artefactos de CI la cadena del secreto o patrones de ID de clave.
  • Revisa registros por tags/digests afectados; elimina o cuarentena aquellos.
  • Rota credenciales; invalida tokens; emite nuevas claves; actualiza despliegues usando la nueva vía de secretos.

Por qué tercero: los secretos se filtran más rápido de lo que puedes programar una reunión. La rotación vence la parálisis por análisis.

Errores comunes: síntomas → causa raíz → solución

Error 1: “Borramos el archivo en un paso posterior del Dockerfile”

Síntomas: el escáner de secretos marca la imagen; no puedes encontrar el archivo en el contenedor en ejecución; seguridad insiste en que sigue ahí.

Causa raíz: el secreto existió en una capa anterior; borrarlo solo lo ocultó en la vista final del filesystem.

Solución: reconstruye sin nunca escribir el secreto en una capa. Usa montajes de secretos de BuildKit o descarga dependencias fuera del build de imagen. Purga tags de imagen antiguos y rota credenciales.

Error 2: “Está bien, está solo en variables de entorno”

Síntomas: secretos aparecen en docker inspect; bundles de soporte contienen tokens; alguien pega la configuración en chat y ahora está en historial buscable.

Causa raíz: las env son metadatos; se copian, registran, extraen y se inspeccionan.

Solución: monta secretos como archivos bajo /run/secrets (o proyecciones de secretos del orquestador). Mantén vars de env para toggles no secretos. Si una app solo soporta env, envuélvela: leer desde archivo y exportar en un entrypoint pequeño, pero acepta el riesgo residual y restringe acceso a inspección/logs.

Error 3: “Tenemos .dockerignore, así que estamos seguros”

Síntomas: transferencia de contexto de CI es enorme; secretos se encuentran en cache del builder; máquinas distintas producen resultados distintos.

Causa raíz: .dockerignore está incompleto, o estás construyendo desde un contexto distinto al que crees (builds en monorepo, directorio equivocado, diferencias en rutas de CI).

Solución: verifica tamaño del contexto en logs de build; establece explícitamente context: en Compose; añade tests que fallen si secrets/ está en el contexto; estandariza puntos de entrada de build (scripts) para que los desarrolladores no construyan a mano.

Error 4: “Montamos secretos, pero la app no puede leerlos”

Síntomas: la app falla con permiso denegado; los archivos secretos existen pero la lectura falla; ingenieros “arreglan” corriendo como root.

Causa raíz: mismatch de UID/GID y modo de archivo demasiado estricto, o secretos proyectados con permisos solo para root.

Solución: ejecuta la app con un UID estable; proyecta secretos con la propiedad correcta; usa modos legibles por grupo; evita “ejecutar como root” como parche.

Error 5: “Montamos ./secrets en el contenedor y olvidamos que es escribible”

Síntomas: archivos secretos cambian inesperadamente; secretos locales de dev se sobrescriben; alguien comitea archivos modificados por accidente.

Causa raíz: bind mount escribible desde el host; procesos del contenedor escriben de vuelta en el filesystem del host.

Solución: monta secretos en solo lectura. Haz que el filesystem del contenedor sea read_only: true y permite escrituras solo en rutas tmpfs.

Error 6: “CI subió artefactos para depuración, incluyendo el workspace”

Síntomas: el secreto aparece en el store de artefactos de CI; la cadena del token aparece en tarballs archivados; seguridad pregunta por qué tu pipeline es un servicio de exfiltración de datos.

Causa raíz: reglas de subida de artefactos demasiado amplias; sin scrubber; sin separación entre salida de build y estado del workspace.

Solución: sube solo outputs explícitos de build; nunca subas docker inspect o workspace completo; limpia logs; mantén los secretos fuera del workspace con el esquema descrito aquí.

Listas de verificación / plan paso a paso

Paso a paso: adopta el esquema de archivos en un repo existente

  1. Crea directorios: config/, secrets/dev/, secrets/runtime/, scripts/.
  2. Mueve configs no secretas de la raíz del repo a config/. Mantén las configs comprometidas no secretas.
  3. Crea plantillas de secretos de ejemplo en secrets/dev (p. ej., app.env.example), y documenta las claves esperadas.
  4. Añade un .gitignore estricto para secrets/runtime y .env*.
  5. Añade un .dockerignore estricto para excluir secretos, git y basura de CI.
  6. Refactoriza el Dockerfile para copiar solo src/ y config/. Elimina cualquier COPY . . a menos que te gusten las auditorías.
  7. Cambia el runtime a montajes en Compose/Kubernetes. Estandariza en /run/secrets.
  8. Deja de usar build args para secretos. Sustitúyelos por montajes de secretos de BuildKit cuando sea inevitable.
  9. Añade guardrails: un script que escanee secretos y haga fallar CI. Hazlo lo bastante rápido como para ejecutarlo en cada PR.
  10. Rota credenciales si encuentras algo sospechoso en historial, imágenes, registros o logs.

Checklist de CI (seguridad mínima viable)

  • Build con BuildKit habilitado (DOCKER_BUILDKIT=1).
  • No imprimir variables de entorno en logs.
  • No subir archives del workspace como artefactos.
  • No compartir cache de capas Docker entre proyectos/tenants a menos que esté diseñado y auditado para ello.
  • Ejecutar un escaneo de secretos contra el diff de git y contra la imagen construida.

Checklist de runtime (contenedores en producción)

  • Ejecutar como non-root con un UID estable.
  • Montar secretos en solo lectura; proyectarlos con permisos correctos.
  • Hacer el filesystem raíz de solo lectura; usar tmpfs para espacio escribible.
  • Eliminar capacidades de Linux y establecer no-new-privileges.
  • Asegurar que los logs nunca incluyan contenido de archivos secretos ni volcados de entorno.

Preguntas frecuentes

1) ¿Un archivo .env es siempre una mala idea?

No. Un .env es un formato conveniente. La mala idea es dejar que derive hacia git, contextos de build Docker, imágenes o artefactos. Pon los reales en secrets/runtime y ignóralos.

2) ¿Por qué archivos montados en lugar de variables de entorno?

Los archivos montados son menos propensos a ser registrados, extraídos o expuestos vía inspección de metadatos. También permiten permisos de sistema de archivos más estrictos. Las env vars son fáciles; fácil no es lo mismo que seguro.

3) ¿Y el soporte de “secrets” de Docker Compose?

Úsalo cuando esté soportado, pero no lo tomes por magia. Muchos equipos terminan montando archivos secretos. El esquema funciona para ambos: un lugar estable en el host o en CI donde los secretos se almacenan y se montan en tiempo de ejecución.

4) ¿Pueden los secretos de BuildKit filtrarse de todos modos?

Sí, si los imprimes, los escribes en disco o los copias en la imagen. BuildKit te da un mecanismo seguro de inyección; no evita scripting malo.

5) Necesitamos un registro privado de dependencias durante el build. ¿Cuál es el patrón más seguro?

Usa montajes --secret de BuildKit y configura el gestor de paquetes para leer credenciales desde el montaje de secreto durante el paso de instalación. Mantén el paso mínimo y evita caching si no puedes demostrar que queda limpio.

6) Si un secreto se commiteó pero el repo es privado, ¿rotamos igual?

Sí. “Repo privado” no es un límite de seguridad que puedas auditar perfectamente. Rota, luego limpia el historial si la política lo requiere. La rotación es lo urgente.

7) ¿Cómo evitamos que los desarrolladores bypassen el esquema?

Hazlo por defecto en plantillas, añade checks pre-commit y CI, y exige .dockerignore y patrones de Dockerfile en las revisiones. Además: mantén el flujo de trabajo rápido para que la gente no invente hacks “temporales”.

8) ¿Y Kubernetes?

El mismo principio aplica: secretos inyectados en tiempo de ejecución, idealmente como archivos (volúmenes de secretos). Mantén la imagen del contenedor libre de secretos. Estandariza en una ruta como /run/secrets para que la app no importe de dónde vino el secreto.

9) ¿Valen la pena los filesystems de solo lectura?

Sí. Previenen toda una clase de accidentes: escribir secretos en el filesystem del contenedor, mutar config y dejar migas de credenciales en capas escribibles. Combínalo con tmpfs para /tmp y lo que tu app necesite.

10) ¿Cuál es la forma más rápida de ver si filtramos por contexto de build?

Ejecuta docker build --progress=plain y observa el tamaño de la transferencia del contexto. Si es grande o cambia cuando añades un archivo bajo secrets/, tus reglas de ignore están mal.

Siguientes pasos (haz esto esta semana)

Los secretos no se filtran porque los ingenieros sean descuidados. Se filtran porque los sistemas son permisivos y los flujos de trabajo premian la velocidad. Arregla el flujo de trabajo.

  1. Adopta el esquema: src/, config/, secrets/dev (ejemplos), secrets/runtime (reales, ignorados), .dockerignore estricto.
  2. Refactoriza Dockerfiles para copiar solo lo necesario. Elimina COPY . . salvo que lo puedas justificar por escrito.
  3. Mueve secretos a montajes de runtime bajo /run/secrets, en solo lectura, con permisos sensatos y contenedores non-root.
  4. Habilita BuildKit y usa montajes de secretos para auth en build. Prohíbe ARG para credenciales.
  5. Añade dos guardrails baratos: escanea el repo en busca de archivos con pinta de secretos y escanea imágenes construidas en busca de contenido con pinta de secretos.
  6. Si encuentras algo: rota primero, analiza después y no negocies con tu propio hindsight.
← Anterior
Windows muestra “Conectado, sin Internet”? Arréglalo sin restablecerlo todo
Siguiente →
Instalación RHEL 10: la lista de comprobación empresarial que desearías haber tenido antes

Deja un comentario