Skip to main content

Flujos de trabajo impulsados por desarrolladores: análisis, priorización y corrección de imágenes de Dockerfile

Escrito por
Blog Design Developer Driven Workflows updated

26 de marzo de 2021

0 minutos de lectura

Al implementar aplicaciones en contenedores, los desarrolladores ahora deben asumir responsabilidades relacionadas con la seguridad del sistema operativo. A menudo, estos temas les resultan desconocidos y, en muchos casos, antes estaban a cargo de los equipos de operaciones y seguridad. Aunque este nuevo ámbito puede parecer abrumador, existen diversas herramientas y prácticas que puedes incorporar a tu flujo de trabajo para asegurarte de detectar y corregir los problemas antes de que lleguen a producción. En este artículo, aprenderás a identificar, priorizar y corregir problemas en las imágenes de contenedor de tu aplicación con distintas herramientas, y comprenderás mejor cómo estas correcciones pueden afectar tus aplicaciones durante la implementación.

La aplicación de ejemplo que se usa en este artículo está disponible para que sigas los pasos y experimentes si quieres. Está en GitHub: https://github.com/snyk-snippets/dev-driven-workflows.


Índice


Las imágenes de contenedor bien diseñadas son esenciales

La base de cualquier contenedor que implementes está definida, en gran medida, por la imagen de Docker/OCI en la que se basa. Por eso, es importante entender qué es una imagen de contenedor y cómo funciona.

¿Qué es una imagen de contenedor?

En esencia, una imagen es simplemente una colección de archivos de sistema de archivos y definiciones de metadatos. Cada uno contiene una parte del sistema de archivos y ajustes del entorno que definen el estado inicial de un contenedor. Las capas se apilan de forma lógica y el contenido de cada una se aplica a la capa anterior, a veces llamada capa principal. Cada capa se cifra mediante un hash criptográfico para garantizar su integridad, y la imagen conserva un manifiesto de ellas, además de su propio hash, llamado digest de la imagen.

Diagrama apilado que muestra una capa de lectura y escritura sobre varias capas etiquetadas como Layer y Base, con identificadores SHA-256.

Cuando se crea un contenedor, el sistema de archivos que se le presenta es una unión de las capas de la imagen con una capa opcional de lectura y escritura encima. Todos los archivos que escribe el contenedor quedan en la capa de lectura y escritura, ya que las capas de la imagen son inmutables. Los cambios en archivos existentes en las capas de la imagen se copian primero a la capa de lectura y escritura y luego se modifican allí; este comportamiento se conoce como copia en escritura.

¿Qué es un Dockerfile?

Un Dockerfile es una lista sencilla de instrucciones que describe qué debe incluir cada capa de una imagen de contenedor.

Ejemplo de Dockerfile

En el siguiente ejemplo, puedes ver cómo se crean las capas, empezando con una imagen base node:14.1.0. Cada capa sucesiva consta de los metadatos o los cambios en el sistema de archivos que generan las instrucciones de cada línea del Dockerfile. Este ejemplo está disponible en el repositorio de GitHub mencionado anteriormente y se llama Dockerfile-initial.

FROM node:14.1.0

RUN mkdir /usr/src/goof
RUN mkdir /tmp/extracted_files
ADD . /usr/src/goof
RUN cd /usr/src/goof

RUN npm ci --only=production
EXPOSE 3001
EXPOSE 9229
ENTRYPOINT npm start

Si ejecutáramos una operación docker build con este Dockerfile de ejemplo, se crearía una capa que agrega el directorio /usr/src/goof, seguida de otra capa que contiene el nuevo directorio /tmp/extracted_files.

La siguiente capa copia el contenido del directorio actual de la máquina de compilación —indicado por . en el comando de compilación—, incluidos sus subdirectorios, al directorio /usr/src/goof de la nueva capa.

La compilación continúa agregando capas nuevas hasta llegar al final del archivo.

Cada capa de una imagen se construye sobre su capa principal, pero, una vez creada, es inmutable. Incluso si agregáramos una línea para eliminar un archivo de un paso anterior, el contenido de ese archivo seguiría presente en la jerarquía; el cambio para eliminarlo solo sería la diferencia de esa nueva capa. Esto es similar a lo que ocurre en repositorios de código fuente como git: confirmar la eliminación de un archivo no lo elimina de las confirmaciones históricas. Así, si se agrega un archivo de 1 MB a una capa y luego se elimina en una capa posterior, la imagen seguirá conteniendo ese 1 MB, aunque el contenedor no lo vea.

Instrucciones RUN compuestas

Es común ver instrucciones compuestas que incluyen la limpieza al final para evitar que queden archivos temporales en el sistema de archivos de la capa. Nuestro Dockerfile no incluye una, pero este es un ejemplo representativo de un patrón de uso frecuente al instalar paquetes del sistema operativo con apt, yum, etc.

RUN apt-get update -y && \
    apt-get install -y --no-install-recommends curl=7.68.0-1ubuntu2.4 && \
    rm -rf /var/lib/apt/lists/*

Vemos que se instala una versión específica de curl en una imagen de Ubuntu y que se evita guardar en la capa las cachés de apt.

Nuestro ejemplo de Dockerfile representa los pasos imperativos que se usaban para compilar y ejecutar esta aplicación antes de pasar a los contenedores. No es muy distinto de lo que podrías encontrar en aplicaciones heredadas que simplemente se trasladaron a contenedores. Es ideal para optimizar y reforzar la seguridad, así que veámoslo en detalle.

Además de necesitar optimizaciones, este Dockerfile tiene un error que hace que falle la compilación con docker build. Veamos el problema y cómo podemos usar la automatización para ayudar a corregirlo.

10 formas de optimizar y proteger tu aplicación en contenedores

1. Usa un linter de Dockerfile

Un primer paso habitual para mejorar nuestros Dockerfiles es usar un linter. Los linters analizan estáticamente el contenido de un archivo y sugieren problemas que podríamos corregir. Como mencionamos antes, hay un error en el Dockerfile actual que hace que falle la compilación.

$ docker build -t goof -f Dockerfile-initial .
[+] Building 11.3s (10/10) FINISHED                                                                                                                                                
 => [internal] load build definition from Dockerfile      0.0s
...
 => ERROR [6/6] RUN npm ci --only=production              1.0s
------
 > [6/6] RUN npm ci --only=production:
#10 0.936 npm ERR! code ENOENT
#10 0.937 npm ERR! syscall open
#10 0.937 npm ERR! path /package.json
#10 0.938 npm ERR! errno -2
#10 0.939 npm ERR! enoent ENOENT: no such file or directory, open '/package.json'
#10 0.939 npm ERR! enoent This is related to npm not being able to find a file.
#10 0.939 npm ERR! enoent 
#10 0.948 
#10 0.948 npm ERR! A complete log of this run can be found in:
#10 0.949 npm ERR!     /root/.npm/_logs/2021-03-17T17_19_44_616Z-debug.log
------
executor failed running [/bin/sh -c npm ci --only=production]: exit code: 254

Hadolint es un popular linter de código abierto para Dockerfiles que lee las instrucciones y ofrece recomendaciones concretas sobre su estructura.

$ hadolint Dockerfile-initial 
Dockerfile:5 DL3020 error: Use COPY instead of ADD for files and folders
Dockerfile:6 SC2164 warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
Dockerfile:6 DL3003 warning: Use WORKDIR to switch to a directory
Dockerfile:11 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

Revisemos los problemas que encontró:

  1. Usa COPY en lugar de ADD para copiar archivos y carpetas. Esto es válido para copiar archivos locales que no sean archivos comprimidos, como se indica en las prácticas recomendadas para Dockerfile de Docker. Vale la pena corregirlo, pero no es la causa del error de compilación.

  2. Usa 'cd ... || exit' o 'cd ... || return' por si falla cd.Técnicamente es cierto, pero no es el problema en este caso. Por ahora, omitiremos este punto.

  3. Usa WORKDIR para cambiar de directorio¡Ajá! Esta es la causa. El efecto de la línea RUN cd /usr/src/goof se limita a la capa que crea esa instrucción; de hecho, esa línea no tiene ningún efecto práctico en la compilación de la imagen. Deberíamos usar el comando de Dockerfile WORKDIR, que establece explícitamente el directorio de trabajo actual a partir de ese momento, incluso durante la ejecución. La falta de esta instrucción es la razón por la que la línea RUN npm… no encuentra el archivo package.json.

  4. Usa la notación JSON de argumentos para los argumentos de CMD y ENTRYPOINTTambién conocida como la forma «exec», esta recomendación genera opiniones diversas, pero la documentación de Docker sí se inclina por usar la notación JSON.

Después de corregir todos estos problemas, nuestro nuevo Dockerfile —llamado Dockerfile-hadolint-fixes en el repositorio de ejemplo— no presenta problemas de linting y ahora se compila correctamente.

FROM node:14.1.0

RUN mkdir /usr/src/goof
RUN mkdir /tmp/extracted_files
COPY . /usr/src/goof
WORKDIR /usr/src/goof

RUN npm ci --only=production
EXPOSE 3001
EXPOSE 9229
ENTRYPOINT ["npm", "start"]

$ hadolint Dockerfile-hadolint-fixes 

$ docker build -t goof -f Dockerfile-hadolint-fixes .
[+] Building 26.5s (11/11) FINISHED                                                                                                                                                
 => [internal] load build definition from Dockerfile-hadolint-fixes                0.0s
...
 => => writing image sha256:c365dec0cfdf64d8cfd43ebd8f2bcd534cfe7b71849796dfb3030b4fe5ff7f93               0.0s 
 => => naming to docker.io/library/goof:hadolint-fixes

2. Ejecuta el linter como hook de confirmación para evitar que se cuelen problemas en el Dockerfile de tu base de código

Herramientas ligeras como hadolint son excelentes para usarlas como hooks de preconfirmación, ya que evitan que se cuelen problemas de Dockerfile en tu base de código. El siguiente es un ejemplo sencillo de un script de git que puedes agregar a tu repositorio en la ruta: .git/hooks/pre-commit. Si no conoces los hooks de git, consulta la documentación de Git-SCM para obtener más detalles.

#!/bin/sh
echo "Linting Dockerfile ...\c"
LINT=$(hadolint Dockerfile)
if [[ $? > 0 ]]
then
   echo " FAILED\n"
   echo "$LINT"
   echo "\nLinting failed, commit aborted."
   exit 1
fi
echo " PASSED"
exit 0

Este es un ejemplo de cómo se ejecuta ese hook con el Dockerfile original, antes de aplicar las correcciones.

$ git commit -m 'Test'
Linting Dockerfile ... FAILED

Dockerfile:5 DL3020 error: Use COPY instead of ADD for files and folders
Dockerfile:6 SC2164 warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
Dockerfile:6 DL3003 warning: Use WORKDIR to switch to a directory
Dockerfile:11 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

Linting failed, commit aborted.

Nota: Si estás probando esto en nuestro repositorio de ejemplo, asegúrate de copiar Dockerfile-initial como Dockerfile y prepararlo en tu repositorio para activar el problema del hook.

3. Prueba tu imagen local de forma iterativa

Ya logramos compilar una imagen, solucionamos un error de compilación y corregimos algunas malas prácticas en el proceso. Ahora debemos asegurarnos de que la imagen incluya todo lo necesario para ejecutar la aplicación.

El ejemplo «goof» es una aplicación sencilla de dos niveles que consta de un frontend de Node.js que depende de un backend de persistencia de MongoDB. Para ejecutar esta aplicación localmente, haremos lo siguiente:

  1. Haz una comprobación rápida de la imagen con docker run. (Por lo general, esto se hace de forma iterativa mientras trabajas en tu Dockerfile). La aplicación fallará porque no tiene una base de datos a la que conectarse, pero, si llegas hasta ese punto, al menos sabrás que el contenedor está iniciando.

  2. Ejecuta un clúster local de Kubernetes que pueda acceder a la imagen que compilaste. Puedes elegir entre varias opciones; estas son algunas de las más populares:

Pruebas con Docker

Mientras trabajas en el Dockerfile, querrás asegurarte de que la imagen se esté creando correctamente. La forma más sencilla de hacerlo es ejecutar un contenedor de vez en cuando y revisar algunos aspectos puntuales.

Por ejemplo, si queremos ejecutar la imagen que compilamos antes con la etiqueta goof, usaríamos docker run --rm -it -p3001:3001 goof.

Esto creará e iniciará un contenedor basado en esa imagen, y hará que el puerto 3001 de tu estación de trabajo envíe tráfico al puerto 3001 del contenedor. --rm le indica a Docker que elimine el contenedor cuando se detenga, y -it lo ejecuta en modo interactivo con una TTY para que podamos ver la salida y usar CTRL-C para detenerlo.

Verás una serie de mensajes mientras la aplicación de Node.js intenta iniciarse, incluido un error que indica que no puede conectarse a la base de datos. Luego, cuando la aplicación se cierre, el contenedor se detendrá y se eliminará. Si ves algo más, como errores que indiquen que no se encuentra npm, sabremos que la propia imagen tiene problemas.

Pruebas en Kubernetes local

Implementaremos la aplicación con los archivos de manifiesto que se encuentran en la carpeta manifests del repositorio de ejemplo.

goof-deployment.yaml

---
apiVersion: apps/v1
kind: Deployment
metadata:
 name: goof
spec:
 replicas: 1
 selector:
   matchLabels:
     app: goof
     tier: frontend
 template:
   metadata:
     labels:
       app: goof
       tier: frontend
   spec:
     containers:
       - name: goof
         image: goof:latest
         resources:
           requests:
             cpu: 100m
             memory: 100Mi
         ports:
           - containerPort: 3001
           - containerPort: 9229
         env:
           - name: DOCKER
             value: "1"
---
apiVersion: apps/v1
kind: Deployment
metadata:
 name: goof-mongo
spec:
 replicas: 1
 selector:
   matchLabels:
     app: goof
     tier: backend
 template:
   metadata:
     labels:
       app: goof
       tier: backend
   spec:
     securityContext:
       runAsNonRoot: true
       runAsUser: 999
     containers:
       - name: goof-mongo
         image: mongo
         securityContext:
           capabilities:
             drop:
               - all
         ports:
           - containerPort: 27017

Una explicación completa de las API de Kubernetes queda fuera del alcance de este artículo, pero, en términos generales, declaramos dos implementaciones que implementarán y administrarán pods, donde se ejecutarán nuestro contenedor y su base de datos.

También implementaremos un archivo goof-services.yaml que expone los pods mediante servicios de Kubernetes y permite descubrir el servicio de esa instancia de MongoDB. Si ejecutas Kubernetes en tu equipo local con Docker Desktop, KinD o MiniKube, deberás agregar imagePullPolicy: Never a la especificación del contenedor goof, como se muestra aquí:

...
     containers:
       - name: goof
         image: goof:latest
         imagePullPolicy: Never
...

Si usas un clúster remoto, esto no es necesario, pero tendrás que subir tu imagen a un registro y cambiar las etiquetas image: en estos archivos yaml según corresponda. Si tienes preguntas, consulta con los operadores de tu clúster.

Suponiendo que tu clúster de Kubernetes está en ejecución y que tu configuración de kubectl está lista, lo único que tienes que hacer ahora es ejecutar kubectl apply -f manifests/ y esperar a que los pods alcancen el estado Running, comprobándolo con kubectl get pods.

$ kubectl apply -f manifests/
deployment.apps/goof created                                                  deployment.apps/goof-mongo created
service/goof created
service/goof-mongo created

$ kubectl get pods
NAME                          READY   STATUS    RESTARTS   AGE
goof-6bf7cfb886-qjgdd         1/1     Running   0          6m7s
goof-mongo-66f98d594c-vsphr   1/1     Running   0          30m

Por último, probamos la aplicación abriendo un navegador en:

  • Docker Desktop o KinD: http://localhost

  • MiniKube ejecuta minikube service goof y usa la primera URL que te proporcione

  • OtrosSi aparece una IP externa en kubectl get service goof, úsala o prueba la IP de uno de los nodos de tu clúster en el puerto 32301. De lo contrario, pide ayuda al operador de tu clúster.

Deberías ver algo como esto en tu navegador:

Encabezado Goof TODO sobre un campo de texto vacío

Puedes agregar una nota TODO y guardarla presionando Enter. Si todo funciona, debería agregarse a una lista en la página.

Repite el proceso: practica de forma iterativa el desarrollo y las pruebas locales

Ahora que está en funcionamiento, puedes seguir desarrollando tu aplicación de forma iterativa, reconstruir la imagen y subirla a tu clúster. Probablemente haya cientos de formas de llevar a cabo este proceso, según el tipo de aplicación que estés creando y la distribución de Kubernetes que uses, pero, en general, todas siguen un flujo de trabajo parecido a este:

  1. Se realizan cambios en el código

  2. Se crea una imagen nueva con el comando docker build o las herramientas de tu lenguaje

  3. Se sube la imagen al clúster o al registro (si es necesario)

  4. Se actualiza el pod de Kubernetes en ejecución para que use la nueva imagen. Para hacerlo, normalmente solo elimino el pod en ejecución y dejo que el clúster inicie otro a partir de la nueva imagen, pero también puedes usar kubectl set image, siempre y cuando la nueva imagen tenga una etiqueta nueva.

  5. Prueba el nuevo pod cuando esté listo y en funcionamiento

Más medidas para reforzar la seguridad de las imágenes

Aparte de corregir algunos errores en el Dockerfile original, todavía no hemos abordado realmente su seguridad. Ahora que tenemos una forma de ejecutar y probar nuestra aplicación, veamos algunos de los problemas de seguridad más interesantes que presenta nuestra imagen.

4. No uses el usuario root de forma predeterminada en tu imagen

Si entras al contenedor de nuestra aplicación y lo verificas, verás que el proceso de node se ejecuta como usuario root.

$ kubectl exec -it goof-6bf7cfb886-slkxm -- id
uid=0(root) gid=0(root) groups=0(root)

Aunque el proceso se ejecuta dentro de un contenedor con un espacio de nombres de procesos de Linux que lo aísla del resto del host, no es buena idea ejecutarlo con el UID 0 por varias razones. Los problemas habituales se deben a errores de configuración durante la implementación, como montar en el contenedor un volumen del host que expone más información de la prevista. El usuario del contenedor tendrá los mismos privilegios en ese sistema de archivos que tendría el mismo UID en el host. Por ejemplo, si el directorio /etc del host se monta en el contenedor, un proceso propiedad de root dentro de ese contenedor tendrá acceso completo para leer y escribir cualquier archivo de la carpeta /etc del host.

¿Recuerdas que mencionamos que las capas son inmutables y que eliminar un archivo de una capa no elimina realmente su contenido de las capas principales? Un ejemplo de cómo se puede explotar esto es que un proceso contenido pueda leer los archivos de las capas de imágenes de contenedor del host porque alguien montó la ruta /var/lib/docker en el contenedor. Esta es la ruta donde están disponibles todas esas capas, así que un proceso malicioso solo tendría que empezar a buscar software vulnerable en ellas. (O en /var/lib/containerd o donde sea que tu entorno de ejecución de contenedores las almacene). Por suerte, quienes mantienen las imágenes oficiales de node ya crearon para nosotros un usuario node con un UID de 1000. Para usarlo, solo tenemos que agregar una línea con USER 1000 en nuestro Dockerfile. Usamos el UID en lugar del nombre de usuario porque algunas herramientas, como Kubernetes, no pueden asignar el nombre de usuario predeterminado de una imagen a su UID antes de iniciar el contenedor, y quizá necesiten hacerlo para aplicar políticas sobre usuarios root. Más adelante veremos cómo se aplican este tipo de políticas.

USER 1000
ENTRYPOINT ["npm", "start"]

Si la imagen base que elegiste no tiene un usuario creado, también tendrás que crear uno agregando la línea RUN necesaria, por ejemplo, adduser, y luego esta línea USER para cambiar a ese UID. Asegúrate de establecer la propiedad y los permisos de los archivos de tu imagen según sea necesario para que la aplicación se ejecute con este nuevo usuario. También es habitual que los ejecutables y otros archivos pertenezcan a root, pero que los usuarios puedan leerlos y ejecutarlos. Esto ayuda a impedir que un proceso malicioso o que se comporte de forma inesperada los modifique durante la ejecución.

Después de crear la imagen y actualizar el pod, todo se ve mucho mejor.

$ kubectl exec -it goof-6bf7cfb886-qxb6w -- id
uid=1000(node) gid=1000(node) groups=1000(node)

5. Aplica controles para el usuario root durante la implementación

Ahora que la imagen está configurada para ejecutarse como un usuario sin privilegios de root, asegurémonos de que siga así modificando el manifiesto de implementación de Kubernetes para exigirlo. Primero, voy a ejecutar nuestro archivo goof-deployment.yaml con el escáner de Snyk IaC.

$ snyk iac test manifests/goof-deployment.yaml 

Testing manifests/goof-deployment.yaml...

Infrastructure as code issues:
  ✗ Container is running with default set of capabilities [Medium Severity] [SNYK-CC-K8S-6] in Deployment
    introduced by input > spec > template > spec > containers[goof] > securityContext > capabilities > drop

  ✗ Container is running without root user control [Medium Severity] [SNYK-CC-K8S-10] in Deployment
    introduced by input > spec > template > spec > containers[goof] > securityContext > runAsNonRoot
...

Como puedes ver, hay varios problemas y, efectivamente, uno de gravedad media es que el contenedor se ejecuta sin controles para el usuario root. Corregirlo es sencillo: solo agrega runAsNonRoot: true a securityContext de pod:spec o de container:spec. Prefiero hacerlo en el nivel del pod, ya que se aplicará a todos los contenedores del pod, a menos que lo anulen explícitamente. Si se agrega otro contenedor a este pod, la configuración también se aplicará automáticamente.

...
 template:
   spec:
     securityContext:
       runAsNonRoot: true
     containers:
       - name: goof
...

Ahora estamos protegidos frente a quien intente revertir la imagen para que vuelva a ejecutarse como usuario root, porque la implementación fallará al probarla. En el repositorio de ejemplo, el archivo manifests/good-deployment.yaml-nonroot ya incluye este cambio.

6. Especifica un usuario durante la ejecución (cuando corresponda)

Quizás quienes tengan buena vista hayan notado que la segunda implementación, goof-mongo, ya incluye securityContext y también especifica un UID.

...
  securityContext:
    runAsNonRoot: true
    runAsUser: 999
...

Agregamos el campo runAsUser porque usamos la imagen base oficial mongo de Docker Hub sin modificar, así que no tenemos otro Dockerfile donde configuraríamos esa línea USER. Confiamos en que el UID de la imagen oficial mongo no cambiará. Para la imagen de nuestra aplicación goof, como podemos controlar ese UID desde el Dockerfile, no tiene sentido volver a declararlo en el manifiesto de implementación; hacerlo infringiría el principio de software DRY.

Cabe señalar que la documentación de la imagen de mongo en Docker Hub no menciona a este usuario ni su UID (al momento de escribir este artículo). Para averiguarlo, ejecuté docker run --rm -it mongo id y confirmé que, efectivamente, se ejecutaba como root. Luego ejecuté docker run --rm -it --entrypoint cat mongo /etc/passwd y vi mongodb:x:999:999 al final del archivo. Las pruebas preliminares mostraron que ejecutar la imagen como ese usuario funciona, pero en una situación real convendría investigar más antes de usar un cambio que no está documentado.

7. Elimina las capacidades que tu aplicación no necesite

Mientras revisamos ese análisis de IaC, el otro problema de gravedad media que se reportó fue que el contenedor se ejecuta con el conjunto de capacidades predeterminado. Nuestra aplicación es sencilla: no necesita llamar a funciones del kernel, como cambiar la propiedad de archivos, ni controlar aspectos de la red del host. Podemos reforzar la seguridad indicándole al entorno de ejecución del contenedor que elimine todas estas capacidades. Es otro ejemplo de cómo dificultar aún más las cosas para cualquier agente malicioso que logre entrar en nuestro contenedor. Para hacerlo, simplemente agregamos un bloque a la especificación del contenedor para eliminar todas las capacidades.

...
  containers:
    - name: goof
      image: goof:latest
      securityContext:
        capabilities:
          drop:
            - all
...

Aplica el nuevo manifiesto y vuelve a probar la aplicación. En el repositorio de ejemplo, el archivo manifests/good-deployment.yaml-nonroot-dropcapabilities ya incluye estos cambios.

De nuevo, verás que la implementación goof-mongo también incluye este cambio, ya que la base de datos tampoco necesita esas capacidades.

La configuración de securityContext de Kubernetes puede ser complicada. Para obtener más información, consulta nuestra hoja de referencia: 10 configuraciones de contexto de seguridad de Kubernetes que debes conocer.

Ten en cuenta que el análisis de Snyk IaC encontró varios otros problemas de gravedad baja, que no se muestran arriba y que deberían corregirse, pero están fuera del alcance de este artículo.

En este punto, voy a confirmar los cambios y subirlos a GitHub. Ya corregí el problema de compilación y no quiero acumular más cambios en una sola confirmación. Como Snyk ya está monitoreando mi repositorio de GitHub, al abrir un pull request de mi rama a main, se analiza rápidamente si los cambios que hice en el Dockerfile introdujeron vulnerabilidades. En este caso, mis cambios no agregaron vulnerabilidades de seguridad ni problemas de licencias, así que las pruebas indican que se aprobaron.

pull request de GitHub que muestra que todas las verificaciones se aprobaron, que no hay conflictos de fusión y un botón verde «Merge pull request»

Ahora combinaremos estos cambios, pero esto nos lleva al siguiente tema para proteger nuestra imagen: el análisis de vulnerabilidades.

8. Usa un escáner de vulnerabilidades de imágenes

Ahora que ya resolvimos los problemas de seguridad de configuración pendientes, centrémonos en la parte de la imagen que no controlamos directamente: los paquetes que heredamos de la propia imagen base o que quizá agreguemos. Para saber qué posibles vulnerabilidades de explotación existen en nuestra imagen, ejecutaremos un escáner de vulnerabilidades que buscará CVE conocidas en los paquetes instalados.

Un análisis de Snyk Container detecta 825 problemas en esta imagen, con distintos niveles de gravedad.

$ snyk container test goof: --file=Dockerfile
...
Tested 412 dependencies for known issues, found 825 issues.

Base Image   Vulnerabilities  Severity
node:14.1.0  825              189 high, 178 medium, 458 low

Recommendations for base image upgrade:

Minor upgrades
Base Image  Vulnerabilities  Severity
node:14.15  571              63 high, 64 medium, 444 low

Major upgrades
Base Image  Vulnerabilities  Severity
node:15     562              58 high, 61 medium, 443 low

Alternative image types
Base Image                 Vulnerabilities  Severity
node:current-buster-slim   58               10 high, 6 medium, 42 low
node:fermium-stretch-slim  75               18 high, 9 medium, 48 low
node:15.10-slim            75               18 high, 9 medium, 48 low
node:14.16.0-buster        325              35 high, 49 medium, 241 low

El informe muestra que todos provienen de la imagen base node:14.1.0 sobre la que estamos construyendo. Tiene sentido, ya que no instalamos paquetes adicionales con apt-get ni con métodos similares en nuestro Dockerfile.

Cuando hay tantos problemas, suele ser más fácil priorizarlos en la consola web de Snyk. Como mi repositorio ya está siendo monitoreado, revisemos el análisis que se activó al combinar el pull request que acabamos de completar.

Lista de vulnerabilidades que muestra problemas de alta gravedad en Node.js relacionados con el contrabando de solicitudes HTTP, la verificación insuficiente de nombres de host y la corrupción de memoria, con sus puntuaciones.

Aquí vemos una tabla priorizada de las vulnerabilidades encontradas, ponderadas según métricas como la puntuación CVSS, la madurez de explotación y la disponibilidad actual de correcciones. Además, tanto los informes de la CLI como los de la web recomiendan imágenes base alternativas con menos vulnerabilidades conocidas.

Tabla que compara las actualizaciones de imágenes base de Docker, la cantidad de vulnerabilidades, las clasificaciones de gravedad y las opciones para «Abrir un PR de corrección».

En este caso, actualizar nuestra imagen base a la opción node:fermium-buster-slim parece ser la alternativa más adecuada y compatible. Ten en cuenta que las imágenes con la etiqueta “slim” tienen menos componentes, así que prueba la compatibilidad si tu aplicación usa paquetes que no estén incluidos.

Podríamos hacer este cambio en nuestro código, pero la interfaz web también ofrece una corrección automatizada mediante el botón “Abrir un PR de corrección” junto a cada recomendación. Usemos esa opción.

Página para abrir un PR de corrección para ericsmalling/goof (main):Dockerfile, con una opción para abrir un pull request de actualizaciones y parches.

Aparecerá una página con un mensaje de confirmación que detalla el cambio que se realizará. Luego, irás directamente a la página del pull request en GitHub.

pull request de GitHub de Snyk que muestra una actualización de seguridad de una imagen base de Docker, una tabla de vulnerabilidades, verificaciones aprobadas y la opción de combinar.

En una situación real, este pull request pasaría por los mismos procesos de revisión de código y pruebas de aplicaciones que cualquier otro cambio. Por ahora, vamos a combinarlo.

Al descargar estos cambios a mi repositorio local de git, reconstruir y analizar mi imagen, vemos que la cantidad de vulnerabilidades bajó a 58. Además, al cambiar a la imagen base “slim”, más pequeña, el tamaño total pasó de 1.03 GB a solo 261 MB.

REPOSITORY   TAG                   IMAGE ID       CREATED             SIZE
goof         old                   f2f1ca19cdc9   48 minutes ago      1.03GB
goof         slim                  865cee656a5f   8 seconds ago       261MB
node         fermium-buster-slim   d1bc1f4d4c5d   4 days ago          180MB
node         14.1.0                a511eb5c14ec   10 months ago       941MB

9. Considera reducir aún más el tamaño de la imagen

Un principio fundamental del paradigma de los contenedores es minimizar el entorno en el que se ejecuta un proceso. Hemos avanzado mucho para reducir el tamaño y proteger nuestra imagen, pero seguimos incluyendo mucho más de lo que necesitamos para implementarla en producción. Podríamos empezar a revisar la imagen para eliminar todos los archivos y paquetes innecesarios que incluye la distribución base de Debian, pero hay alternativas más sencillas… con algunas concesiones.

Imágenes basadas en Alpine

Una de las soluciones más sencillas es elegir otro sistema operativo base centrado en la seguridad y el tamaño reducido: Alpine. Las imágenes basadas en Alpine son conocidas por ocupar poco espacio, principalmente porque tienen muy pocos paquetes preinstalados. Esto es positivo desde el punto de vista de la seguridad, ya que los archivos que no existen no se pueden explotar. Reconfiguremos nuestro Dockerfile para usar la imagen base node:14-alpine.

FROM node:14-alpine

USER node
RUN mkdir /home/node/goof
RUN mkdir /tmp/extracted_files
COPY . /home/node/goof
WORKDIR /home/node/goof

RUN npm ci --only=production
EXPOSE 3001
EXPOSE 9229
ENTRYPOINT ["npm", "start"]

Como puedes ver, cambié el orden de algunos elementos y moví la aplicación a otro directorio para adaptarla a las convenciones de la imagen base de Alpine. Como era de esperar, esta versión redujo drásticamente el tamaño de la imagen.

$ docker images
REPOSITORY   TAG                   IMAGE ID       CREATED          SIZE
goof         alpine                3aa775448685   11 minutes ago   197MB
goof         slim                  8b4795653de3   3 hours ago      261MB

Veamos qué resultados arroja el análisis de Snyk Container para nuestra nueva imagen:

$ snyk container test goof:alpine --file=Dockerfile

Testing goof:alpine...

Organization:      eric-smalling-snyk
Package manager:   apk
Target file:       Dockerfile
Project name:      docker-image|goof
Docker image:      goof:alpine
Platform:          linux/amd64
Base image:        node:14-alpine
Licenses:          enabled

✓ Tested 16 dependencies for known issues, no vulnerable paths found.

According to our scan, you are currently using the most secure version of the selected base image

¡No se encontraron rutas vulnerables! ¡No se puede pedir más!

Entonces, ¿por qué no usar siempre imágenes base de Alpine? Como se indica en la documentación de la imagen de Node:

La principal salvedad es que usa musl libc en lugar de glibc y otras bibliotecas relacionadas, por lo que el software suele tener problemas según el grado de dependencia o las suposiciones que haga sobre libc. Consulta este hilo de comentarios de Hacker News para obtener más información sobre los posibles problemas y comparar las ventajas y desventajas de usar imágenes basadas en Alpine.

En muchos casos, esto no representa ningún problema, especialmente en plataformas como Node, que se compilan de forma cruzada para Alpine. Sin embargo, a veces el cambio a un conjunto completamente distinto de bibliotecas C de bajo nivel puede hacer que las aplicaciones dejen de funcionar. Por este motivo, es posible que los escáneres de vulnerabilidades no recomienden Alpine para reducir el número de vulnerabilidades cuando analizan una imagen basada en glibc. Algunos casos habituales en los que la migración a Alpine falla son las aplicaciones heredadas que dependen de bibliotecas precompiladas, como una aplicación Java con llamadas JNI o una aplicación Go que usa cgo. En cualquier caso, si cambias a Alpine desde otra imagen basada en Linux, asegúrate de realizar pruebas funcionales y de rendimiento exhaustivas de tu aplicación, ya que, en cierto sentido, estás cambiando de sistema operativo.

Imágenes Distroless

Si Alpine no es una opción viable, existe un proyecto alojado por Google llamado Distroless, que ofrece imágenes generalmente basadas en Debian y reducidas a lo estrictamente necesario. A menudo, ni siquiera incluyen un shell en el que ejecutar comandos. El equipo de Google Distroless mantiene estas imágenes y puedes consultar los detalles en su repositorio de GitHub.

Cómo crear tus propias imágenes basadas en Scratch

La imagen base scratch no es realmente una imagen. Es un término reservado en la especificación de Dockerfile que inicia la compilación de la imagen con un sistema de archivos completamente vacío. No incluye shell, administrador de paquetes ni nada. Todo lo que quieras agregar a este tipo de imagen debes copiarlo por tu cuenta. Normalmente, verás que se usa scratch en la etapa final de un Dockerfile con compilaciones multietapa, una función que permite incluir varias líneas FROM; la última determina la base de la imagen final. El patrón multietapa más común consiste en tener una etapa de «compilación», donde se compila el código, y luego copiar el artefacto resultante a la etapa final. En el caso de una etapa final scratch, tendrías que copiar todo lo necesario para ejecutar tu programa. Por eso, no suele usarse con lenguajes interpretados o que necesitan un entorno de ejecución, como Node.js, Java o Python. Este patrón funciona bien con lenguajes que se compilan en un único archivo o en un conjunto pequeño de archivos. Las aplicaciones de C, C++ y Go suelen usar imágenes scratch.

10. Investiga los generadores de imágenes que no usan Dockerfile

A lo largo de este documento, nos centramos en el proceso de compilación de imágenes basado en Dockerfile, que es, con diferencia, la herramienta más utilizada para crear imágenes. Sin embargo, existen otras maneras de crear imágenes sin usar Dockerfile. Algunas de las más populares son Bazel, jib y los scripts de Buildah. Docker también admite otras técnicas de creación mediante scripts a través del proyecto BuildKit, un generador opcional incluido con el cliente de Docker.

Conclusiones y enlaces para seguir leyendo

El ejemplo que vimos es bastante sencillo, dado que JavaScript es un lenguaje interpretado. Sin embargo, si trabajas con lenguajes cuyas etapas de compilación y ejecución están más claramente diferenciadas, consulta los Dockerfiles multietapa mencionados anteriormente. Te permiten seguir compilando en tu Dockerfile y, a la vez, dejar el compilador y el código fuente fuera de la imagen final que implementas.

Para obtener más información sobre cómo ejecutar Node.js en contenedores, consulta 10 prácticas recomendadas para contenerizar aplicaciones web de Node.js con Docker. También tenemos una versión de ese documento para aplicaciones Java: 10 prácticas recomendadas para crear un contenedor Java con Docker.

Seguridad de contenedores centrada en los desarrolladores

Snyk encuentra y corrige automáticamente vulnerabilidades en imágenes de contenedores y cargas de trabajo de Kubernetes.

Si usas las herramientas de contenedores alternativas de Red Hat, consulta nuestra publicación del blog sobre Herramientas de línea de comandos para contenedores: cómo usar Snyk con Buildah, Podman y Skopeo.

Por último, como mencionamos antes, las API de contexto de seguridad de Kubernetes pueden ser difíciles de configurar. El artículo 10 configuraciones de contexto de seguridad de Kubernetes que debes conocer las explica y te ayudará a tomar decisiones seguras que funcionen para tu aplicación. Una opción muy útil disponible en estas configuraciones es forzar la ejecución del contenedor en modo de solo lectura. Esto puede ofrecer una excelente protección contra modificaciones maliciosas en el sistema de archivos del contenedor. Obtén más información en esta hoja de referencia.

¿Quieres profundizar en las imágenes de Docker? Adam Gordon Bell escribió un excelente artículo que analiza en detalle cómo se construyen las imágenes. Además, durante el segundo seminario web de Docker Community All-Hands, algunos Docker Captains ofrecieron excelentes charlas sobre imágenes de Docker:

Esperamos que esta guía te haya ayudado a comprender mejor cómo funcionan las imágenes de contenedores y cómo puedes usar herramientas para proteger y mantener tus aplicaciones en contenedores.