En la lección anterior endurecimos cada componente de Rutas Norte: tienda-web corre sin root con el sistema de ficheros en solo lectura, api-reservas no tiene ni una capacidad de Linux, postgres-reservas escribe en su volumen gracias a fsGroup y todos llevan seccompProfile: RuntimeDefault. El trabajo está hecho y verificado.

Y aun así, hay un agujero enorme: todo eso depende de que alguien se acuerde. Mañana un compañero añade un sidecar y olvida el drop: ["ALL"]. Un desarrollador copia un manifiesto de un blog que trae privileged: true porque "así funcionaba". Un operador de terceros se instala con un DaemonSet que monta / del nodo. Ninguna de esas cosas rompe nada visible, nadie recibe una alerta, y el endurecimiento que construimos con tanto cuidado deja de aplicarse en el punto exacto donde importa.

Esta lección da el salto que lo cambia todo: pasar de "cada equipo pone bien su securityContext" a "el clúster rechaza un pod que no lo tenga". Es la tercera puerta que vimos en 08-01, el control de admisión, y es la diferencia entre una norma escrita y una norma aplicada.

Advertencia importante. Las políticas de admisión son el mecanismo que hace cumplir el diseño de seguridad, y por eso mismo un error en ellas puede bloquear despliegues legítimos o, peor, dar una falsa sensación de protección. El conjunto de políticas de un clúster de producción debe diseñarlo y revisarlo un profesional de seguridad. Cuando las cargas afectadas tratan datos personales —postgres-reservas, api-reservas e informes-ocupacion en Rutas Norte— el responsable de cumplimiento normativo debe conocer y aprobar qué se exige y qué excepciones existen.

Contenido

  1. De la norma escrita a la norma aplicada
  2. Contexto histórico: las PodSecurityPolicy y por qué desaparecieron
  3. Los Pod Security Standards: privileged, baseline y restricted
  4. Pod Security Admission: activarlo con etiquetas en el namespace
  5. Aplicación progresiva en Rutas Norte
  6. Cargas que legítimamente necesitan privilegios
  7. Las limitaciones del Pod Security Admission
  8. Motores de política general: Kyverno y OPA Gatekeeper
  9. Políticas de Kyverno para Rutas Norte
  10. ValidatingAdmissionPolicy: CEL nativo sin instalar nada
  11. Errores comunes y consejos
  12. Ejercicios
  13. Conclusión

  1. De la norma escrita a la norma aplicada

Recordemos el recorrido de una petición a la API de 08-01:

flowchart LR
    A["kubectl apply -f pod.yaml"] --> B["Autenticación<br/>¿quién eres?"]
    B --> C["Autorización RBAC<br/>¿puedes crear pods?"]
    C --> D["Admisión mutante<br/>modifica el objeto"]
    D --> E["Admisión validante<br/>¿el objeto cumple?<br/>ESTA LECCIÓN"]
    E -->|No cumple| X["Rechazado<br/>el objeto NUNCA llega a etcd"]
    E -->|Cumple| F[("etcd")]
    style E fill:#d5e8f9,stroke:#36c

La diferencia con RBAC es fundamental y conviene tenerla clarísima:

RBAC (08-01) Control de admisión (esta lección)
Pregunta ¿Tienes derecho a hacer esta operación? ¿El objeto que envías es aceptable?
Mira Sujeto, verbo, recurso, namespace El contenido del objeto
Ejemplo de decisión "Ana puede crear pods en dev" "Este pod no puede ser privilegiado"
Si falla 403 Forbidden 400 Bad Request o 422 con el motivo

Alguien de plataforma tiene permiso RBAC para crear pods en rutas-norte-pro. Eso no cambia. Lo que añadimos es que, aunque tenga permiso, el pod concreto que envía debe cumplir unas reglas.

Y hay una propiedad muy valiosa: el objeto rechazado nunca llega a etcd. No se crea y hay que borrarlo después; sencillamente no existe. La respuesta llega al instante a quien hizo el kubectl apply, con el motivo escrito.

Los dos tipos de controlador de admisión

Tipo Qué hace Ejemplos
Mutante (mutating) Modifica el objeto antes de guardarlo Inyectar la ServiceAccount por defecto, añadir el sidecar de una malla de servicio, aplicar los valores de un LimitRange (03-05)
Validante (validating) Solo acepta o rechaza, no modifica Pod Security Admission, ResourceQuota (03-04), políticas de Kyverno de tipo validate

Los mutantes se ejecutan antes que los validantes, lo cual tiene sentido: primero se completa el objeto, luego se comprueba el resultado final.

  1. Contexto histórico: las PodSecurityPolicy y por qué desaparecieron

Si buscas información sobre este tema encontrarás muchísimo material sobre PodSecurityPolicy (PSP). Es importante que sepas qué eran, porque aparecen constantemente en documentación antigua, en respuestas de foros y en manifiestos heredados. Pero también que tengas muy claro esto:

Las PodSecurityPolicy quedaron obsoletas en Kubernetes 1.21 y se eliminaron por completo en 1.25. En un clúster 1.30 no existen. Si un tutorial te dice que crees una PodSecurityPolicy, ese tutorial tiene al menos cinco años.

Qué eran

Un recurso a nivel de clúster que describía qué se permitía en un pod: si podía ser privilegiado, qué usuarios podía usar, qué volúmenes, qué capacidades. Su forma se parecía a esto:

# NO USAR: recurso eliminado en Kubernetes 1.25. Solo como referencia histórica.
apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
  name: restrictiva
spec:
  privileged: false
  allowPrivilegeEscalation: false
  requiredDropCapabilities: ["ALL"]
  runAsUser:
    rule: MustRunAsNonRoot
  seLinux:
    rule: RunAsAny
  fsGroup:
    rule: MustRunAs
    ranges: [{min: 1, max: 65535}]
  volumes: ["configMap", "emptyDir", "secret", "persistentVolumeClaim"]

Conceptualmente la idea era buena. La implementación tenía tres problemas graves que resultaron insalvables.

Problema 1: RBAC contraintuitivo

Una PSP no se aplicaba a nadie por sí sola. Se "activaba" concediendo el verbo use sobre ella mediante RBAC, y —esto es lo raro— al sujeto que creaba el pod, que muchas veces no era la persona sino un controlador. Cuando un Deployment creaba pods, el sujeto relevante era la ServiceAccount del controlador de ReplicaSets, no el usuario que hizo el kubectl apply.

El resultado: nadie sabía con certeza qué política se le estaba aplicando a un pod concreto, y responder a "¿por qué se ha rechazado esto?" era un ejercicio de arqueología.

Problema 2: orden de aplicación impredecible

Si a un sujeto le aplicaban varias PSP —cosa habitual— Kubernetes elegía una según un algoritmo complicado: primero las que no mutaban el pod, y si todas mutaban, la primera por orden alfabético. El orden alfabético. Renombrar una política cambiaba qué política se aplicaba.

Problema 3: mutación

Las PSP no solo validaban: también modificaban el pod (rellenaban runAsUser, añadían fsGroup, quitaban capacidades). Eso significaba que el pod que se ejecutaba no era el que estaba en tu manifiesto, y que aplicar el mismo YAML en dos clústeres podía dar resultados distintos sin aviso alguno.

Y un problema de fondo: la usabilidad

En la práctica, activar PSP en un clúster existente rompía casi todo, y como no había un modo "avísame pero no bloquees", el camino habitual era crear una PSP permisiva "temporal" que se quedaba para siempre. La conclusión de la comunidad fue clara: un mecanismo de seguridad que nadie consigue activar no protege a nadie.

Qué se aprendió

El sustituto, el Pod Security Admission, se diseñó explícitamente para corregir cada uno de esos defectos:

Problema de PSP Cómo lo resuelve el PSA
RBAC contraintuitivo Se activa con etiquetas en el namespace. Sin RBAC de por medio
Orden impredecible Tres perfiles fijos definidos por el proyecto. No hay que elegir
Mutación Nunca muta. Solo valida
Todo o nada Tres modos: warn, audit y enforce. Se puede adoptar gradualmente
Complejidad Una etiqueta en un namespace

La contrapartida es que el PSA es mucho menos flexible: solo tres perfiles, sin posibilidad de personalizarlos. Ese hueco lo cubren los motores de política del apartado 8.

  1. Los Pod Security Standards: privileged, baseline y restricted

Los Pod Security Standards (PSS) son tres perfiles definidos por el proyecto Kubernetes. No son un recurso ni un objeto: son una especificación, un acuerdo sobre qué significa "seguro" en tres niveles.

Perfil Filosofía Para qué
privileged Sin restricciones Infraestructura del sistema: CNI, CSI, agentes de nodo
baseline Impide las escaladas de privilegios conocidas Aplicaciones normales; adopción realista y compatible
restricted Buenas prácticas de endurecimiento actuales El objetivo para toda aplicación

Tabla detallada de qué controla cada perfil

Control privileged baseline restricted
privileged: true Permitido Prohibido Prohibido
hostNetwork Permitido Prohibido Prohibido
hostPID / hostIPC Permitido Prohibido Prohibido
hostPath Permitido Prohibido Prohibido
hostPort Permitido Prohibido Prohibido
Capacidades añadidas Todas Solo una lista corta (ver abajo) Solo NET_BIND_SERVICE
capabilities.drop: ["ALL"] No exigido No exigido Obligatorio
allowPrivilegeEscalation Libre Libre Debe ser false
runAsNonRoot Libre Libre Debe ser true
runAsUser: 0 Permitido Permitido Prohibido
seccompProfile Libre No puede ser Unconfined RuntimeDefault o Localhost
Tipos de volumen Todos Todos menos los de host Lista restringida
sysctls no seguros Permitidos Prohibidos Prohibidos
procMount: Unmasked Permitido Prohibido Prohibido
SELinux: tipos peligrosos Permitidos Prohibidos Prohibidos
AppArmor Unconfined Permitido Prohibido Prohibido
/dev/... como hostPath Permitido Prohibido Prohibido

Capacidades permitidas en baseline

baseline permite añadir solo estas, que son las del conjunto por defecto y todas relativamente inocuas:

AUDIT_WRITE, CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL,
MKNOD, NET_BIND_SERVICE, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT

Lo que baseline prohíbe añadir es lo verdaderamente peligroso: NET_ADMIN, SYS_ADMIN, SYS_MODULE, SYS_PTRACE, BPF, NET_RAW...

restricted va mucho más allá: exige drop: ["ALL"] y solo permite volver a añadir NET_BIND_SERVICE. Es exactamente la excepción de la que hablamos en 08-02 y por eso decíamos que el puerto alto es preferible: con puerto 8080 no necesitas ni siquiera esa.

Tipos de volumen permitidos en restricted

configMap, csi, downwardAPI, emptyDir, ephemeral, persistentVolumeClaim,
projected, secret

Fíjate en que están todos los que usa Rutas Norte —emptyDir para los directorios de escritura, persistentVolumeClaim para los datos de PostgreSQL, configMap y secret para la configuración— y no está hostPath. Es la formalización de la advertencia de 05-01.

El campo securityContext mínimo que cumple restricted

spec:
  securityContext:
    runAsNonRoot: true
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: registry.rutasnorte.example/api-reservas:2.7.1
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]

Compáralo con el bloque de "consejo de oro" de 08-02: es prácticamente el mismo. Los Pod Security Standards no son otra cosa que la codificación oficial de lo que ya hicimos a mano. Todo lo de la lección anterior encaja ahora en una etiqueta.

Nota importante: restricted no exige readOnlyRootFilesystem. Es una omisión conocida (era demasiado disruptiva para muchas cargas) y una de las razones por las que un motor de política complementa al PSA.

  1. Pod Security Admission: activarlo con etiquetas en el namespace

El Pod Security Admission (PSA) es el controlador que aplica los Pod Security Standards. Va integrado en el apiserver desde 1.25: no hay que instalar nada, no hay ningún pod que mantener, no hay webhook que pueda caerse.

Se activa poniendo etiquetas en el Namespace:

apiVersion: v1
kind: Namespace
metadata:
  name: rutas-norte-pro
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
    # --- Pod Security Admission ---
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.30
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: v1.30
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: v1.30

La sintaxis de la etiqueta es:

pod-security.kubernetes.io/<MODO>[-version]: <VALOR>

Los tres modos

Modo Qué pasa cuando un pod incumple Dónde se ve
enforce El pod se rechaza. No se crea Error inmediato al aplicar
audit Se crea, pero se anota en el registro de auditoría del apiserver Log de auditoría (08-06)
warn Se crea, y el cliente recibe un aviso Salida de kubectl

Los tres son independientes y se pueden combinar. Ahí está la clave de la adopción gradual: puedes poner enforce: baseline (bloquea lo peor) junto con warn: restricted y audit: restricted (te avisa de lo que aún no cumple, sin bloquear).

Un detalle esencial sobre enforce:

enforce solo actúa sobre pods que se crean o actualizan. No afecta a los que ya están corriendo. Si activas enforce: restricted en un namespace con pods que no cumplen, esos pods siguen funcionando tan tranquilos... hasta que el Deployment cree uno nuevo, que fallará. Es un fallo diferido y desconcertante si no lo esperas.

Por eso warn y audit no bloquean pero sí evalúan objetos que crean pods (Deployments, StatefulSets, CronJobs), avisándote en el momento del apply. enforce, en cambio, solo evalúa el pod, no el Deployment. Volveremos sobre esto en el apartado 7.

El fijado de versión

pod-security.kubernetes.io/enforce-version: v1.30

Los Pod Security Standards evolucionan: en cada versión de Kubernetes se pueden añadir controles nuevos. Si no fijas la versión, el valor por defecto es latest, lo que significa que una actualización del clúster puede empezar a rechazar pods que antes se aceptaban, sin que nadie haya tocado nada.

Valor Comportamiento
latest (por defecto) Usa siempre los controles de la versión actual del clúster
v1.30 Congela los controles de esa versión

Fija siempre la versión. Y cuando actualices el clúster, sube la etiqueta como un cambio explícito y revisado, con warn primero. Es la diferencia entre una actualización planificada y un incidente el lunes por la mañana.

Configurar valores por defecto para todo el clúster

Se puede indicar al apiserver un perfil por defecto para los namespaces que no lleven etiquetas, mediante AdmissionConfiguration:

# /etc/kubernetes/admision/pod-security.yaml (en los nodos del plano de control)
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
  - name: PodSecurity
    configuration:
      apiVersion: pod-security.admission.config.k8s.io/v1
      kind: PodSecurityConfiguration
      defaults:
        enforce: "baseline"          # línea base para namespaces sin etiquetar
        enforce-version: "v1.30"
        audit: "restricted"
        audit-version: "v1.30"
        warn: "restricted"
        warn-version: "v1.30"
      exemptions:
        usernames: []
        runtimeClasses: []
        namespaces:
          - kube-system              # el sistema necesita privilegios

Esto es muy potente porque cubre los namespaces futuros: si mañana alguien crea rutas-norte-experimentos sin etiquetas, arranca ya en baseline. Sin esta configuración, un namespace sin etiquetar equivale a privileged, es decir, sin ninguna restricción.

Requiere acceso al plano de control, así que en Kubernetes gestionado (10-06) puede no estar disponible; en ese caso, la alternativa es una política de Kyverno que exija las etiquetas en todo namespace nuevo (lo veremos en el apartado 9).

El modo warn en acción

kubectl label namespace rutas-norte-pre \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/warn-version=v1.30

kubectl apply -f k8s/entornos/pre/tienda-web.yaml
Warning: would violate PodSecurity "restricted:v1.30": allowPrivilegeEscalation != false
(container "nginx" must set securityContext.allowPrivilegeEscalation=false),
unrestricted capabilities (container "nginx" must set securityContext.capabilities.drop=["ALL"]),
runAsNonRoot != true (pod or container "nginx" must set securityContext.runAsNonRoot=true),
seccompProfile (pod or container "nginx" must set securityContext.seccompProfile.type
to "RuntimeDefault" or "Localhost")
deployment.apps/tienda-web configured

Lee bien esa salida: el Deployment se ha aplicado (configured), pero el aviso enumera exactamente los cuatro campos que faltan, contenedor por contenedor. Es la mejor documentación posible de qué hay que arreglar. Anota lo bien redactado que está el mensaje: cada línea te dice el campo, el contenedor y el valor esperado.

  1. Aplicación progresiva en Rutas Norte

Nunca actives enforce: restricted de golpe en un namespace de producción. El camino correcto tiene cuatro fases y puede llevar semanas. Vamos a recorrerlo.

flowchart TD
    A["Fase 0<br/>Sin etiquetas<br/>= privileged"] --> B["Fase 1<br/>warn + audit: restricted<br/>NO bloquea"]
    B --> C["Analizar avisos<br/>y arreglar componentes"]
    C --> D["Fase 2<br/>enforce: baseline<br/>+ warn/audit: restricted"]
    D --> E["Arreglar lo que falta<br/>para restricted"]
    E --> F["Fase 3<br/>enforce: restricted<br/>en los tres modos"]
    style F fill:#d5f9d5,stroke:#3a3

Fase 1: ver qué se rompería, sin romper nada

kubectl label namespace rutas-norte-pro \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/warn-version=v1.30 \
  pod-security.kubernetes.io/audit=restricted \
  pod-security.kubernetes.io/audit-version=v1.30

Aquí no se bloquea nada: el clúster sigue funcionando igual. Ahora hay que provocar la evaluación de todas las cargas existentes. Un truco muy útil es reaplicar los manifiestos sin cambios:

kubectl apply -f k8s/entornos/pro/ --dry-run=server 2>&1 | grep -i warning

--dry-run=server envía el objeto al apiserver, que lo pasa por toda la cadena de admisión y devuelve el resultado sin guardarlo. Es la forma segura de evaluar la situación completa.

Warning: would violate PodSecurity "restricted:v1.30": allowPrivilegeEscalation != false
  (container "postgres" ...), runAsNonRoot != true (pod or container "postgres" ...)
Warning: would violate PodSecurity "restricted:v1.30": host namespaces (hostPath volume "varlog"),
  restricted volume types (volume "varlog" uses restricted volume type "hostPath")

Y para ver lo que evalúa el modo audit, la fuente es el registro de auditoría del apiserver, con la anotación pod-security.kubernetes.io/audit-violations. Lo veremos en detalle en 08-06.

Una herramienta que ahorra mucho tiempo en esta fase:

# Evalúa todo el clúster contra un perfil sin aplicar nada
kubectl krew install score   # o usa pod-security-admission-checker

También se puede consultar directamente qué namespaces están sin protección:

kubectl get namespaces -o custom-columns=\
'NOMBRE:.metadata.name,ENFORCE:.metadata.labels.pod-security\.kubernetes\.io/enforce,WARN:.metadata.labels.pod-security\.kubernetes\.io/warn'
NOMBRE              ENFORCE       WARN
default             <none>        <none>
kube-system         privileged    <none>
rutas-norte-dev     <none>        <none>
rutas-norte-pre     <none>        restricted
rutas-norte-pro     <none>        restricted

Los <none> en enforce son namespaces donde ahora mismo se puede desplegar cualquier cosa, incluido default. Esa tabla es una excelente diapositiva para una reunión de seguridad.

Fase 2: enforce: baseline

baseline bloquea lo verdaderamente peligroso —privilegiados, namespaces de host, hostPath— y casi ninguna aplicación normal lo incumple. Es un paso con muy poco riesgo y mucho valor.

# k8s/entornos/pro/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: rutas-norte-pro
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
    pod-security.kubernetes.io/enforce: baseline     # bloquea lo peor
    pod-security.kubernetes.io/enforce-version: v1.30
    pod-security.kubernetes.io/audit: restricted     # sigue avisando del objetivo
    pod-security.kubernetes.io/audit-version: v1.30
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: v1.30

Ahora, si alguien intenta desplegar un pod privilegiado:

kubectl run prueba --image=nginx --privileged -n rutas-norte-pro
Error from server (Forbidden): pods "prueba" is forbidden: violates PodSecurity
"baseline:v1.30": privileged (container "prueba" must not set securityContext.privileged=true)

Ese es el momento en el que el trabajo de 08-02 deja de depender de la memoria de nadie.

Fase 3: enforce: restricted y el mensaje de rechazo exacto

Antes de dar este paso, todos los componentes deben cumplir. Probemos primero con un manifiesto sin endurecer para ver el mensaje completo:

# /tmp/pod-sin-endurecer.yaml
apiVersion: v1
kind: Pod
metadata:
  name: prueba-restricted
  namespace: rutas-norte-pro
spec:
  containers:
    - name: app
      image: registry.rutasnorte.example/utilidades:1.4.2
kubectl label namespace rutas-norte-pro \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/enforce-version=v1.30 --overwrite

kubectl apply -f /tmp/pod-sin-endurecer.yaml
Error from server (Forbidden): error when creating "/tmp/pod-sin-endurecer.yaml":
pods "prueba-restricted" is forbidden: violates PodSecurity "restricted:v1.30":
allowPrivilegeEscalation != false (container "app" must set
securityContext.allowPrivilegeEscalation=false),
unrestricted capabilities (container "app" must set securityContext.capabilities.drop=["ALL"]),
runAsNonRoot != true (pod or container "app" must set securityContext.runAsNonRoot=true),
seccompProfile (pod or container "app" must set securityContext.seccompProfile.type
to "RuntimeDefault" or "Localhost")

El mensaje es una lista de tareas. Corregimos:

# /tmp/pod-endurecido.yaml
apiVersion: v1
kind: Pod
metadata:
  name: prueba-restricted
  namespace: rutas-norte-pro
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: registry.rutasnorte.example/utilidades:1.4.2
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]
kubectl apply -f /tmp/pod-endurecido.yaml
pod/prueba-restricted created

El componente que no cumplía: postgres-reservas

En 08-02 dejamos readOnlyRootFilesystem: false en PostgreSQL como excepción documentada. Buena noticia: restricted no exige ese campo, así que ese punto no bloquea. Lo que sí exige es runAsNonRoot: true y drop: ["ALL"], que ya teníamos. postgres-reservas pasa restricted sin cambios.

Comprobémoslo antes de tocar el namespace:

kubectl apply -f k8s/entornos/pro/postgres-reservas.yaml --dry-run=server
statefulset.apps/postgres-reservas configured

Sin avisos. Perfecto.

El componente que no cumple: el recolector de logs

kubectl apply -f k8s/entornos/pro/logs-daemonset.yaml --dry-run=server
Warning: would violate PodSecurity "restricted:v1.30": hostPath volumes (volume "varlog"),
runAsNonRoot != true (pod or container "recolector" must set securityContext.runAsNonRoot=true),
restricted volume types (volume "varlog" uses restricted volume type "hostPath")

Y aquí no hay nada que arreglar: el recolector necesita legítimamente leer /var/log del nodo y necesita ser root para hacerlo. Es el tema del siguiente apartado.

Estado final de los tres entornos

Namespace enforce audit / warn Razón
rutas-norte-dev baseline restricted Los desarrolladores necesitan margen para depurar
rutas-norte-pre restricted restricted Debe ser idéntico a producción
rutas-norte-pro restricted restricted Objetivo
rutas-norte-sistema privileged Agentes de nodo (apartado 6)

Que rutas-norte-pre sea igual de estricto que producción es innegociable: si en preproducción se permite lo que en producción no, la promoción falla justo en el peor momento. Ese es todo el sentido de tener un entorno intermedio.

rutas-norte-dev en baseline es una concesión deliberada: bloquea lo peligroso pero permite, por ejemplo, correr como root para probar algo. Con warn: restricted, el desarrollador ve en cada apply exactamente qué le faltará para promocionar. Es una decisión de equilibrio que hay que documentar.

  1. Cargas que legítimamente necesitan privilegios

Hay software que no puede cumplir restricted, y no por dejadez. El plugin CNI configura las interfaces de red del nodo. El driver CSI monta volúmenes en el sistema de ficheros del anfitrión. El recolector de logs lee /var/log. El agente de detección en tiempo de ejecución (Falco, que veremos en 08-06) observa llamadas al sistema.

La respuesta correcta no es relajar el perfil de rutas-norte-pro. Es aislar esas cargas.

Solución 1: namespace aparte con perfil privileged

apiVersion: v1
kind: Namespace
metadata:
  name: rutas-norte-sistema
  labels:
    app.kubernetes.io/part-of: rutas-norte
    pod-security.kubernetes.io/enforce: privileged
    pod-security.kubernetes.io/enforce-version: v1.30
    # audit y warn en restricted: nos deja rastro de todo lo que se salta el perfil
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: v1.30
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: v1.30
  annotations:
    seguridad.rutasnorte.example/justificacion: >-
      Namespace para agentes de nodo que requieren acceso al anfitrión:
      recolector de logs (lectura de /var/log) y agente de detección en
      tiempo de ejecución. Revisión trimestral por el equipo de plataforma
      y por seguridad. Ninguna aplicación de negocio puede desplegarse aquí.

Tres decisiones importantes en ese manifiesto:

  1. audit y warn siguen en restricted. Aunque enforce sea privileged, cada pod que se salte el perfil deja rastro en el registro de auditoría. Así el namespace privilegiado no es un agujero negro: sabemos exactamente qué se ejecuta ahí y con qué privilegios.
  2. La anotación de justificación. Un namespace con perfil privileged es una excepción de seguridad y debe estar documentada en el propio objeto, con quién la revisa y cuándo.
  3. RBAC estricto encima. Recordando 08-01: quien puede crear pods aquí puede crear pods privilegiados. Solo plataforma debe tener permiso, y en 08-01 vimos que crear pods en un namespace equivale a poder usar cualquier ServiceAccount de ese namespace: otra razón para que las aplicaciones no vivan ahí.

Y el complemento imprescindible en RBAC:

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: plataforma-sistema
  namespace: rutas-norte-sistema
subjects:
  - kind: Group
    name: plataforma         # SOLO plataforma. Ni desarrollo, ni CI, ni soporte
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: admin
  apiGroup: rbac.authorization.k8s.io

Solución 2: exenciones en la configuración del apiserver

El PSA admite exenciones globales por tres criterios:

apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
  - name: PodSecurity
    configuration:
      apiVersion: pod-security.admission.config.k8s.io/v1
      kind: PodSecurityConfiguration
      defaults:
        enforce: "baseline"
        enforce-version: "v1.30"
      exemptions:
        # Por usuario: el sujeto que crea el pod queda exento
        usernames:
          - "system:serviceaccount:kube-system:daemon-set-controller"
        # Por clase de ejecución: los pods con este RuntimeClass quedan exentos
        runtimeClasses:
          - "gvisor"
        # Por namespace: nada del namespace se evalúa
        namespaces:
          - "kube-system"
          - "rutas-norte-sistema"
Criterio Cuándo usarlo Riesgo
namespaces Namespaces de infraestructura completos Medio: hay que vigilar quién puede desplegar ahí
usernames Un controlador concreto que crea pods de sistema Alto: esa identidad queda exenta en todo el clúster
runtimeClasses Pods que ya corren aislados (gVisor, Kata) Bajo: el aislamiento lo aporta el entorno de ejecución

Un aviso sobre usernames: exime al sujeto en todos los namespaces, no solo donde te interesa. Es un permiso muy amplio. La exención por namespace es más fácil de razonar y de auditar.

Y la advertencia general: cada exención es una excepción de seguridad. Debe tener justificación escrita, responsable, y una revisión periódica que confirme que sigue siendo necesaria. En Rutas Norte, la lista de exenciones se revisa cada trimestre junto con el RBAC.

  1. Las limitaciones del Pod Security Admission

El PSA es excelente en lo suyo, y lo suyo es bastante estrecho. Conocer sus límites evita confiar en él para cosas que no hace.

Limitación 1: solo mira el pod

El PSA evalúa objetos Pod. Punto. No mira Services, ni Ingresses, ni PVCs, ni ConfigMaps, ni NetworkPolicies.

Esto tiene un efecto práctico incómodo con enforce:

# Un Deployment con un pod que NO cumple restricted
kubectl apply -f deployment-malo.yaml
deployment.apps/aplicacion-mala created
kubectl get pods -n rutas-norte-pro -l app=aplicacion-mala
No resources found in rutas-norte-pro namespace.
kubectl describe replicaset -n rutas-norte-pro -l app=aplicacion-mala | tail -5
Events:
  Type     Reason        Age   From                   Message
  ----     ------        ----  ----                   -------
  Warning  FailedCreate  12s   replicaset-controller  Error creating: pods
  "aplicacion-mala-6c5f9b8d4-" is forbidden: violates PodSecurity "restricted:v1.30":
  allowPrivilegeEscalation != false (container "app" must set
  securityContext.allowPrivilegeEscalation=false), ...

El Deployment se crea correctamente, el ReplicaSet se crea, y es el ReplicaSet el que no consigue crear pods. Quien hizo el apply ve un éxito y tiene que ir a mirar los eventos para descubrir el problema.

Por eso warn es tan valioso: sí evalúa los objetos que crean pods y avisa en el momento del apply. Mantén siempre warn activo aunque tengas enforce, precisamente por este motivo.

Limitación 2: no puede exigir nada que no sea del securityContext

Cosas que el PSA no puede exigir:

No puede exigir Por qué importa en Rutas Norte
Etiquetas (app.kubernetes.io/part-of) Sin ellas se rompen los selectores y los cuadros de mando del módulo 7
resources.requests y limits Sin ellos, la QoS es BestEffort (03-05) y el pod es el primero en ser desalojado
Un registro de imágenes concreto Cualquiera puede desplegar desde Docker Hub sin revisión
Que la imagen no use latest Despliegues irreproducibles (08-05)
Sondas de salud (liveness, readiness) Sin ellas no funciona lo del módulo 7
readOnlyRootFilesystem Ni siquiera restricted lo exige
Que exista una NetworkPolicy La microsegmentación de 08-04
automountServiceAccountToken: false Lo que trabajamos en 03-06

Esa lista es exactamente el hueco que cubren Kyverno y Gatekeeper.

Limitación 3: los tres perfiles no son configurables

No puedes crear un perfil "restricted pero permitiendo hostPath en solo lectura" ni "restricted más readOnlyRootFilesystem". Los perfiles los define el proyecto Kubernetes y son inmutables.

Limitación 4: no muta

Es una virtud de diseño (evita el problema 3 de las PSP), pero significa que el PSA nunca te va a ayudar rellenando campos. Si quieres que a todo pod se le añada automáticamente seccompProfile: RuntimeDefault, necesitas un motor de política mutante.

Resumen

Necesidad Herramienta
Impedir pods privilegiados, hostPath, root PSA (integrado, sin instalar nada)
Exigir etiquetas, límites, registro de imágenes Kyverno o Gatekeeper
Rellenar campos automáticamente Kyverno (mutación)
Verificar firmas de imágenes Kyverno o el policy-controller de Sigstore (08-05)
Reglas sencillas sin instalar nada ValidatingAdmissionPolicy (apartado 10)

La recomendación práctica: PSA siempre, como línea base que no puede fallar (está en el apiserver, no depende de ningún pod), y encima un motor de política para todo lo demás.

  1. Motores de política general: Kyverno y OPA Gatekeeper

Un motor de política es un webhook de admisión: el apiserver le envía cada objeto y él responde si lo acepta, lo rechaza o lo modifica.

flowchart LR
    A["kubectl apply"] --> B["apiserver"]
    B --> C["PSA<br/>(integrado)"]
    C --> D["Webhook mutante<br/>Kyverno"]
    D --> E["Webhook validante<br/>Kyverno / Gatekeeper"]
    E -->|Rechaza| X["Error con el motivo"]
    E -->|Acepta| F[("etcd")]

Esto trae una consideración operativa muy seria: el motor de política se convierte en una dependencia del apiserver. Si el webhook está configurado con failurePolicy: Fail y los pods del motor están caídos, nadie puede crear nada en el clúster. Se han caído clústeres enteros así.

failurePolicy Si el webhook no responde Cuándo usarlo
Ignore El objeto se acepta Adopción inicial, políticas no críticas
Fail El objeto se rechaza Políticas de seguridad, con alta disponibilidad del motor

Las mitigaciones habituales: varias réplicas del motor con anti-afinidad (06-05), un PodDisruptionBudget (09-05), y excluir kube-system de los webhooks para poder recuperar el clúster.

Comparación: Kyverno y OPA Gatekeeper

Kyverno OPA Gatekeeper
Lenguaje de políticas YAML (como Kubernetes) Rego (lenguaje propio de OPA)
Curva de aprendizaje Baja: si sabes YAML, sabes escribir políticas Alta: Rego es un lenguaje declarativo distinto
Validar
Mutar , muy bien resuelto Sí, más limitado
Generar objetos (crear una NetworkPolicy con cada namespace) No
Verificar firmas de imágenes Sí, integrado con Cosign Requiere trabajo adicional
Limpieza de recursos Sí (CleanupPolicy) No
Informes de cumplimiento PolicyReport como CRD Objetos de violación
Alcance Solo Kubernetes Genérico: también CI, Terraform, APIs
Políticas prehechas Catálogo amplio y mantenido Biblioteca de plantillas
Comunidad y madurez CNCF, crecimiento rápido CNCF, más veterano
Rendimiento con muchas políticas Bueno Muy bueno

Recomendación para Rutas Norte: Kyverno. Las razones son concretas:

  • El equipo ya escribe YAML todo el día. Aprender Rego sería una barrera real para que las políticas las escriban también los desarrolladores.
  • La verificación de firmas con Cosign está integrada, y es justo lo que necesitaremos en 08-05.
  • La capacidad de generar objetos permite, por ejemplo, crear automáticamente la NetworkPolicy deny-all de 04-06 en cada namespace nuevo.
  • La mutación resuelve el "añade seccompProfile a todo" sin tocar cien manifiestos.

Gatekeeper es la mejor opción si la organización ya usa OPA para otras cosas (control de acceso en APIs, políticas en Terraform) y quiere un único lenguaje de políticas para todo.

Instalación de Kyverno

helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update

helm install kyverno kyverno/kyverno \
  --namespace kyverno --create-namespace \
  --set admissionController.replicas=3 \
  --set admissionController.podDisruptionBudget.minAvailable=2

Tres réplicas y un PDB: el motor de política es infraestructura crítica y hay que tratarla como tal.

  1. Políticas de Kyverno para Rutas Norte

Una política de Kyverno es un ClusterPolicy (todo el clúster) o Policy (un namespace) con una lista de reglas.

Política 1: todas las imágenes desde el registro de la empresa

Esta es probablemente la política de más valor por línea escrita. Impide que se despliegue nada que no haya pasado por el registro de la empresa, donde en 08-05 y 08-06 pondremos el escaneo y la firma.

# k8s/politicas/registro-obligatorio.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: registro-obligatorio
  annotations:
    policies.kyverno.io/title: Registro de imágenes obligatorio
    policies.kyverno.io/category: Seguridad de la cadena de suministro
    policies.kyverno.io/severity: high
    policies.kyverno.io/description: >-
      Toda imagen desplegada en los namespaces de Rutas Norte debe venir de
      registry.rutasnorte.example. Las imágenes públicas se replican allí
      previamente, donde se escanean y se firman. Ver lecciones 08-05 y 08-06.
spec:
  # Fail: si Kyverno no puede evaluar, se rechaza. Es una política de seguridad.
  validationFailureAction: Enforce
  background: true          # también evalúa objetos existentes, para los informes
  rules:
    - name: comprobar-registro
      match:
        any:
          - resources:
              kinds:
                - Pod
              namespaces:
                - rutas-norte-dev
                - rutas-norte-pre
                - rutas-norte-pro
      validate:
        message: >-
          La imagen "{{ request.object.spec.containers[0].image }}" no procede de
          registry.rutasnorte.example. Todas las imágenes deben replicarse en el
          registro de la empresa, donde se escanean y se firman.
          Consulta la guía de imágenes de la plataforma.
        pattern:
          spec:
            # El signo = indica "si el campo existe, debe cumplir"
            =(ephemeralContainers):
              - image: "registry.rutasnorte.example/*"
            =(initContainers):
              - image: "registry.rutasnorte.example/*"
            containers:
              - image: "registry.rutasnorte.example/*"

Desmenucemos el manifiesto:

  • validationFailureAction: Enforce rechaza. La alternativa es Audit, que solo genera un informe. Igual que con el PSA, se empieza en Audit y se pasa a Enforce cuando ya no hay incumplimientos.
  • background: true hace que Kyverno evalúe también los objetos que ya existen, produciendo PolicyReport. Sin esto solo verías los objetos nuevos.
  • match.any.resources define el ámbito. Aquí, pods de los tres namespaces de Rutas Norte. kyverno, kube-system y rutas-norte-sistema quedan fuera porque usan imágenes de infraestructura externas.
  • =(initContainers) y =(ephemeralContainers): el prefijo =() significa "si este campo existe, debe cumplir el patrón". Sin él, un pod sin initContainers fallaría la validación. No olvides los contenedores efímeros: son la vía de kubectl debug (07-06) y sin esta línea alguien podría inyectar una imagen arbitraria en un pod de producción.
  • El mensaje aparece literalmente en la terminal de quien despliega. Escribirlo bien —qué falla, por qué, y qué hacer— ahorra muchísimas preguntas.

Probando:

kubectl run prueba --image=nginx:latest -n rutas-norte-pro
Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:

resource Pod/rutas-norte-pro/prueba was blocked due to the following policies

registro-obligatorio:
  comprobar-registro: 'validation error: La imagen "nginx:latest" no procede de
    registry.rutasnorte.example. Todas las imágenes deben replicarse en el registro
    de la empresa, donde se escanean y se firman. Consulta la guía de imágenes de
    la plataforma. rule comprobar-registro failed at path /spec/containers/0/image/'

Política 2: exigir las etiquetas del esquema oficial

En 02-07 definimos el esquema de etiquetas de Rutas Norte, y en el módulo 7 los cuadros de mando y las alertas dependen de él. Una carga sin app.kubernetes.io/part-of: rutas-norte es invisible para la monitorización.

# k8s/politicas/etiquetas-obligatorias.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: etiquetas-obligatorias
  annotations:
    policies.kyverno.io/title: Esquema de etiquetas de Rutas Norte
    policies.kyverno.io/category: Gobierno
    policies.kyverno.io/severity: medium
spec:
  validationFailureAction: Enforce
  background: true
  rules:
    - name: etiquetas-en-cargas
      match:
        any:
          - resources:
              kinds:
                - Deployment
                - StatefulSet
                - DaemonSet
                - CronJob
                - Job
              namespaces:
                - rutas-norte-dev
                - rutas-norte-pre
                - rutas-norte-pro
      validate:
        message: >-
          Faltan etiquetas obligatorias. Toda carga de Rutas Norte debe llevar,
          en metadata y en la plantilla de pod: "app" (nombre del componente),
          "app.kubernetes.io/part-of: rutas-norte" y "entorno" (dev|pre|pro).
          Sin ellas, la carga no aparece en los cuadros de mando ni en las alertas.
        pattern:
          metadata:
            labels:
              app: "?*"                                  # ?* = no vacío
              app.kubernetes.io/part-of: "rutas-norte"   # valor exacto
              entorno: "dev | pre | pro"                 # una de las tres
          spec:
            template:
              metadata:
                labels:
                  app: "?*"
                  app.kubernetes.io/part-of: "rutas-norte"
                  entorno: "dev | pre | pro"

    - name: coherencia-entorno-namespace
      match:
        any:
          - resources:
              kinds: [Deployment, StatefulSet, DaemonSet, CronJob]
              namespaces: [rutas-norte-pro]
      validate:
        message: >-
          En rutas-norte-pro la etiqueta "entorno" debe valer "pro".
          Una etiqueta incorrecta hace que las alertas de producción se
          encaminen al canal equivocado (ver 07-04).
        pattern:
          metadata:
            labels:
              entorno: "pro"

Sintaxis de patrones de Kyverno que aparece aquí:

Sintaxis Significado
"?*" Cualquier valor no vacío
"rutas-norte" Ese valor exacto
"dev | pre | pro" Uno de esos tres (el | es un "o")
"registry.rutasnorte.example/*" Comodín al final
=(campo) Si el campo existe, debe cumplir
X(campo) El campo no debe existir

La segunda regla es un detalle que parece menor y no lo es: una etiqueta entorno: dev en un manifiesto copiado a producción hace que Alertmanager encamine las alertas al canal de desarrollo y nadie se entere de una caída real. La política lo impide.

Política 3: exigir límites de recursos

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: recursos-obligatorios
spec:
  validationFailureAction: Enforce
  background: true
  rules:
    - name: requests-y-limits
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces: [rutas-norte-pre, rutas-norte-pro]
      validate:
        message: >-
          Todo contenedor debe declarar requests y limits de CPU y memoria.
          Sin ellos la clase de QoS es BestEffort (ver 03-05) y el pod es el
          primero en ser desalojado cuando el nodo va justo de recursos.
        pattern:
          spec:
            containers:
              - resources:
                  requests:
                    cpu: "?*"
                    memory: "?*"
                  limits:
                    memory: "?*"

Fíjate en que exigimos limits.memory pero no limits.cpu. Es deliberado y es una decisión discutida: limitar la CPU provoca estrangulamiento (throttling) que puede degradar la latencia de api-reservas sin que se vea claro por qué, mientras que no limitar la memoria puede tumbar el nodo. Es un buen ejemplo de política que codifica una decisión técnica del equipo; lo trataremos a fondo en 09-06.

Política 4: mutar para añadir la línea base de seguridad

Aquí brilla Kyverno frente al PSA:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: anadir-linea-base-seguridad
spec:
  rules:
    - name: seccomp-por-defecto
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces: [rutas-norte-dev, rutas-norte-pre, rutas-norte-pro]
      mutate:
        patchStrategicMerge:
          spec:
            # +() significa "añade este valor SOLO si el campo no existe"
            +(securityContext):
              seccompProfile:
                type: RuntimeDefault

Con +(), si el manifiesto ya define securityContext, Kyverno no lo toca; si no lo define, lo añade. Es una red de seguridad, no una sustitución del trabajo de 08-02: lo correcto sigue siendo declararlo explícitamente en el manifiesto, para que quien lo lea sepa qué se está ejecutando.

Una advertencia sobre mutar: es exactamente el problema 3 de las PodSecurityPolicy. Úsalo con moderación y solo para valores por defecto seguros, nunca para "arreglar" manifiestos incorrectos, porque entonces el YAML del repositorio deja de describir lo que corre.

Política 5: generar la NetworkPolicy deny-all en cada namespace nuevo

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: generar-deny-all
spec:
  rules:
    - name: deny-all-en-namespaces-rutas-norte
      match:
        any:
          - resources:
              kinds: [Namespace]
              selector:
                matchLabels:
                  app.kubernetes.io/part-of: rutas-norte
      generate:
        apiVersion: networking.k8s.io/v1
        kind: NetworkPolicy
        name: deny-all
        namespace: "{{request.object.metadata.name}}"
        synchronize: true      # si alguien la borra, Kyverno la recrea
        data:
          spec:
            podSelector: {}
            policyTypes: [Ingress, Egress]

Esto garantiza que ningún namespace de Rutas Norte pueda existir sin la denegación por defecto que construimos en 04-06. Y synchronize: true hace que, si alguien la borra, vuelva sola. Es una capacidad que Gatekeeper no tiene y que resuelve un problema real.

Consultar los informes

kubectl get policyreport -n rutas-norte-pro
NAME                                   PASS   FAIL   WARN   ERROR   SKIP   AGE
polr-ns-rutas-norte-pro                 47     2      0      0       0      6d
kubectl get policyreport -n rutas-norte-pro -o json | jq -r '
  .items[].results[] | select(.result == "fail")
  | "\(.policy)/\(.rule): \(.resources[0].kind)/\(.resources[0].name)\n    \(.message)"'
etiquetas-obligatorias/etiquetas-en-cargas: Deployment/exportador-legado
    validation error: Faltan etiquetas obligatorias...
recursos-obligatorios/requests-y-limits: Pod/depuracion-temporal-x8k2p
    validation error: Todo contenedor debe declarar requests y limits...

Esos informes son la lista de deberes pendientes y, con background: true, se mantienen al día solos. En 08-06 los convertiremos en evidencia para auditorías de cumplimiento.

  1. ValidatingAdmissionPolicy: CEL nativo sin instalar nada

Desde Kubernetes 1.30, la ValidatingAdmissionPolicy permite escribir reglas de admisión dentro del apiserver, sin webhook, usando expresiones CEL (Common Expression Language).

Ventajas frente a un motor externo:

ValidatingAdmissionPolicy Webhook (Kyverno/Gatekeeper)
Instalación Ninguna Helm, pods, certificados
Disponibilidad La del apiserver Puede caerse y bloquear el clúster
Latencia Sin salto de red Una llamada HTTP por objeto
Mutación No (hay MutatingAdmissionPolicy en alfa)
Generar objetos No Kyverno sí
Verificar firmas No Kyverno sí
Expresividad CEL: buena para reglas de campos Muy alta

Ejemplo: prohibir la etiqueta latest

Se compone de dos objetos: la política (qué se comprueba) y el binding (dónde se aplica).

# k8s/politicas/vap-prohibir-latest.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: prohibir-etiqueta-latest
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: >-
        object.spec.containers.all(c,
          !c.image.endsWith(":latest") && c.image.contains(":"))
      message: >-
        Las imágenes no pueden usar la etiqueta "latest" y deben llevar una
        etiqueta explícita. Usa una etiqueta inmutable o, mejor, un digest
        (imagen@sha256:...). Ver la lección 08-05.
      reason: Invalid
    - expression: >-
        !has(object.spec.initContainers) ||
        object.spec.initContainers.all(c,
          !c.image.endsWith(":latest") && c.image.contains(":"))
      message: 'La misma regla aplica a los initContainers.'
      reason: Invalid
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: prohibir-etiqueta-latest
spec:
  policyName: prohibir-etiqueta-latest
  validationActions: [Deny]       # Deny | Warn | Audit, combinables
  matchResources:
    namespaceSelector:
      matchLabels:
        app.kubernetes.io/part-of: rutas-norte

Puntos a observar:

  • validationActions cumple el mismo papel que los modos del PSA: [Warn, Audit] para probar, [Deny] cuando estás seguro. Se pueden combinar: [Deny, Audit].
  • namespaceSelector aplica la política solo a los namespaces etiquetados como parte de Rutas Norte. Los de sistema quedan fuera.
  • CEL tiene funciones muy legibles: all(), exists(), has(), endsWith(), contains(), matches() para expresiones regulares.
  • Separar política y binding permite escribir la regla una vez y aplicarla con distinta severidad en distintos entornos: [Warn] en dev y [Deny] en pro, con dos bindings.

Prueba:

kubectl run prueba --image=registry.rutasnorte.example/utilidades:latest -n rutas-norte-pro
The pods "prueba" is invalid: ValidatingAdmissionPolicy 'prohibir-etiqueta-latest'
with binding 'prohibir-etiqueta-latest' denied request: Las imágenes no pueden usar
la etiqueta "latest" y deben llevar una etiqueta explícita. Usa una etiqueta inmutable
o, mejor, un digest (imagen@sha256:...). Ver la lección 08-05.

Ejemplo con parámetros

Las VAP pueden leer configuración de un recurso externo, lo que las hace reutilizables:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: limitar-replicas
spec:
  failurePolicy: Fail
  paramKind:
    apiVersion: v1
    kind: ConfigMap
  matchConstraints:
    resourceRules:
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments"]
  validations:
    - expression: >-
        object.spec.replicas <= int(params.data.maxReplicas)
      messageExpression: >-
        "El número de réplicas (" + string(object.spec.replicas) +
        ") supera el máximo permitido (" + params.data.maxReplicas + ")."

messageExpression construye el mensaje con los valores reales, algo que se agradece mucho al depurar.

Criterio de uso

Usa VAP para reglas sencillas sobre campos: prohibir latest, exigir una etiqueta, limitar un valor numérico, comprobar un prefijo. Son gratis en disponibilidad y no añaden ninguna pieza que mantener.

Usa Kyverno para lo que VAP no puede: verificar firmas de imágenes (08-05), generar objetos, mutar, o políticas que necesitan consultar otros recursos del clúster.

Y usa el PSA siempre como línea base de securityContext, porque está integrado y no puede fallar.

En Rutas Norte usamos los tres, en capas:

flowchart TD
    A["Todo pod que se crea"] --> B["PSA: restricted<br/>línea base de securityContext<br/>(integrado, infalible)"]
    B --> C["VAP: reglas simples<br/>sin latest, campos obligatorios<br/>(integrado)"]
    C --> D["Kyverno: registro, firmas,<br/>etiquetas, recursos, generación<br/>(webhook)"]
    D --> E["Pod admitido"]
    style B fill:#d5f9d5,stroke:#3a3
    style C fill:#d5e8f9,stroke:#36c
    style D fill:#f9f0d5,stroke:#ca3

Errores Comunes y Consejos

Buscar tutoriales de PodSecurityPolicy. Se eliminaron en 1.25. Si un recurso te habla de kind: PodSecurityPolicy, está desactualizado y probablemente todo lo demás también.

Activar enforce: restricted directamente en producción. Los pods existentes siguen corriendo, pero el primer reinicio o la primera actualización falla. Recorre siempre las fases: warn/audit, luego baseline, luego restricted.

No fijar -version en las etiquetas. Sin ella se usa latest y una actualización del clúster puede rechazar pods que antes pasaban. Fija la versión y súbela como cambio explícito.

Creer que enforce avisa al aplicar un Deployment. No: el PSA solo evalúa pods. El Deployment se crea y es el ReplicaSet el que falla, en un evento que hay que ir a buscar. Mantén siempre warn activo, que sí evalúa los objetos que crean pods.

Dejar namespaces sin etiquetar. Un namespace sin etiquetas de PSA equivale a privileged. Comprueba default y cualquier namespace creado a mano; configura el valor por defecto en el apiserver o exige las etiquetas con Kyverno.

Relajar el perfil de todo un namespace por una carga. Si el recolector de logs necesita hostPath, va a rutas-norte-sistema con perfil privileged, no se baja rutas-norte-pro a baseline.

Olvidar el RBAC del namespace privilegiado. Un namespace con perfil privileged donde desarrollo pueda crear pods es peor que no tener PSA: da una falsa sensación de seguridad. Y recuerda de 08-01 que crear pods ahí permite usar cualquier ServiceAccount de ese namespace.

Usar failurePolicy: Fail sin alta disponibilidad del motor. Si Kyverno cae, nadie puede crear nada. Tres réplicas, PDB y anti-afinidad como mínimo, y excluye kube-system.

Olvidar initContainers y ephemeralContainers en las políticas. Un initContainer con una imagen sin verificar se salta toda la política de registro. Los contenedores efímeros son la vía de kubectl debug.

Poner validationFailureAction: Enforce desde el primer día. Igual que con el PSA: primero Audit, mira los PolicyReport, arregla, y luego Enforce.

Abusar de la mutación. Si Kyverno "arregla" los manifiestos, el YAML del repositorio deja de describir lo que corre. Úsala solo para valores por defecto seguros.

Mensajes de error inútiles. El mensaje lo lee una persona que está intentando desplegar. Dile qué falla, por qué existe la regla y qué tiene que hacer. Un buen mensaje ahorra una interrupción al equipo de plataforma.

Consejo de oro: las políticas son código. Van en k8s/politicas/ del repositorio, se revisan en pull request, y se prueban en rutas-norte-dev antes de llegar a producción. Una política aplicada a mano en producción es exactamente el mismo problema que un RoleBinding aplicado a mano.

Ejercicios

Ejercicio 1: preparar un namespace para restricted

El equipo crea rutas-norte-analitica para desplegar un servicio de agregación de estadísticas de ocupación. Escribe:

  1. El manifiesto del Namespace en la fase 1 (avisar sin bloquear) con la versión fijada.
  2. La orden que evaluaría un manifiesto existente contra el perfil sin desplegarlo.
  3. El manifiesto del Namespace en su estado final, dado que el servicio ya está endurecido.

Ejercicio 2: interpretar y corregir un rechazo

Al desplegar un componente nuevo en rutas-norte-pro, que tiene enforce: restricted, obtienes:

Error from server (Forbidden): error when creating "exportador.yaml":
pods "exportador-horarios" is forbidden: violates PodSecurity "restricted:v1.30":
non-default capabilities (container "exportador" must not include "NET_RAW" in
securityContext.capabilities.add), restricted volume types (volume "config-nodo"
uses restricted volume type "hostPath"), runAsNonRoot != true (pod or container
"exportador" must set securityContext.runAsNonRoot=true), seccompProfile
(pod or container "exportador" must set securityContext.seccompProfile.type
to "RuntimeDefault" or "Localhost")

El manifiesto original:

apiVersion: v1
kind: Pod
metadata:
  name: exportador-horarios
  namespace: rutas-norte-pro
spec:
  containers:
    - name: exportador
      image: registry.rutasnorte.example/exportador-horarios:2.1.0
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]
          add: ["NET_RAW"]
      volumeMounts:
        - name: config-nodo
          mountPath: /etc/horarios
  volumes:
    - name: config-nodo
      hostPath:
        path: /etc/rutasnorte/horarios
        type: Directory
  1. Enumera las cuatro violaciones y explica qué significa cada una.
  2. Reescribe el manifiesto para que cumpla restricted, sabiendo que NET_RAW se añadió "por si acaso" y que el fichero de /etc/rutasnorte/horarios es en realidad un fichero de configuración estático.
  3. ¿Qué habrías hecho si NET_RAW fuese realmente imprescindible?

Ejercicio 3: política de Kyverno para automountServiceAccountToken

En 03-06 establecimos que las cargas que no hablan con la API deben llevar automountServiceAccountToken: false. Escribe un ClusterPolicy de Kyverno que:

  • Se aplique a Pods de rutas-norte-pre y rutas-norte-pro.
  • Rechace los pods que no declaren automountServiceAccountToken explícitamente (ni a true ni a false), obligando a que sea una decisión consciente.
  • Exceptúe los pods cuya ServiceAccount sea api-reservas o agente-inventario, que sí necesitan el token.
  • Incluya un mensaje que explique qué hacer.

Indica también cómo lo desplegarías de forma segura.

Soluciones

Solución 1

1. Fase 1: avisar sin bloquear

# k8s/entornos/analitica/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: rutas-norte-analitica
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
    # Fase 1: NO se pone enforce. Solo observamos.
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: v1.30
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: v1.30
  annotations:
    seguridad.rutasnorte.example/fase-psa: >-
      Fase 1 (warn+audit) desde 2026-08-06. Objetivo: enforce:restricted
      antes de 2026-09-01. Responsable: equipo de plataforma.

La anotación con fecha objetivo evita que "temporal" se convierta en "permanente", que es como acaban casi todas las fases 1.

2. Evaluar sin desplegar

kubectl apply -f k8s/entornos/analitica/agregador.yaml --dry-run=server
Warning: would violate PodSecurity "restricted:v1.30": allowPrivilegeEscalation != false
(container "agregador" must set securityContext.allowPrivilegeEscalation=false),
unrestricted capabilities (container "agregador" must set
securityContext.capabilities.drop=["ALL"])
deployment.apps/agregador created (server dry run)

--dry-run=server pasa el objeto por toda la cadena de admisión —PSA, VAP y Kyverno incluidos— y devuelve el veredicto sin guardar nada. Es distinto de --dry-run=client, que solo valida el YAML localmente y no detectaría nada de esto.

3. Estado final

apiVersion: v1
kind: Namespace
metadata:
  name: rutas-norte-analitica
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.30
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: v1.30
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: v1.30

Se mantienen audit y warn aunque enforce ya bloquee: warn sigue evaluando los Deployments (que enforce no ve) y audit deja constancia en el registro de auditoría.

Solución 2

1. Las cuatro violaciones:

Violación Significado
non-default capabilities: NET_RAW restricted solo permite volver a añadir NET_BIND_SERVICE. NET_RAW (sockets en bruto) está prohibido
restricted volume types: hostPath hostPath no está en la lista de volúmenes permitidos por restricted. Es la formalización de la advertencia de 05-01
runAsNonRoot != true No se declaró; restricted exige la declaración explícita, no basta con que la imagen tenga un USER
seccompProfile Falta RuntimeDefault. Recuerda de 08-02 que sin declararlo probablemente no hay ningún filtro

2. Manifiesto corregido:

# k8s/entornos/pro/exportador-horarios.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: horarios-config
  namespace: rutas-norte-pro
  labels:
    app: exportador-horarios
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
data:
  horarios.yaml: |
    lineas:
      - codigo: N-101
        origen: Bilbao
        destino: Santander
        salidas: ["07:00", "10:30", "15:00", "19:45"]
---
apiVersion: v1
kind: Pod
metadata:
  name: exportador-horarios
  namespace: rutas-norte-pro
  labels:
    app: exportador-horarios
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
spec:
  automountServiceAccountToken: false
  securityContext:
    runAsNonRoot: true                # violación 3
    runAsUser: 10005
    runAsGroup: 10005
    seccompProfile:
      type: RuntimeDefault            # violación 4
  containers:
    - name: exportador
      image: registry.rutasnorte.example/exportador-horarios:2.1.0
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true  # no lo exige restricted, pero es lo correcto
        capabilities:
          drop: ["ALL"]               # violación 1: sin el add de NET_RAW
      volumeMounts:
        - name: horarios              # violación 2: ConfigMap en lugar de hostPath
          mountPath: /etc/horarios
          readOnly: true
        - name: temporal
          mountPath: /tmp
      resources:
        requests: { cpu: 50m, memory: 64Mi }
        limits:   { memory: 128Mi }
  volumes:
    - name: horarios
      configMap:
        name: horarios-config
    - name: temporal
      emptyDir: { sizeLimit: 32Mi }

Los cuatro cambios y su justificación:

  1. NET_RAW eliminado. Era "por si acaso": permite crear sockets en bruto, lo que sirve para hacer ping y para inspeccionar tráfico. Un exportador de horarios no lo necesita. Si el proceso falla al arrancar, el log lo dirá y entonces investigaremos por qué; no se conceden capacidades preventivamente.
  2. hostPath sustituido por un ConfigMap. El fichero era configuración estática, exactamente para lo que sirve un ConfigMap (03-01). Además de cumplir la política, esto gana: el fichero deja de depender de que alguien lo haya copiado a cada nodo, queda versionado en Git y es idéntico en los tres entornos. La corrección de la política ha mejorado el diseño, que es lo que suele ocurrir.
  3. runAsNonRoot: true + runAsUser: 10005 declarados explícitamente.
  4. seccompProfile: RuntimeDefault en el pod, heredado por el contenedor.

Extras que no exigía restricted pero sí las políticas de Kyverno del apartado 9: etiquetas del esquema, resources y automountServiceAccountToken: false. Y readOnlyRootFilesystem: true, que ninguna política exige pero es la línea base de 08-02.

3. Si NET_RAW fuese imprescindible:

El proceso sería este, en orden:

  1. Cuestionar el requisito. ¿Por qué necesita sockets en bruto un exportador de horarios? En el 90 % de los casos hay una alternativa (una comprobación TCP en lugar de ICMP, por ejemplo).
  2. Si es real, usar una biblioteca que no lo necesite o cambiar el diseño.
  3. Si no hay alternativa, no relajar rutas-norte-pro. Se despliega en un namespace propio con enforce: baseline (que sí permite NET_RAW), con RBAC restringido, con justificación anotada, con revisión trimestral y con aprobación del profesional de seguridad.
  4. Documentar la excepción en el registro de excepciones de seguridad, con fecha de caducidad. Volveremos a este registro en 08-06.

Lo que nunca se hace es bajar rutas-norte-pro a baseline: eso convierte una excepción de un componente en una excepción de toda la plataforma.

Solución 3

# k8s/politicas/token-serviceaccount-explicito.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: token-serviceaccount-explicito
  annotations:
    policies.kyverno.io/title: Decisión explícita sobre el token de ServiceAccount
    policies.kyverno.io/category: Seguridad
    policies.kyverno.io/severity: medium
    policies.kyverno.io/description: >-
      Todo pod debe declarar automountServiceAccountToken de forma explícita.
      Montar el token sin necesitarlo entrega una credencial de la API a un
      proceso que no la usa (ver 03-06). Las cargas que sí hablan con la API
      están exceptuadas por nombre de ServiceAccount.
spec:
  validationFailureAction: Enforce
  background: true
  rules:
    - name: exigir-declaracion-explicita
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces:
                - rutas-norte-pre
                - rutas-norte-pro
      exclude:
        any:
          # Cargas que legítimamente necesitan el token
          - resources:
              subjects:
                - kind: ServiceAccount
                  name: api-reservas
                - kind: ServiceAccount
                  name: agente-inventario
      preconditions:
        all:
          # No aplicar a los pods que crea el propio sistema
          - key: "{{ request.object.metadata.namespace }}"
            operator: AnyIn
            value: ["rutas-norte-pre", "rutas-norte-pro"]
      validate:
        message: >-
          El pod debe declarar spec.automountServiceAccountToken de forma
          explícita. Si la carga NO habla con la API de Kubernetes (lo habitual),
          pon "automountServiceAccountToken: false". Si sí lo hace, ponlo a true
          y asegúrate de que su ServiceAccount tiene un Role con el mínimo
          privilegio (ver 08-01 y 03-06).
        pattern:
          spec:
            automountServiceAccountToken: "false | true"

Notas sobre la solución:

  • El patrón "false | true" obliga a que el campo exista con uno de esos dos valores. Si el campo no está, la validación falla, que es justo lo que pedía el enunciado: convertir una omisión en una decisión.
  • exclude.any.resources.subjects permite exceptuar por ServiceAccount. Una alternativa más mantenible a largo plazo sería exceptuar por una etiqueta (seguridad.rutasnorte.example/usa-api: "true"), para no tener que editar la política cada vez que una carga nueva necesite la API.
  • Es una política de gobierno, no de bloqueo de un ataque: su valor está en forzar que alguien piense en cada despliegue.

Despliegue seguro, en cuatro pasos:

# 1. Aplicar en modo Audit: no bloquea nada
sed 's/validationFailureAction: Enforce/validationFailureAction: Audit/' \
  k8s/politicas/token-serviceaccount-explicito.yaml | kubectl apply -f -

# 2. Esperar a que el análisis en segundo plano complete el informe y revisarlo
kubectl get policyreport -A -o json | jq -r '
  .items[].results[]
  | select(.policy == "token-serviceaccount-explicito" and .result == "fail")
  | "\(.resources[0].namespace)/\(.resources[0].name)"'
rutas-norte-pro/worker-notificaciones-6d4f8b9c7-k2m4x
rutas-norte-pro/informes-ocupacion-29187360-9wzqt
rutas-norte-pre/tienda-web-7c8d9f6b5-p3n8v
# 3. Corregir los manifiestos de esas cargas (añadir el campo explícito)
#    y verificar que el informe queda limpio.

# 4. Solo entonces, pasar a Enforce
kubectl apply -f k8s/politicas/token-serviceaccount-explicito.yaml

Y la verificación final:

kubectl run prueba --image=registry.rutasnorte.example/utilidades:1.4.2 -n rutas-norte-pro
Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:

resource Pod/rutas-norte-pro/prueba was blocked due to the following policies

token-serviceaccount-explicito:
  exigir-declaracion-explicita: 'validation error: El pod debe declarar
    spec.automountServiceAccountToken de forma explícita...'

Una precaución adicional importante: antes de pasar a Enforce, comprueba que la política no bloquea a los controladores del sistema que crean pods en esos namespaces (el controlador de ReplicaSets, el de Jobs). Si un controlador crea pods sin el campo, tus despliegues dejarán de funcionar. Probarlo primero en rutas-norte-dev con Enforce durante unos días es la forma barata de descubrirlo.

Conclusión

Hemos convertido el endurecimiento de 08-02 en algo que el clúster hace cumplir por sí solo:

  • El control de admisión es la tercera puerta: RBAC decide si tienes derecho a la operación, la admisión decide si el objeto es aceptable. El objeto rechazado nunca llega a etcd.
  • Las PodSecurityPolicy son historia: obsoletas en 1.21, eliminadas en 1.25. Sus tres defectos —RBAC contraintuitivo, orden alfabético impredecible y mutación— explican el diseño de su sustituto.
  • Los Pod Security Standards definen tres perfiles: privileged (sin restricciones), baseline (bloquea las escaladas conocidas) y restricted (el objetivo para toda aplicación), que es esencialmente la codificación oficial de lo que hicimos a mano en 08-02.
  • El Pod Security Admission va integrado en el apiserver y se activa con etiquetas en el namespace, con tres modos —enforce, audit, warn— que permiten la adopción gradual. Fija siempre -version para que una actualización del clúster no rompa nada.
  • La adopción correcta es por fases: warn+audit, luego enforce: baseline, luego enforce: restricted. Nunca de golpe en producción. Y rutas-norte-pre debe ser tan estricto como rutas-norte-pro.
  • Las cargas que legítimamente necesitan privilegios —CNI, CSI, recolector de logs— van a un namespace aparte con perfil privileged, RBAC restringido y justificación anotada. Nunca se relaja el perfil de producción por una carga.
  • El PSA tiene límites claros: solo mira el pod, no puede exigir etiquetas, límites de recursos ni registros de imágenes, y no muta.
  • Ese hueco lo cubren Kyverno (YAML, muta, genera, verifica firmas: la elección de Rutas Norte) y OPA Gatekeeper (Rego, genérico más allá de Kubernetes), y las ValidatingAdmissionPolicy con CEL cubren las reglas sencillas sin instalar nada.
  • Rutas Norte usa los tres en capas: PSA como base infalible, VAP para reglas simples, Kyverno para el registro obligatorio, las etiquetas del esquema, los límites de recursos y la generación automática de la NetworkPolicy deny-all.

Ahora el clúster rechaza por sí mismo un pod privilegiado, uno que monte hostPath, uno que corra como root o uno cuya imagen no venga del registro de la empresa. Nadie puede saltárselo por olvido.

Pero volvamos a mirar el conjunto. Hemos protegido quién puede hacer qué (08-01) y qué puede hacer un contenedor (08-02 y 08-03). Falta una dimensión entera: qué puede hablar con qué. En 04-06 pusimos una NetworkPolicy deny-all en rutas-norte-pro y autorizamos las conversaciones una a una, y ya entonces señalamos dos límites incómodos: las políticas trabajan en L3/L4 —no entienden de rutas HTTP ni de métodos— y no registran nada, así que un intento de conexión denegado es invisible. Además, dentro del clúster todo el tráfico viaja sin cifrar una vez pasado el TLS del Ingress: quien pueda observar la red del nodo ve las consultas a postgres-reservas en claro, con los datos personales incluidos. Y el tráfico saliente sigue prácticamente sin restringir, que es la vía natural por la que se exfiltra una base de datos de clientes.

La siguiente lección, 08-04, Seguridad de Red, construye la estrategia completa sobre lo que ya sabes: microsegmentación, control del tráfico de salida y el problema de las IP frente a los nombres de dominio, cifrado en tránsito con mTLS y mallas de servicio —Istio, Linkerd y Cilium comparadas, con el criterio honesto de cuándo hace falta una y cuándo no—, protección del perímetro y del plano de control, y la visibilidad del tráfico que es justo lo que le falta a NetworkPolicy.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados