Pod Security Admission
RBAC decide quién puede crear un Pod. No dice nada sobre cómo puede ser ese Pod. Y un desarrollador con permiso legítimo para desplegar en su namespace puede pedir, sin pretender nada malo, un contenedor privileged: true que monta el sistema de archivos del nodo. Ese Pod ya no está dentro del clúster: está por encima de él.
La respuesta integrada en Kubernetes se llama Pod Security Admission, y aplica los Pod Security Standards, tres niveles con nombres deliberadamente aburridos:
- privileged: no prohíbe nada. Es la ausencia de política.
- baseline: prohíbe las escaladas obvias (contenedores privilegiados,
hostNetwork,hostPID, montar rutas del nodo). El mínimo civilizado. - restricted: además exige buenas prácticas (no correr como root, sin escalada de privilegios, sin capabilities, seccomp activo). Es el objetivo al que aspirar.
Lo mejor de todo: no se instala nada. Es un controlador de admisión que ya está en el API server y se activa etiquetando un namespace.
Trabaja desde la pestaña dev-machine.
Paso 1: Hardening del Namespace tienda
kubectl create namespace tienda
kubectl label namespace tienda \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted
kubectl get namespace tienda --show-labels
¿Por qué tres etiquetas y no una?
Porque son los tres modos de la admisión, y se pueden mezclar:
enforce: rechaza el Pod. Es el único con consecuencias.warn: lo deja pasar pero devuelve un aviso en la terminal de quien lo aplica.audit: lo deja pasar y lo anota en el log de auditoría del clúster.
La combinación útil de verdad, y el motivo por el que existen los tres, es esta: para endurecer un namespace vivo se empieza con warn y audit en restricted mientras enforce sigue en baseline. Así descubres qué se rompería sin romper nada, y solo cuando el ruido baja a cero subes el enforce.
Paso 2: El Pod que no pasa
Intenta crear el Pod más inocente del mundo, el mismo que llevas creando desde la lección 1:
kubectl run rebelde --image=ghcr.io/iximiuz/labs/nginx:alpine -n tienda
Lee el error con calma, porque es una de las salidas más didácticas de todo Kubernetes: la API enumera todas las violaciones, una por una (allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile). No se ha creado nada. No hay un Pod en Pending ni en CrashLoopBackOff esperando a que alguien lo diagnostique: el objeto no llegó a existir. Es el mismo mecanismo de admisión que volverás a ver en el módulo de Policies, y la diferencia clave con un Pending del scheduler, donde el objeto sí existe.
Y ahora prueba el mismo comando en el namespace default:
kubectl run rebelde --image=ghcr.io/iximiuz/labs/nginx:alpine
kubectl delete pod rebelde
Entra sin una queja. La admisión es por namespace: la seguridad se aplica donde tú digas, y ese es también su punto ciego.
💡 La admisión no es retroactiva: los Pods que ya existían siguen corriendo tan tranquilos. Antes de subir el enforce de un namespace vivo, comprueba qué se rompería con una simulación contra el servidor: kubectl label --dry-run=server ns <nombre> pod-security.kubernetes.io/enforce=restricted. La API te devuelve la lista de Pods existentes que violarían el nivel, sin cambiar nada.
Paso 3: El manifiesto que sí pasa
Toca vestir al Pod. Crea api.yaml:
cat << 'EOF' > api.yaml
apiVersion: v1
kind: Pod
metadata:
name: api
namespace: tienda
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: ghcr.io/iximiuz/labs/nginx:alpine
command: ["sh", "-c", "sleep infinity"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
EOF
El YAML, explicado con preguntas y respuestas
¿Hay dos securityContext? ¿Cuál manda?
Sí, y conviene tenerlo claro: el del Pod fija los valores por defecto para todos sus contenedores (identidad del usuario, seccomp, grupos), y el del contenedor los afina y gana en caso de conflicto. Algunas opciones solo existen en uno de los dos niveles: capabilities y readOnlyRootFilesystem son por contenedor; fsGroup y runAsNonRoot tienen sentido a nivel de Pod.
¿Qué significa runAsNonRoot: true frente a runAsUser: 1000?
runAsUser fija el UID. runAsNonRoot es una verificación: si la imagen intentara arrancar como root, el kubelet se niega a ejecutarla. Se ponen los dos porque cubren cosas distintas: uno declara la intención, el otro la defiende aunque la imagen cambie.
¿Qué es allowPrivilegeEscalation: false?
Impide que un proceso obtenga más privilegios que su padre (el mecanismo de los binarios setuid). Es una de las líneas más rentables que existen: cierra una vía entera de escalada sin cambiar una coma de la aplicación.
¿Por qué drop: [ALL] en capabilities?
Porque un contenedor arranca con un puñado de capabilities de Linux que casi ninguna aplicación usa. restricted exige descartarlas todas; si alguna hace falta de verdad (la típica es NET_BIND_SERVICE, para escuchar en puertos por debajo del 1024), se descartan todas y se vuelve a añadir esa sola con add.
¿Qué es seccompProfile: RuntimeDefault?
Activa el filtro de llamadas al sistema que trae el runtime de contenedores, que bloquea las syscalls peligrosas y raramente necesarias. Está ahí desde hace años, es gratis, y hasta que restricted lo exigió casi nadie lo activaba.
¿Y readOnlyRootFilesystem: true?
No lo exige restricted, pero es de las mejores prácticas que existen: el contenedor no puede escribir en su propio sistema de archivos. Si la aplicación necesita escribir en algún sitio, se le monta un emptyDir en esa ruta concreta, como aprendiste en el módulo de almacenamiento. Un atacante que consiga ejecución dentro del contenedor no puede ni dejar un fichero.
¿Por qué este contenedor ejecuta sleep y no nginx?
Por una lección incómoda: el nginx de esta imagen quiere escribir en su caché y escuchar en el puerto 80, y ninguna de las dos cosas es posible como usuario 1000 con el sistema de archivos en solo lectura. Las imágenes tienen que estar preparadas para correr sin privilegios (escuchar en un puerto alto, escribir solo en rutas montadas). Cuando restricted rompe un despliegue, la culpa casi nunca es de la política: es de una imagen que se construyó dando por hecho que sería root.
Aplícalo:
kubectl apply -f api.yaml
kubectl wait --for=condition=Ready pod/api -n tienda --timeout=60s
kubectl get pod api -n tienda
kubectl exec api -n tienda -- id
Ese id devuelve uid=1000. El Pod corre, y no es root.
Resumen
- Tres niveles (
privileged,baseline,restricted) y tres modos (enforce,warn,audit), activados con etiquetas en el namespace. - El rechazo ocurre en la admisión: el Pod no llega a existir. Y no es retroactivo.
- El camino profesional para endurecer un namespace vivo:
warnyauditprimero,enforcecuando el ruido sea cero. - El manifiesto mínimo para pasar
restricted:runAsNonRoot,seccompProfile: RuntimeDefault,allowPrivilegeEscalation: falseycapabilities.drop: [ALL]. - Si
restrictedrompe tu aplicación, sospecha de la imagen antes que de la política.
- Previous lesson
- Namespaces, RBAC y ServiceAccounts
- Next lesson
- ResourceQuotas y LimitRanges