Kubernetes image policy webhook explicado


kubernetes

Introducción

En este artículo vamos a explorar cómo funciona un webhook en Kubernetes, y más específicamente sobre el ImagePolicyWebhook. La documentación oficial de Kubernetes sobre esto es un poco escasa, ya que no proporciona ejemplos reales o implementaciones de las cuales puedas aprender. Aquí desglosaremos las diferentes alternativas. En un escenario real, yo prefiero confiar en OPA Gatekeeper. Estoy planeando hacer que esta implementación valga la pena añadiendo una base de datos que permita que el webhook acepte o rechace imágenes basadas en el análisis de vulnerabilidades, por ejemplo, permitiendo solo imágenes con vulnerabilidades de nivel medio o inferior. Pero eso lo dejaremos para otro día. Si te interesa, podés colaborar en este repositorio.


Existen dos formas de hacer que esto funcione, cada una con un comportamiento ligeramente diferente. Una forma es usar el ImagePolicyWebhook y la otra es utilizando un Admission Controller, ya sea validando o mutando. En este caso, usé el webhook de validación, podés aprender más aquí.

Este admission controller rechazará todos los pods que utilicen imágenes con la etiqueta latest, y en el futuro veremos si los pods cumplen con los niveles de seguridad requeridos.


Comparación

El ImagePolicyWebhook es un admission controller que solo evalúa imágenes. Necesitás analizar las solicitudes, aplicar la lógica y devolver una respuesta para permitir o denegar las imágenes en el clúster.


Las ventajas del ImagePolicyWebhook:

  • El API server puede configurarse para rechazar imágenes si el webhook no está disponible, lo cual es útil, aunque también puede causar problemas, como que algunos pods esenciales no se puedan ejecutar.

Las desventajas del ImagePolicyWebhook:

  • La configuración es más compleja y requiere acceso a los nodos maestros o al archivo de configuración del apiserver. La documentación no es clara y puede ser difícil realizar cambios o actualizaciones.
  • El despliegue no es trivial, ya que debés desplegarlo con systemd o ejecutarlo como un contenedor Docker en el host, actualizar el DNS, etc.

Por otro lado, el ValidatingAdmissionWebhook puede utilizarse para muchas más cosas además de imágenes (si usás el de mutación, incluso podés modificar cosas al vuelo).


Las ventajas del ValidatingAdmissionWebhook:

  • Despliegue más sencillo ya que el servicio se ejecuta como un pod.
  • Todo puede ser un recurso de Kubernetes.
  • Menos intervención manual, no se requiere acceso al nodo maestro.
  • Si el pod o el servicio no están disponibles, se permitirán todas las imágenes, lo que podría ser un riesgo de seguridad en algunos casos. Si elegís este camino, asegurate de hacerlo altamente disponible. Esto puede configurarse especificando el failurePolicy en Fail en lugar de Ignore (por defecto está en Fail).

Las desventajas del ValidatingAdmissionWebhook:

  • Cualquier persona con suficientes permisos de RBAC puede cambiar la configuración, ya que es solo otro recurso de Kubernetes.

Construcción

Si querés usarlo como un servicio simple:

$ go get github.com/kainlite/kube-image-bouncer

También podés usar esta imagen Docker:

$ docker pull kainlite/kube-image-bouncer

Certificados

Podemos apoyarnos en el CA de Kubernetes para generar los certificados que necesitamos. Si querés aprender más, andá aquí.


Creá un CSR:

$ cat <<EOF | cfssl genkey - | cfssljson -bare server
{
  "hosts": [
    "image-bouncer-webhook.default.svc",
    "image-bouncer-webhook.default.svc.cluster.local",
    "image-bouncer-webhook.default.pod.cluster.local",
    "192.0.2.24",
    "10.0.34.2"
  ],
  "CN": "system:node:image-bouncer-webhook.default.pod.cluster.local",
  "key": {
    "algo": "ecdsa",
    "size": 256
  },
  "names": [
    {
      "O": "system:nodes"
    }
  ]
}
EOF

Luego aplicalo en el clúster:

$ cat <<EOF | kubectl apply -f -
apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata:
  name: image-bouncer-webhook.default
spec:
  request: $(cat server.csr | base64 | tr -d '\n')
  signerName: kubernetes.io/kubelet-serving
  usages:
  - digital signature
  - key encipherment
  - server auth
EOF

Aprobá y obtené tu certificado para usarlo más adelante:

$ kubectl get csr image-bouncer-webhook.default -o jsonpath='{.status.certificate}' | base64 --decode > server.crt

Ruta para ImagePolicyWebhook

Hay dos maneras de desplegar este controller (webhook). Para que esto funcione, necesitás crear los certificados como se explicó antes, pero primero debés ocuparte de otros detalles. Agregá esto a tu archivo /etc/hosts en el nodo maestro o donde se ejecutará el bouncer:


Usamos este nombre porque tiene que coincidir con los nombres del certificado. Dado que este se ejecutará fuera de Kubernetes (y podría incluso estar disponible externamente), simplemente lo falsificamos con una entrada en /etc/hosts:

$ echo "127.0.0.1 image-bouncer-webhook.default.svc" >> /etc/hosts

Además, en el apiserver necesitás actualizarlo con estas configuraciones:

--admission-control-config-file=/etc/kubernetes/kube-image-bouncer/admission_configuration.json
--enable-admission-plugins=ImagePolicyWebhook

Si seguís este método, no necesitás crear el recurso validating-webhook-configuration.yaml ni aplicar el despliegue de Kubernetes para ejecutar en el clúster.


Creá un archivo de configuración de control de admisión llamado /etc/kubernetes/kube-image-bouncer/admission_configuration.json con el siguiente contenido:

{
  "imagePolicy": {
     "kubeConfigFile": "/etc/kubernetes/kube-image-bouncer/kube-image-bouncer.yml",
     "allowTTL": 50,
     "denyTTL": 50,
     "retryBackoff": 500,
     "defaultAllow": false
  }
}

Ajustá los valores predeterminados si querés permitir imágenes por defecto.


Creá un archivo kubeconfig /etc/kubernetes/kube-image-bouncer/kube-image-bouncer.yml con el siguiente contenido:

apiVersion: v1
kind: Config
clusters:
- cluster:
    certificate-authority: /etc/kubernetes/kube-image-bouncer/pki/server.crt
    server: https://image-bouncer-webhook.default.svc:1323/image_policy
  name: bouncer_webhook
contexts:
- context:
    cluster: bouncer_webhook
    user: api-server
  name: bouncer_validator
current-context: bouncer_validator
preferences: {}
users:
- name: api-server
  user:
    client-certificate: /etc/kubernetes/pki/apiserver.crt
    client-key:  /etc/kubernetes/pki/apiserver.key

Este archivo de configuración le indica al API server que debe conectarse al servidor webhook en https://image-bouncer-webhook.default.svc:1323 y utilizar el endpoint /image_policy. Estamos reutilizando los certificados del apiserver y el generado para kube-image-bouncer.


Asegurate de estar en la carpeta que contiene los certificados para que funcione:

$ docker run --rm -v `pwd`/server-key.pem:/certs/server-key.pem:ro -v `pwd`/server.crt:/certs/server.crt:ro -p 1323:1323 --network host kainlite/kube-image-bouncer -k /certs/server-key.pem -c /certs/server.crt

Ruta para ValidatingAdmissionWebhook

Si optás por esta ruta, todo lo que necesitás hacer es generar los certificados; todo lo demás puede hacerse con kubectl. Primero debés crear un secreto TLS que contenga el certificado y la clave del webhook (que generamos en el paso anterior):

$ kubectl create secret tls tls-image-bouncer-webhook \
  --key server-key.pem \
  --cert server.pem

Luego creá un despliegue de Kubernetes para el image-bouncer-webhook:

$ kubectl apply -f kubernetes/image-bouncer-webhook.yaml

Finalmente, creá la ValidatingWebhookConfiguration que utiliza nuestro endpoint del webhook. Podés usar este archivo, pero asegurate de actualizar el campo caBundle con el contenido de server.crt en formato base64:

$ kubectl apply -f kubernetes/validating-webhook-configuration.yaml

O también podés generar el archivo validating-webhook-configuration.yaml directamente y aplicarlo de una sola vez como sigue:

$ cat <<EOF | kubectl apply -f -
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: image-bouncer-webook
webhooks:
  - name: image-bouncer-webhook.default.svc
    rules:
      - apiGroups:
          - ""
        apiVersions:
          - v1
        operations:
          - CREATE
        resources:
          - pods
    failurePolicy: Ignore
    sideEffects: None
    admissionReviewVersions: ["v1", "v1beta1"]
    clientConfig:
      caBundle: $(kubectl get csr image-bouncer-webhook.default -o jsonpath='{.status.certificate}')
      service:
        name: image-bouncer-webhook
        namespace: default
EOF

Este proceso se puede automatizar fácilmente (estoy trabajando en un Helm chart para esto…). Los cambios pueden tardar unos segundos en reflejarse, así que esperá un poco y luego probá si todo funciona correctamente.

Pruebas

Ambos métodos deberían funcionar de la misma manera y vas a ver un mensaje de error similar al siguiente ejemplo:

Error creating: pods "nginx-latest-sdsmb" is forbidden: image policy webhook backend denied one or more images: Images using latest tag are not allowed

o

Warning  FailedCreate  23s (x15 over 43s)  replication-controller  Error creating: admission webhook "image-bouncer-webhook.default.svc" denied the request: Images using latest tag are not allowed

Creá un ReplicationController (RC) para nginx con una versión específica para validar que los lanzamientos con versiones funcionen correctamente:

$ cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ReplicationController
metadata:
  name: nginx-versioned
spec:
  replicas: 1
  selector:
    app: nginx-versioned
  template:
    metadata:
      name: nginx-versioned
      labels:
        app: nginx-versioned
    spec:
      containers:
      - name: nginx-versioned
        image: nginx:1.13.8
        ports:
        - containerPort: 80
EOF

Verificando que esta corriendo:

$ kubectl get rc
NAME              DESIRED   CURRENT   READY     AGE
nginx-versioned   1         1         0         2h

Ahora creemos un despliegue de nginx usando latest para validar que funciona:

$ cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ReplicationController
metadata:
  name: nginx-latest
spec:
  replicas: 1
  selector:
    app: nginx-latest
  template:
    metadata:
      name: nginx-latest
      labels:
        app: nginx-latest
    spec:
      containers:
      - name: nginx-latest
        image: nginx
        ports:
        - containerPort: 80
EOF

Si verificamos el pod, no debería crearse, y el ReplicationController (RC) debería mostrar algo similar al siguiente resultado. También podés verificarlo con el comando kubectl get events --sort-by='{.lastTimestamp}':

$ kubectl describe rc nginx-latest
Name:         nginx-latest
Namespace:    default
Selector:     app=nginx-latest
Labels:       app=nginx-latest
Annotations:  <none>
Replicas:     0 current / 1 desired
Pods Status:  0 Running / 0 Waiting / 0 Succeeded / 0 Failed
Pod Template:
  Labels:  app=nginx-latest
  Containers:
   nginx-latest:
    Image:        nginx
    Port:         80/TCP
    Host Port:    0/TCP
    Environment:  <none>
    Mounts:       <none>
  Volumes:        <none>
Conditions:
  Type             Status  Reason
  ----             ------  ------
  ReplicaFailure   True    FailedCreate
Events:
  Type     Reason        Age                 From                    Message
  ----     ------        ----                ----                    -------
  Warning  FailedCreate  23s (x15 over 43s)  replication-controller  Error creating: admission webhook "image-bouncer-webhook.default.svc" denied the request: Images using latest tag are not allowed

Esto confirma que el webhook está bloqueando correctamente las imágenes con la etiqueta latest.


Depuración

Es muy útil revisar los logs del apiserver si estás utilizando el camino del admission controller, ya que allí se registrará por qué falló, así como también los logs del image-bouncer. Aquí hay un ejemplo:

apiserver:

W0107 17:39:00.619560       1 dispatcher.go:142] rejected by webhook "image-bouncer-webhook.default.svc": &errors.StatusError{ErrStatus:v1.Status{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ListMeta:v1.ListMeta{ SelfLink:"", ResourceVersion:"", Continue:"", RemainingItemCount:(*int64)(nil)}, Status:"Failure", Message:"admission webhook \"image-bouncer-webhook.default.svc\" denied the request: Images using latest tag are not allowed", Reason:"", Details:(*v1.StatusDetails)(nil), Code:400}}

kube-image-bouncer:

echo: http: TLS handshake error from 127.0.0.1:49414: remote error: tls: bad certificate
method=POST, uri=/image_policy?timeout=30s, status=200
method=POST, uri=/image_policy?timeout=30s, status=200
method=POST, uri=/image_policy?timeout=30s, status=200

El error es de una prueba manual, los otros son solicitudes exitosas del apiserver.


El código en sí

Echemos un vistazo rápido a las partes críticas de la creación de un admission controller o webhook.

Esta es una sección del archivo main.go. Como podemos ver, estamos manejando dos rutas POST con diferentes métodos y algunas otras validaciones. Lo que debemos saber es que recibiremos una llamada POST con un JSON como payload que necesitamos convertir en una solicitud de revisión del admission controller.

    app.Action = func(c *cli.Context) error {
        e := echo.New()
        e.POST("/image_policy", handlers.PostImagePolicy())
        e.POST("/", handlers.PostValidatingAdmission())

        e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
            Format: "method=${method}, uri=${uri}, status=${status}\n",
        }))

        if debug {
            e.Logger.SetLevel(log.DEBUG)
        }

        if whitelist != "" {
            handlers.RegistryWhitelist = strings.Split(whitelist, ",")
            fmt.Printf(
                "Accepting only images from these registries: %+v\n",
                handlers.RegistryWhitelist)
            fmt.Println("WARN: this feature is implemented only by the ValidatingAdmissionWebhook code")
        } else {
            fmt.Println("WARN: accepting images from ALL registries")
        }

        var err error
        if cert != "" && key != "" {
            err = e.StartTLS(fmt.Sprintf(":%d", port), cert, key)
        } else {
            err = e.Start(fmt.Sprintf(":%d", port))
        }

        if err != nil {
            return cli.NewExitError(err, 1)
        }

        return nil
    }

    app.Run(os.Args)

Esta es una sección del archivo handlers/validating_admission.go. Básicamente, analiza y valida si la imagen debe ser permitida o no, y luego envía una respuesta de AdmissionResponse con el valor Allowed configurado como verdadero o falso.

Si querés aprender más sobre los diferentes tipos utilizados aquí, podés explorar la documentación de v1beta1.Admission.

func PostValidatingAdmission() echo.HandlerFunc {
    return func(c echo.Context) error {
        var admissionReview v1beta1.AdmissionReview

        err := c.Bind(&admissionReview)
        if err != nil {
            c.Logger().Errorf("Something went wrong while unmarshalling admission review: %+v", err)
            return c.JSON(http.StatusBadRequest, err)
        }
        c.Logger().Debugf("admission review: %+v", admissionReview)

        pod := v1.Pod{}
        if err := json.Unmarshal(admissionReview.Request.Object.Raw, &pod); err != nil {
            c.Logger().Errorf("Something went wrong while unmarshalling pod object: %+v", err)
            return c.JSON(http.StatusBadRequest, err)
        }
        c.Logger().Debugf("pod: %+v", pod)

        admissionReview.Response = &v1beta1.AdmissionResponse{
            Allowed: true,
            UID:     admissionReview.Request.UID,
        }
        images := []string{}

        for _, container := range pod.Spec.Containers {
            images = append(images, container.Image)
            usingLatest, err := rules.IsUsingLatestTag(container.Image)
            if err != nil {
                c.Logger().Errorf("Error while parsing image name: %+v", err)
                return c.JSON(http.StatusInternalServerError, "error while parsing image name")
            }
            if usingLatest {
                admissionReview.Response.Allowed = false
                admissionReview.Response.Result = &metav1.Status{
                    Message: "Images using latest tag are not allowed",
                }
                break
            }

            if len(RegistryWhitelist) > 0 {
                validRegistry, err := rules.IsFromWhiteListedRegistry(
                    container.Image,
                    RegistryWhitelist)
                if err != nil {
                    c.Logger().Errorf("Error while looking for image registry: %+v", err)
                    return c.JSON(
                        http.StatusInternalServerError,
                        "error while looking for image registry")
                }
                if !validRegistry {
                    admissionReview.Response.Allowed = false
                    admissionReview.Response.Result = &metav1.Status{
                        Message: "Images from a non whitelisted registry",
                    }
                    break
                }
            }
        }

        if admissionReview.Response.Allowed {
            c.Logger().Debugf("All images accepted: %v", images)
        } else {
            c.Logger().Infof("Rejected images: %v", images)
        }

        c.Logger().Debugf("admission response: %+v", admissionReview.Response)

        return c.JSON(http.StatusOK, admissionReview)
    }
}

Todo está en este repositorio.


Palabras finales

Este ejemplo y el post original fueron creados aquí, así que muchas gracias a Flavio Castelli por crear un excelente ejemplo. Mis cambios son principalmente para explicar cómo funciona y las modificaciones necesarias para que funcione en la última versión de Kubernetes (en este momento v1.20.0), mientras aprendía a usarlo y a crear mi propio webhook.


El archivo readme del proyecto puede no coincidir completamente con este artículo, pero ambos deberían funcionar. Aún no he actualizado todo el readme.


Errata

Si encontrás algún error o tenés alguna sugerencia, por favor enviame un mensaje para que lo pueda corregir.



No tienes cuenta? Regístrate aqui

Ya registrado? Iniciar sesión a tu cuenta ahora.

Iniciar session con GitHub
Iniciar sesion con Google
  • Comentarios

    Online: 0

Por favor inicie sesión para poder escribir comentarios.

by Gabriel Garrido