Tutorial de NGINX: cómo gestionar secretos de forma segura en contenedores

NGINX | 14 de marzo de 2023

Esta publicación forma parte de una serie de cuatro tutoriales que le ayudarán a poner en práctica los conceptos de Marzo de los microservicios de 2023: empiece a implementar microservicios:

Muchos de tus microservicios necesitan secretos para funcionar de forma segura. Algunos ejemplos de secretos son la clave privada de un certificado SSL/TLS, una clave de API para autenticarse en otro servicio o una clave SSH para iniciar sesión de forma remota. Una gestión adecuada de los secretos exige limitar estrictamente los contextos en los que se utilizan únicamente a aquellos donde son necesarios e impedir que se acceda a ellos salvo cuando sea preciso. Sin embargo, esta práctica suele pasarse por alto debido a las prisas durante el desarrollo de aplicaciones. ¿El resultado? Una gestión inadecuada de los secretos es una causa habitual de filtraciones de información y vulnerabilidades explotables.

Descripción general del tutorial

En este tutorial, mostramos cómo distribuir y utilizar de forma segura un token web JSON (JWT) que un contenedor cliente utiliza para acceder a un servicio. En los cuatro retos de este tutorial, experimentarás con cuatro métodos diferentes para gestionar secretos, con el fin de aprender no solo a gestionarlos correctamente en tus contenedores, sino también a identificar los métodos que no son adecuados:

Aunque este tutorial utiliza un JWT como ejemplo de secreto, estas técnicas se pueden aplicar a cualquier elemento de los contenedores que necesites mantener en secreto, como credenciales de bases de datos, claves privadas SSL y otras claves de API.

El tutorial utiliza dos componentes de software principales:

  • Servidor de API – Un contenedor que ejecuta NGINX Open Source y código básico de NGINX JavaScript que extrae una declaración del JWT y devuelve el valor de una de las declaraciones o, si no hay ninguna, un mensaje de error
  • Cliente de API – Un contenedor que ejecuta un código Python muy sencillo que simplemente realiza una solicitud GET al servidor de API

Mira este vídeo para ver una demostración práctica del tutorial.

La forma más sencilla de seguir este tutorial es inscribirse en Microservices March y utilizar el laboratorio proporcionado, al que se accede desde el navegador. En esta publicación se explica cómo ejecutar el tutorial en su propio entorno.

Requisitos previos y configuración

Requisitos previos

Para completar el tutorial en tu propio entorno, necesitas:

  • Un entorno compatible con Linux/Unix
  • Conocimientos básicos de la línea de comandos de Linux
  • Un editor de texto como nano o vim
  • Docker (incluidos Docker Compose y Docker Engine Swarm)
  • curl (ya está instalado en la mayoría de los sistemas)
  • git (ya está instalado en la mayoría de los sistemas)

Notas:

  • El tutorial utiliza un servidor de prueba que escucha en el puerto 80. Si ya estás utilizando el puerto 80, usa la opción ‑p para asignar un valor diferente al servidor de prueba cuando lo inicies con el comando docker run. A continuación, añade el sufijo :<port_number> a localhost en los comandos curl.
  • A lo largo del tutorial se omite el prompt de la línea de comandos de Linux para facilitar que pueda copiar y pegar los comandos en su terminal. La virgulilla (~) representa su directorio personal.

Configuración

En esta sección, clonará el repositorio del tutorial, iniciará el servidor de autenticación y enviará solicitudes de prueba con y sin token.

Clona el repositorio del tutorial

  1. En tu directorio personal, crea el directorio microservices-march y clona en él el repositorio de GitHub. (También puedes usar otro nombre de directorio y adaptar las instrucciones según corresponda). El repositorio incluye archivos de configuración y versiones independientes de la aplicación cliente de la API que utilizan distintos métodos para obtener secretos.
mkdir ~/microservices-marchcd ~/microservices-march git clone https://github.com/microservices-march/auth.git
  1. Muestra el secreto. Es un JWT firmado que se utiliza habitualmente para autenticar clientes de API ante servidores.
cat ~/microservices-march/auth/apiclient/token1.jwt"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2Nz UyMDA4MTMsImlzcyI6ImFwaUtleTEiLCJhdWQiOiJhcGlTZXJ2aWNlIiwic3ViIjoiYXBpS2V5MSJ9._6L_Ff29p9AWHLLZ-jEZdihy-H1glooSq_z162VKghA"

Aunque hay varias formas de usar este token para la autenticación, en este tutorial la aplicación cliente de la API lo envía al servidor de autenticación mediante el marco de autorización con tokens de portador de OAuth 2.0. Para ello, se antepone al JWT Authorization: Bearer como en este ejemplo:

"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2NzUyMDA4MTMsImlzcyI6ImFwaUtleTEiLCJhdWQiOiJhcGlTZXJ2aWNlIiwic3ViIjoiYXBpS2V5MSJ9._6L_Ff29p9AWHLLZ-jEZdihy-H1glooSq_z162VKghA"

Compilar e iniciar el servidor de autenticación

  1. Cambia al directorio del servidor de autenticación:
cd apiserver
  1. Crea la imagen de Docker para el servidor de autenticación (ten en cuenta el punto final):
docker build -t apiserver .
  1. Inicia el servidor de autenticación y confirma que está en ejecución (la salida se muestra en varias líneas para facilitar su lectura):
docker run -d -p 80:80 apiserver docker ps
CONTAINER ID   IMAGE       COMMAND                  ...
2b001f77c5cb   apiserver   "nginx -g 'daemon of..." ...  


    ... CREATED         STATUS          ...                                    
    ... 26 seconds ago  Up 26 seconds   ... 


    ... PORTS                                      ...
    ... 0.0.0.0:80->80/tcp, :::80->80/tcp, 443/tcp ...


    ... NAMES
    ... relaxed_proskuriakova

Prueba el servidor de autenticación

  1. Comprueba que el servidor de autenticación rechaza una solicitud que no incluye el JWT y devuelve 401 Authorization Required:
curl -X GET http://localhost<html>
<head><title>401 Authorization Required</title></head>
<body>
<center><h1>401 Authorization Required</h1></center>
<hr><center>nginx/1.23.3</center>
</body>
</html>
  1. Proporciona el JWT mediante la cabecera Authorization. El código de respuesta 200 OK indica que la aplicación cliente de la API se ha autenticado correctamente.
curl -i -X GET -H "Authorization: Bearer `cat $HOME/microservices-march/auth/apiclient/token1.jwt`" http://localhostHTTP/1.1 200 OK
Server: nginx/1.23.2
Date: Day, DD Mon YYYY hh:mm:ss TZ
Content-Type: text/html
Content-Length: 64
Last-Modified: Day, DD Mon YYYY hh:mm:ss TZ
Connection: keep-alive
ETag: "63dc0fcd-40"
X-MESSAGE: Success apiKey1
Accept-Ranges: bytes


{ "response": "success", "authorized": true, "value": "999" }

Reto 1: Codifica secretos directamente en tu aplicación (¡no lo hagas!)

Antes de comenzar este reto, dejemos una cosa clara: incluir secretos directamente en el código de tu aplicación es una idea pésima. Verás que cualquiera que tenga acceso a la imagen del contenedor puede encontrar y extraer fácilmente las credenciales incluidas en el código.

En este reto, copiarás el código de la aplicación cliente de la API en el directorio de compilación, compilarás y ejecutarás la aplicación, y extraerás el secreto.

Copiar la aplicación cliente de la API

El subdirectorio app_versions del directorio apiclient contiene distintas versiones de la aplicación cliente de API sencilla para los cuatro desafíos, cada una ligeramente más segura que la anterior (consulte Descripción general del tutorial para obtener más información).

  1. Cambie al directorio del cliente de la API:
cd ~/microservices-march/auth/apiclient
  1. Copia la aplicación de este reto —la que tiene un secreto codificado de forma fija— en el directorio de trabajo:
cp ./app_versions/very_bad_hard_code.py ./app.py
  1. Echa un vistazo a la aplicación:
cat app.pyimport urllib.request
import urllib.error

jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2NzUyMDA4MTMsImlzcyI6ImFwaUtleTEiLCJhdWQiOiJhcGlTZXJ2aWNlIiwic3ViIjoiYXBpS2V5MSJ9._6L_Ff29p9AWHLLZ-jEZdihy-H1glooSq_z162VKghA"
authstring = "Bearer " + jwt
req = urllib.request.Request("http://host.docker.internal")
req.add_header("Authorization", authstring)
try:
    with urllib.request.urlopen(req) as response:
        the_page = response.read()
        message = response.getheader("X-MESSAGE")
        print("200  " + message)
except urllib.error.URLError as e:
    print(str(e.code) + " s " + e.msg)
  1. El código simplemente realiza una solicitud a un host local y muestra un mensaje de éxito o un código de error. La solicitud añade la cabecera «Authorization» en esta línea:
req.add_header("Authorization", authstring)
  1. ¿Notas algo más? ¿Quizás un JWT codificado de forma fija? Volveremos a ello en un momento. Primero, vamos a compilar y ejecutar la aplicación.

Compilar y ejecutar la aplicación cliente de la API

Estamos utilizando el comando docker compose junto con un archivo YAML de Docker Compose, lo que facilita un poco la comprensión de lo que está ocurriendo.

(Ten en cuenta que, en el paso 2 de la sección anterior, cambiaste el nombre del archivo de Python de la aplicación cliente de la API específico del reto 1 (very_bad_hard_code.py) a app.py. También harás lo mismo en los otros tres retos. Usar app.py cada vez simplifica la gestión, ya que no es necesario modificar el Dockerfile. Sin embargo, esto implica que debes incluir el argumento ‑build en el comando docker compose para forzar que el contenedor se vuelva a compilar cada vez).

El comando docker compose crea el contenedor, inicia la aplicación, realiza una única solicitud a la API y, a continuación, detiene el contenedor mientras muestra en la consola los resultados de la llamada a la API.

El código 200 Success de la penúltima línea de la salida indica que la autenticación se ha realizado correctamente. El valor apiKey1 lo confirma, ya que demuestra que el servidor de autenticación ha podido descodificar la declaración con ese nombre en el JWT:

docker compose -f docker-compose.hardcode.yml up -build
...
apiclient-apiclient-1  | 200 Success apiKey1
apiclient-apiclient-1 exited with code 0

Así que las credenciales codificadas directamente funcionaron correctamente en nuestra aplicación cliente de la API, lo cual no es de extrañar. Pero ¿es seguro? Puede que sí, ya que el contenedor solo ejecuta este script una vez antes de finalizar y no dispone de un intérprete de comandos.

De hecho, no: no es seguro en absoluto.

Recuperar el secreto de la imagen del contenedor

Incluir credenciales directamente en el código permite que cualquiera que pueda acceder a la imagen del contenedor las inspeccione, ya que extraer el sistema de archivos de un contenedor es una tarea trivial.

  1. Cree el directorio extract y acceda a él:
mkdir extractcd extract
  1. Muestra información básica sobre las imágenes de contenedor. La opción «--format» hace que la salida sea más legible (y, por el mismo motivo, aquí se muestra dividida en dos líneas):
docker ps -a --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"CONTAINER ID   NAMES                   IMAGE       ...
11b73106fdf8   apiclient-apiclient-1   apiclient   ...
ad9bdc05b07c   exciting_clarke         apiserver   ...


    ... CREATED          STATUS
    ... 6 minutes ago    Exited (0) 4 minutes ago
    ... 43 minutes ago   Up 43 minutes
  1. Extraiga la imagen más reciente de apiclient como un archivo .tar. En <container_ID>, sustituya el valor del campo CONTAINER ID de la salida anterior (11b73106fdf8 en este tutorial):
docker export -o api.tar <container_ID>
  1. Se tarda unos segundos en crear el archivo api.tar, que incluye todo el sistema de archivos del contenedor. Una forma de buscar secretos consiste en extraer el archivo completo y analizarlo, pero resulta que hay un atajo para encontrar lo que probablemente sea de interés: mostrar el historial del contenedor con el comando docker history. (Este atajo resulta especialmente útil porque también funciona con los contenedores que se encuentran en Docker Hub o en otro registro de contenedores y de los que, por tanto, puede que no se disponga del archivo Dockerfile, sino únicamente de la imagen del contenedor).
  2. Muestra el historial del contenedor:
docker history apiclient
IMAGE         CREATED        ...
9396dde2aad0  8 minutes ago  ...                    
<missing>     8 minutes ago  ...   
<missing>     28 minutes ago ...  
               
    ... CREATED BY                          SIZE ... 
    ... CMD ["python" "./app.py"]           622B ...   
    ... COPY ./app.py ./app.py # buildkit   0B   ... 
    ... WORKDIR /usr/app/src                0B   ...   
             
    ... COMMENT
    ... buildkit.dockerfile.v0
    ... buildkit.dockerfile.v0
    ... buildkit.dockerfile.v0
  1. Las líneas de salida aparecen en orden cronológico inverso. Muestran que el directorio de trabajo se estableció en /usr/app/src, tras lo cual se copió y ejecutó el archivo de código Python de la aplicación. No hace falta ser un gran detective para deducir que el código principal de este contenedor se encuentra en /usr/app/src/app.py, por lo que es probable que allí haya credenciales.
  2. Con esa información, extrae únicamente ese archivo:
tar --extract --file=api.tar usr/app/src/app.py
  1. Muestra el contenido del archivo y, así de fácil, habremos obtenido acceso al JWT «seguro»:
cat usr/app/src/app.py
...
jwt="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2NzUyMDA4MTMsImlzcyI6ImFwaUtleTEiLCJhdWQiOiJhcGlTZXJ2aWNlIiwic3ViIjoiYXBpS2V5MSJ9._6L_Ff29p9AWHLLZ-jEZdihy-H1glooSq_z162VKghA"
...

Reto 2: Pasar secretos como variables de entorno (de nuevo, ¡no!)

Si completaste la Unidad 1 de Microservices March 2023 (Aplicar la metodología de aplicaciones de doce factores a las arquitecturas de microservicios), ya sabrás cómo usar variables de entorno para pasar datos de configuración a los contenedores. Si te la perdiste, no te preocupes: está disponible bajo demanda tras registrarte.

En este reto, se pasan secretos como variables de entorno. Al igual que el método del reto 1, ¡no recomendamos usarlo! No es tan malo como incluir los secretos directamente en el código, pero, como verás, presenta algunas debilidades.

Hay cuatro formas de pasar variables de entorno a un contenedor:

  • Usa la instrucción ENV en un archivo Dockerfile para sustituir variables (establece la variable para todas las imágenes creadas). Por ejemplo:
ENV PORT $PORT
  • Usa la opción ‑e en el comando docker run. Por ejemplo:
docker run -e PASSWORD=123 mycontainer
  • Usa la clave environment en un archivo YAML de Docker Compose.
  • Usa un archivo .env que contenga las variables.

En este reto, usarás una variable de entorno para configurar el JWT y examinarás el contenedor para comprobar si el JWT queda expuesto.

Pasar una variable de entorno

  1. Vuelve al directorio del cliente de la API:
cd ~/microservices-march/auth/apiclient
  1. Copia la aplicación de este reto —la que utiliza variables de entorno— en el directorio de trabajo, sobrescribiendo el archivo app.py del reto 1:
cp ./app_versions/medium_environment_variables.py ./app.py
  1. Echa un vistazo a la aplicación. En las líneas de salida pertinentes, el secreto (JWT) se lee como una variable de entorno en el contenedor local:
cat app.py...
jwt = ""
if "JWT" in os.environ:
    jwt = "Bearer " + os.environ.get("JWT")
...
  1. Como se ha explicado anteriormente, hay varias formas de introducir la variable de entorno en el contenedor. Para mantener la coherencia, seguiremos utilizando Docker Compose. Muestra el contenido del archivo YAML de Docker Compose, que utiliza la clave «environment» para establecer la variable de entorno «JWT»:
cat docker-compose.env.yml---
version: "3.9"
services:
  apiclient:
    build: .
    image: apiclient
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      - JWT
  1. Ejecuta la aplicación sin configurar la variable de entorno. El código 401 Unauthorized de la penúltima línea de la salida confirma que la autenticación falló porque la aplicación cliente de la API no proporcionó el JWT:
docker compose -f docker-compose.env.yml up -build...
apiclient-apiclient-1  | 401 Unauthorized
apiclient-apiclient-1 exited with code 0
  1. Para simplificar, configura la variable de entorno localmente. No hay problema en hacerlo en este punto del tutorial, ya que ahora mismo no es el problema de seguridad que nos ocupa:
export JWT=`cat token1.jwt`
  1. Vuelve a ejecutar el contenedor. Ahora la prueba se completa correctamente, con el mismo mensaje que en el Reto 1:
docker compose -f docker-compose.env.yml up -build
... 
apiclient-apiclient-1  | 200 Success apiKey1
apiclient-apiclient-1 exited with code 0

Así que, al menos ahora, la imagen base no contiene el secreto y podemos proporcionarlo en tiempo de ejecución, lo cual es más seguro. Pero sigue habiendo un problema.

Examina el contenedor

  1. Muestra información sobre las imágenes de contenedor para obtener el ID del contenedor de la aplicación cliente de la API (la salida se distribuye en dos líneas para facilitar su lectura):
docker ps -a --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"CONTAINER ID   NAMES                   IMAGE      ...
6b20c75830df   apiclient-apiclient-1   apiclient  ...
ad9bdc05b07c   exciting_clarke         apiserver  ...


    ... CREATED             STATUS
    ... 6 minutes ago       Exited (0) 6 minutes ago
    ... About an hour ago   Up About an hour
  1. Inspecciona el contenedor de la aplicación cliente de la API. En lugar de <container_ID>, introduce el valor del campo CONTAINER ID de la salida anterior (en este caso, 6b20c75830df). El comando docker inspect permite inspeccionar todos los contenedores iniciados, tanto si están en ejecución como si no. Y ese es el problema: aunque el contenedor no esté en ejecución, la salida expone el JWT en la matriz Env, guardado de forma no segura en la configuración del contenedor.
docker inspect <container_ID>...
"Env": [
  "JWT=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2NzUyMDA...",
  "PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  "LANG=C.UTF-8",
  "GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D",
  "PYTHON_VERSION=3.11.2",
  "PYTHON_PIP_VERSION=22.3.1",
  "PYTHON_SETUPTOOLS_VERSION=65.5.1",
  "PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/1a96dc5acd0303c4700e026...",
  "PYTHON_GET_PIP_SHA256=d1d09b0f9e745610657a528689ba3ea44a73bd19c60f4c954271b790c..."
]

Desafío 3: Usa secretos locales

A estas alturas, ya has aprendido que incluir secretos directamente en el código y usar variables de entorno no es tan seguro como tú (o tu equipo de seguridad) necesitáis.

Para mejorar la seguridad, puedes probar a usar secretos locales de Docker para almacenar información confidencial. Una vez más, este no es el método de referencia, pero conviene entender cómo funciona. Aunque no uses Docker en producción, lo importante es aprender cómo dificultar la extracción del secreto de un contenedor.

En Docker, los secretos se ponen a disposición de un contenedor mediante el punto de montaje del sistema de archivos /run/secrets/ donde hay un archivo independiente que contiene el valor de cada secreto.

En este reto, pasarás al contenedor un secreto almacenado localmente mediante Docker Compose y, a continuación, comprobarás que el secreto no está visible en el contenedor cuando se utiliza este método.

Pasar un secreto almacenado localmente al contenedor

  1. Como ya cabría esperar, primero debes cambiar al directorio apiclient:
cd ~/microservices-march/auth/apiclient
  1. Copia la aplicación de este reto —la que utiliza secretos desde dentro de un contenedor— en el directorio de trabajo, sobrescribiendo el archivo app.py del reto 2:
cp ./app_versions/better_secrets.py ./app.py
  1. Echa un vistazo al código Python, que lee el valor JWT del archivo /run/secrets/jot. (Y sí, probablemente deberíamos comprobar que el archivo solo tenga una línea. ¿Quizá en Microservices March 2024?)
cat app.py...
jotfile = "/run/secrets/jot"
jwt = ""
if os.path.isfile(jotfile):
    with open(jotfile) as jwtfile:
        for line in jwtfile:
            jwt = "Bearer " + line
...
  1. Bien, ¿cómo vamos a crear este secreto? La respuesta está en el archivo docker-compose.secrets.yml.
  2. Echa un vistazo al archivo de Docker Compose, donde el archivo secreto se define en la sección secrets y, a continuación, el servicio apiclient hace referencia a él:
cat docker-compose.secrets.yml---
version: "3.9"
secrets:
  jot:
    file: token1.jwt
services:
  apiclient:
    build: .
    extra_hosts:
      - "host.docker.internal:host-gateway"
    secrets:
      - jot

Verifica que el secreto no sea visible en el contenedor

  1. Ejecuta la aplicación. Como hemos hecho que el JWT sea accesible dentro del contenedor, la autenticación se realiza correctamente y aparece el mensaje que ya conocemos:
docker compose -f docker-compose.secrets.yml up -build...
apiclient-apiclient-1  | 200 Success apiKey1
apiclient-apiclient-1 exited with code 0
  1. Muestra información sobre las imágenes de contenedor y anota el ID del contenedor de la aplicación cliente de la API (para ver un ejemplo de salida, consulta el paso 1 de Examinar el contenedor del reto 2):
docker ps -a --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"
  1. Inspeccione el contenedor de la aplicación cliente de la API. En lugar de <container_ID>, introduzca el valor del campo CONTAINER ID que aparece en la salida del paso anterior. A diferencia de la salida del paso 2 de Examinar el contenedor, no hay ninguna línea JWT= al principio de la sección Env:
docker inspect <container_ID>
"Env": [
  "PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  "LANG=C.UTF-8",
  "GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D",
  "PYTHON_VERSION=3.11.2",
  "PYTHON_PIP_VERSION=22.3.1",
  "PYTHON_SETUPTOOLS_VERSION=65.5.1",
  "PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/1a96dc5acd0303c4700e026...",
  "PYTHON_GET_PIP_SHA256=d1d09b0f9e745610657a528689ba3ea44a73bd19c60f4c954271b790c..."
]
  1. Hasta aquí, todo bien, pero nuestro secreto se encuentra en el sistema de archivos del contenedor, en /run/secrets/jot. Quizá podamos extraerlo de ahí utilizando el mismo método que en «Recuperar el secreto de la imagen del contenedor» del reto 1.
  2. Ve al directorio extract (que creaste durante el reto 1) y exporta el contenedor a un archivo tar:
cd extractdocker export -o api2.tar <container_ID>
  1. Busca el archivo de secretos en el archivo tar:
tar tvf api2.tar | grep jot-rwxr-xr-x  0 0      0           0 Mon DD hh:mm run/secrets/jot
  1. Vaya, el archivo que contiene el JWT está visible. ¿No habíamos dicho que incluir secretos en el contenedor era «seguro»? ¿Está la cosa tan mal como en el Reto 1?
  2. Veamos: extraigamos el archivo de secretos del archivo tar y examinemos su contenido:
tar --extract --file=api2.tar run/secrets/jotcat run/secrets/jot
  1. ¡Buenas noticias! El comando cat no genera ninguna salida, lo que significa que el archivo run/secrets/jot del sistema de archivos del contenedor está vacío. ¡No hay ningún secreto que ver ahí! Aunque haya un artefacto de secretos en nuestro contenedor, Docker es lo bastante inteligente como para no almacenar datos confidenciales en él.

Dicho esto, aunque la configuración de este contenedor es segura, tiene un inconveniente. Al ejecutar el contenedor, depende de que exista un archivo llamado token1.jwt en el sistema de archivos local. Si cambia el nombre del archivo, no se podrá reiniciar el contenedor. (Puede comprobarlo cambiando el nombre de token1.jwt —¡sin eliminarlo!— y volviendo a ejecutar el comando docker compose del paso 1).

Así que ya estamos a medio camino: el contenedor utiliza secretos de forma que evita que se vean comprometidos fácilmente, pero el secreto sigue sin estar protegido en el host. No conviene almacenar secretos sin cifrar en un archivo de texto sin formato. Es hora de incorporar una herramienta de gestión de secretos.

Desafío 4: usar un gestor de secretos

Un gestor de secretos te ayuda a gestionar, recuperar y rotar secretos a lo largo de todo su ciclo de vida. Hay muchos gestores de secretos entre los que elegir y todos cumplen una función similar:

  • Almacenar secretos de forma segura
  • Controlar el acceso
  • Distribuirlos en tiempo de ejecución
  • Habilitar la rotación de secretos

Entre las opciones para gestionar secretos se incluyen:

Para simplificar, este reto utiliza Docker Swarm, pero los principios son los mismos para muchos gestores de secretos.

En este reto, crearás un secreto en Docker, copiarás el secreto y el código del cliente de la API, desplegarás el contenedor, comprobarás si puedes extraer el secreto y rotar el secreto.

Configurar un secreto de Docker

  1. Como ya es habitual, ve al directorio apiclient:
cd ~/microservices-march/auth/apiclient
  1. Inicializa Docker Swarm:
docker swarm init
Swarm initialized: current node (t0o4eix09qpxf4ma1rrs9omrm) is now a manager.
...
  1. Crea un secreto y guárdalo en «token1.jwt»:
docker secret create jot ./token1.jwtqe26h73nhb35bak5fr5east27
  1. Muestra información sobre el secreto. Ten en cuenta que el valor del secreto (el JWT) no se muestra:
docker secret inspect jot[
  {
    "ID": "qe26h73nhb35bak5fr5east27",
    "Version": {
      "Index": 11
    },
    "CreatedAt": "YYYY-MM-DDThh:mm:ss.msZ",
    "UpdatedAt": "YYYY-MM-DDThh:mm:ss.msZ",
    "Spec": {
      "Name": "jot",
      "Labels": {}
    }
  }
]

Usar un secreto de Docker

Usar el secreto de Docker en el código de la aplicación cliente de la API es exactamente igual que usar un secreto creado localmente: puede leerlo desde el sistema de archivos /run/secrets/. Solo tiene que cambiar el calificador del secreto en el archivo YAML de Docker Compose.

  1. Echa un vistazo al archivo YAML de Docker Compose. Observa el valor true en el campo external, que indica que estamos utilizando un secreto de Docker Swarm:
cat docker-compose.secretmgr.yml---
version: "3.9"
secrets:
  jot:
    external: true
services:
  apiclient:
    build: .
    image: apiclient
    extra_hosts:
      - "host.docker.internal:host-gateway"
    secrets:
      - jot
  1. Por tanto, podemos esperar que este archivo de Compose funcione con el código existente de nuestra aplicación cliente de la API. Bueno, casi. Aunque Docker Swarm (o cualquier otra plataforma de orquestación de contenedores) aporta mucho valor añadido, también introduce cierta complejidad adicional. Dado que docker compose no funciona con secretos externos, tendremos que utilizar algunos comandos de Docker Swarm, en concreto docker stack deploy. Docker Stack oculta la salida de la consola, por lo que tenemos que escribirla en un registro y, después, examinarlo. Para facilitar las cosas, también utilizamos un bucle continuo while True para mantener el contenedor en ejecución.
  2. Copia la aplicación de este reto —la que utiliza un gestor de secretos— en el directorio de trabajo, sobrescribiendo el archivo app.py del reto 3. Al mostrar el contenido de app.py, vemos que el código es prácticamente idéntico al del reto 3. La única diferencia es que se ha añadido el bucle while True:
cp ./app_versions/best_secretmgr.py ./app.pycat ./app.py
...
while True:
    time.sleep(5)
    try:
        with urllib.request.urlopen(req) as response:
            the_page = response.read()
            message = response.getheader("X-MESSAGE")
            print("200 " + message, file=sys.stderr)
    except urllib.error.URLError as e:
        print(str(e.code) + " " + e.msg, file=sys.stderr)

Despliega el contenedor y comprueba los registros

  1. Crea el contenedor (en los retos anteriores, Docker Compose se encargaba de ello):
docker build -t apiclient .
  1. Despliega el contenedor:
docker stack deploy --compose-file docker-compose.secretmgr.yml secretstack
Creating network secretstack_default
Creating service secretstack_apiclient
  1. Enumere los contenedores en ejecución y anote el ID del contenedor secretstack_apiclient (como antes, la salida se distribuye en varias líneas para facilitar su lectura).
docker ps --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"CONTAINER ID  ...  
20d0c83a8b86  ... 
ad9bdc05b07c  ... 

    ... NAMES                                             ...  
    ... secretstack_apiclient.1.0e9s4mag5tadvxs6op6lk8vmo ...  
    ... exciting_clarke                                   ...                                 

    ... IMAGE              CREATED          STATUS
    ... apiclient:latest   31 seconds ago   Up 30 seconds
    ... apiserver          2 hours ago      Up 2 hours
  1. Muestra el archivo de registro de Docker; en lugar de <container_ID>, usa el valor del campo CONTAINER ID que aparece en la salida del paso anterior (en este caso, 20d0c83a8b86). El archivo de registro muestra una serie de mensajes de operación correcta porque añadimos el bucle while True al código de la aplicación. Pulsa Ctrl+c para salir del comando.
docker logs -f <container_ID>200 Success apiKey1
200 Success apiKey1
200 Success apiKey1
200 Success apiKey1
200 Success apiKey1
200 Success apiKey1
...
^c

Intenta acceder al secreto

Sabemos que no hay ninguna variable de entorno confidencial configurada (aunque siempre puedes comprobarlo con el comando docker inspect, como en el paso 2 de «Examinar el contenedor» del reto 2).

Por el reto 3 también sabemos que el archivo /run/secrets/jot está vacío, pero puedes comprobarlo:

cd extractdocker export -o api3.tar  tar --extract --file=api3.tar run/secrets/jot cat run/secrets/jot

¡Éxito! No puedes obtener el secreto del contenedor ni leerlo directamente desde el secreto de Docker.

Rotar el secreto

Por supuesto, con los privilegios adecuados podemos crear un servicio y configurarlo para que escriba el secreto en el registro o lo establezca como variable de entorno. Además, puede que hayas observado que la comunicación entre nuestro cliente de API y el servidor no está cifrada (se transmite en texto sin formato).

Por tanto, la filtración de secretos sigue siendo posible con casi cualquier sistema de gestión de secretos. Una forma de limitar los posibles daños resultantes es rotar (sustituir) los secretos periódicamente.

Con Docker Swarm, solo se pueden eliminar los secretos y volver a crearlos posteriormente (Kubernetes permite actualizarlos de forma dinámica). Tampoco se pueden eliminar los secretos asociados a servicios en ejecución.

  1. Enumera los servicios en ejecución:
docker service lsID             NAME                    MODE         ... 
sl4mvv48vgjz   secretstack_apiclient   replicated   ... 


    ... REPLICAS   IMAGE              PORTS
    ... 1/1        apiclient:latest
  1. Elimina el servicio secretstack_apiclient.
docker service rm secretstack_apiclient
  1. Elimina el secreto y vuelve a crearlo con un token nuevo:
docker secret rm jot docker secret create jot ./token2.jwt
  1. Vuelva a crear el servicio:
docker stack deploy --compose-file docker-compose.secretmgr.yml secretstack
  1. Busque el ID del contenedor de apiclient (para ver un ejemplo de salida, consulte el paso 3 de «Desplegar el contenedor y comprobar los registros»):
docker ps --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"
  1. Muestre el archivo de registro de Docker, que contiene una serie de mensajes de operación correcta. En lugar de <container_ID>, introduzca el valor del campo CONTAINER ID que aparece en la salida del paso anterior. Pulse Ctrl+c para salir del comando.
docker logs -f <container_ID>200 Success apiKey2
200 Success apiKey2
200 Success apiKey2
200 Success apiKey2
...
^c

¿Ves el cambio de apiKey1 a apiKey2? Has rotado el secreto.

En este tutorial, el servidor de la API sigue aceptando ambos JWT, pero en un entorno de producción puedes dejar de admitir los JWT más antiguos exigiendo determinados valores para las declaraciones del JWT o comprobando las fechas de caducidad de los JWT.

Ten en cuenta también que, si utilizas un sistema de gestión de secretos que permite actualizar el secreto, el código debe volver a leerlo con frecuencia para obtener los nuevos valores.

Limpieza

Para eliminar los objetos que ha creado en este tutorial:

  1. Elimina el servicio secretstack_apiclient.
docker service rm secretstack_apiclient
  1. Elimina el secreto.
docker secret rm jot
  1. Abandona el swarm (suponiendo que hayas creado uno solo para este tutorial).
docker swarm leave --force
  1. Detén el contenedor apiserver en ejecución.
docker ps -a | grep "apiserver" | awk {'print $1'} |xargs docker kill
  1. Elimina los contenedores que no quieras; primero, enuméralos y, después, elimínalos.
docker ps -a --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"docker rm <container_ID>
  1. Elimine las imágenes de contenedor que no necesite; para ello, primero enumérelas y, a continuación, elimínelas.
docker image list docker image rm <image_ID>

Próximos pasos

Puedes usar este blog para seguir el tutorial en tu propio entorno o probarlo en nuestro laboratorio accesible desde el navegador (regístrate aquí). Para obtener más información sobre cómo exponer servicios de Kubernetes, continúa con las demás actividades de la Unidad 2: Introducción a la gestión de secretos en microservicios.

Para obtener más información sobre la autenticación JWT apta para entornos de producción con NGINX Plus, consulta nuestra documentación y lee Autenticación de clientes de API con JWT y NGINX Plus en nuestro blog.

Banner con el texto «Microservicios, marzo de 2023: inscríbete gratis, regístrate hoy»


Compartir
Etiquetas: Security, F5 NGINX, Tech

Sobre el autor

Robert Haynes
Robert HaynesResponsable de marketing técnico

Más artículos de Robert Haynes

Artículos relacionados del blog

Únete a F5 NGINX en AppWorld 2024
NGINX | 04/01/2024

Únete a F5 NGINX en AppWorld 2024

Descubre las numerosas razones para asistir al evento insignia de F5, que incluirá un emocionante anuncio sobre la cartera de productos NGINX.

Rotación de certificados SSL/TLS sin reinicios en NGINX Open Source
NGINX | 26/09/2023

Rotación de certificados SSL/TLS sin reinicios en NGINX Open Source

La función de diccionario compartido permite configurar NGINX sin necesidad de reiniciarlo al renovar los certificados SSL/TLS.

Exposición de APIs en Kubernetes
NGINX | 10/03/2022

Exposición de APIs en Kubernetes

La unidad 2 de Microservices March 2022 se centra en cómo exponer tus API en Kubernetes. Aquí tienes enlaces a los recursos que necesitas: la retransmisión en directo del 14 de marzo, artículos de blog y otros recursos de preparación, y el laboratorio práctico «Protege las API de Kubernetes mediante la limitación de solicitudes».

NGINX Sprint 2.0: visión clara, código renovado y nuevos compromisos con el código abierto
NGINX | 23/08/2021

NGINX Sprint 2.0: visión clara, código renovado y nuevos compromisos con el código abierto

Hoy, en NGINX Sprint 2.0, anunciamos cómo respaldaremos nuestra visión del futuro de las aplicaciones modernas con una mayor presencia en la comunidad de código abierto, incluida la participación en el proyecto comunitario Kubernetes, nuevos proyectos de software de código abierto y la arquitectura de referencia para aplicaciones modernas.

Presentamos NGINX Plus R22
NGINX | 09/06/2020

Presentamos NGINX Plus R22

NGINX Plus R22 incorpora nuevas funcionalidades, como la compatibilidad con OCSP para verificar la revocación de certificados en TLS mutuo, el uso de varios proveedores de identidad OIDC en una configuración, el seguimiento en tiempo real de la limitación de solicitudes y conexiones en el panel de NGINX Plus y mejoras en NGINX JavaScript.

Presentamos NGINX Plus R17
NGINX | 11/12/2018

Presentamos NGINX Plus R17

NGINX Plus R17 incorpora compatibilidad con la limitación de velocidad en dos fases y con TLS 1.3, la versión más reciente del protocolo Transport Layer Security. La configuración de OpenID Connect es más sencilla y NGINX ModSecurity WAF es el doble de rápido que antes. También se ha actualizado el módulo JavaScript de NGINX.