
🌐 También en: English · Deutsch · Français
🇸🇪 He vuelto de Suecia. Bronceado: inexistente (es Suecia). Número de picaduras de mosquito: ilegal. Ganas de volver a meterle mano a mi homelab: por las nubes. Dos semanas de descanso forzoso sin pantallas en medio de un bosque tienen algo que hace que llegues a casa y te entren ganas inmediatas de hacerssh a un clúster de Kubernetes un miércoles a las 11 de la noche. No me diagnostiques. 💻 Así que estas últimas noches he hecho lo que haría cualquier persona equilibrada después de unas vacaciones: 📚 empaparme de material de seguridad en Kubernetes, en concreto del temario del Certified Kubernetes Security Specialist (CKS). Que quede claro: ahora mismo no tengo intención de presentarme al examen; simplemente me estoy friqueando con el tema porque es realmente interesante y tiene que ver directamente con un clúster que tengo en producción (bueno, «producción»: es un blog). El CKS es el temario del «vale, pero ¿sabes asegurar el cacharro de verdad?» en el mundo Kubernetes: bastionado de RBAC, cadena de suministro, seguridad en tiempo de ejecución, network policies, todo el bufé de «cosas que están bien hasta que dejan de estarlo, y de qué manera». Y en algún punto de esa madriguera redescubrí una herramienta que había usado una vez hace años y de cuya existencia me había olvidado por completo: kube-bench 🔍.🧐 ¿Qué es kube-bench y qué es eso de un CIS Benchmark?
kube-bench es un pequeño binario en Go de Aqua Security que comprueba la configuración de tu clúster de Kubernetes contra el CIS Kubernetes Benchmark: una lista de comprobación enorme, aburrida y tremendamente útil publicada por el Center for Internet Security, que dice cosas como «oye, el registro de auditoría de tu servidor API probablemente debería estar activado» o «tus certificados de kubelet no deberían ser legibles por cualquiera, genio». No hackea nada, no busca CVE; simplemente recorre cientos de comprobaciones de configuración muy concretas y muy puntillosas (permisos de ficheros, flags del servidor API, bindings de RBAC, ajustes de seguridad de pods) y te dice PASS, FAIL o WARN para cada una. Lo bueno de kube-bench en particular es que trae perfiles adaptados a cada distribución. Un clúster kubeadm estándar no se parece a EKS, que no se parece a GKE, que no se parece nada a k3s (mi distribución favorita para este homelab, porque ¿quién tiene RAM para un control plane «de verdad»?). Lo ejecuté con el perfilk3s-cis-1.9 contra el clúster k3s de un solo nodo que ahora mismo aloja, bueno, precisamente este blog.🚦 Primer asalto: 55 pass, 4 fail, 57 warn
La primera pasada devolvió ✅ 55 PASS, ❌ 4 FAIL, ⚠️ 57 WARN, ℹ️ 14 INFO. Nada catastrófico, pero tampoco es poca cosa. Un apunte rápido antes de seguir: la mayoría de esos 57 WARN son comprobaciones que kube-bench marca como «Manual», es decir, la herramienta literalmente no puede verificarlas de forma automática y siempre muestra WARN por muy bien configurado que lo tengas, porque hace falta que un humano mire la cosa y use su criterio. Así que un número alto de WARN no significa automáticamente «58 problemas», sino más bien «58 cosas a las que un humano debería echar un vistazo, y algunas ya están bien». Eché un vistazo a un montón. Más sobre eso abajo. 👇 Aquí va el recorrido por lo que se arregló, lo que resultó no ser un problema y, como ningún cambio de infraestructura sobrevive al contacto con la realidad, un par de formas realmente graciosas en que rompí cosas mientras arreglaba otras. 🙃1️⃣ 🔐 Certificados con los permisos de un banco del parque
k3s escribe sus certificados PKI internos (los de/var/lib/rancher/k3s/server/tls/) como 644, legibles por todo el mundo. El CIS quiere 600. Para ser justos, son los certificados públicos, no las claves privadas (esas ya estaban bien protegidas), así que el riesgo real se acercaba más a «un poco desordenado» que a «agujero enorme». Lo arreglé con un chmod y, como k3s regenera de vez en cuando algunos de ellos al reiniciar, hice el arreglo idempotente en el rol de Ansible para que se vuelva a aplicar en cada ejecución del playbook en lugar de revertirse sin hacer ruido.2️⃣ 🎫 Tokens de ServiceAccount montados en pods que jamás van a llamar a la API de Kubernetes
Esto es un clásico. Por defecto, a cada pod se le monta automáticamente un token de la API de Kubernetes en su sistema de ficheros, «por si acaso». Mis pods de WordPress, MariaDB y Redis no hablan nunca con la API de Kubernetes en toda su vida: sirven un blog, guardan filas y cachean cosas, respectivamente. Pero todos tenían un token de API válido, aunque con pocos privilegios, en/var/run/secrets/kubernetes.io/serviceaccount/, en plan «¿por qué no?». Si algún atacante llegara a comprometer uno de esos contenedores (pongamos, mediante una RCE en algún plugin dudoso; es WordPress, pasa 😅), ese token habría sido terreno gratis para hacer reconocimiento. Puse automountServiceAccountToken: false en todos los sitios donde no hacía falta: en los propios pods y, como descubrí a las malas, también en los objetos ServiceAccount subyacentes, porque la comprobación automática de kube-bench por lo visto inspecciona el ServiceAccount y no solo si algún pod lo sobrescribe. Ya puestos, lo parcheé en todo el clúster para cada namespace que no fuera del sistema. 🧹3️⃣ 🕵️ A fondo con RBAC: esperaba encontrar algo y no encontré nada
El CIS tiene todo un bloque de comprobaciones sobre minimización de RBAC: permisos con comodines, quién puede suplantar a quién, quién puede crear pods, etc. Entré esperando encontrar al menos un binding demasiado amplio que alguien (yo, hace seis meses, a la 1 de la madrugada 🌙) hubiera dejado tirado por ahí. Pues no. Cada rol personalizado del clúster resultó ser o bien un rol por defecto de Kubernetes sin usar en absoluto (admin/edit, sin vincular a nadie), o bien un rol instalado por un chart de Helm (cert-manager, ingress-nginx) ajustado exactamente a lo que ese componente necesita y nada más. Da mucho gusto confirmarlo en lugar de suponerlo. ✨4️⃣ 🧱 NetworkPolicies: la que llevaba tiempo aplazando
Un poco de historia divertida del homelab: mi stack de Nextcloud lleva siglos con NetworkPolicies aplicadas mediante Calico, pero el stack de WordPress/blog nunca recibió el mismo trato. El razonamiento en su momento fue que Collabora (el editor de documentos de Nextcloud) tiene un modelo de amenazas obvio y limpio: procesa documentos no fiables y no tiene ningún motivo legítimo para tocar la base de datos, así que blindarlo era de cajón. WordPress parecía más turbio: el pod de WordPress sí necesita legítimamente hablar con MariaDB y con Redis, así que «denegarlo todo» no es la forma adecuada de política. Pero más turbio no significa inútil. El valor real aquí no es aislar WordPress de su propia base de datos, sino asegurarse de que MariaDB y Redis sean accesibles solo desde WordPress, y no desde cualquier otro pod que pueda acabar en este clúster, y limitar el radio de impacto del propio WordPress si alguna vez lo comprometen. Así que por fin escribí tres NetworkPolicies: WordPress puede llegar a MariaDB, Redis, DNS e internet abierto por el 443 (para actualizaciones de plugins y del core; enseguida explico por qué esto último no se puede restringir más); a MariaDB y Redis solo puede llegar WordPress y nadie más. Una limitación honesta que reconozco públicamente, porque ocultarla sería deshonesto y además inútil: las NetworkPolicies estándar de Kubernetes solo pueden filtrar por IP/CIDR, no por nombre de dominio, y mi instalación de Calico funciona en el modo gratuito «policy-only», sin conjuntos de reglas sofisticados basados en dominios. El core de WordPress, sus plugins y mi bootstrap de WP-CLI necesitan HTTPS saliente hacia un reparto cambiante de IP de CDN (wordpress.org, el CDN de releases de GitHub, etc.) que sencillamente no puedo enumerar. Así que el puerto 443 hacia internet abierto sigue permitido. Lo que esta política me aporta de verdad es bloquear el movimiento lateral dentro del clúster (un pod de WordPress comprometido no puede ponerse a husmear en cert-manager ni en la superficie de administración del controlador ingress), no un bloqueo completo del tráfico saliente. Prefiero contarte con honestidad el alcance real de una medida antes que dejarte creer que hace más de lo que hace. 🙏5️⃣ 🐛🐛 Dos bugs que solo aparecieron porque probé las cosas de verdad en lugar de fiarme de los ticks verdes
Esta es la parte del artículo en la que el trabajo de infraestructura deja de ser una lista de comprobación y se convierte en una investigación de verdad, y sinceramente es la parte con la que más me divertí. 🕵️♂️ Después de desplegar las NetworkPolicies, lancé a mano el cron de WordPress (un CronJob de Kubernetes que ejecutawp-cron.php cada 5 minutos) para asegurarme de que no se había roto nada. Devolvió STATUS: Complete ✅. Genial, a producción… salvo que fui a mirar los logs en lugar de fiarme del estado verde y encontré una página de error fatal de WordPress completita: “Error establishing a Redis connection.” 💥 En cada ejecución del cron. En silencio. Desde siempre, por lo visto, porque un wp_die() de PHP termina con código 0, así que Kubernetes no tiene ni idea de que algo ha ido mal. «Complete» solo significaba que el proceso había terminado, no que hubiera tenido éxito. Lección reaprendida a las malas: un estado verde es una afirmación, no una prueba. Al indagar, resultó que había dos bugs distintos apilados uno encima del otro:- 🐛 Bug n.º 1, el de verdad, que ya existía y no tenía nada que ver con lo de hoy: la definición del contenedor del CronJob nunca había tenido la variable de entorno
WORDPRESS_CONFIG_EXTRA(el bloque que defineWP_REDIS_HOSTy compañía), a diferencia del deployment principal de WordPress. Así queWP_REDIS_HOSTestaba sin definir en cada ejecución del cron desde siempre, el plugin de caché de objetos de Redis recurría en silencio a su valor por defecto127.0.0.1y, obviamente, nada escucha en localhost dentro de ese pod. Esto no tenía nada que ver con NetworkPolicies, Calico ni nada de hoy: estaba roto desde que se escribió el CronJob y nunca salió a la luz porque «Complete» nos mintió a todos, a mí incluido, durante quién sabe cuánto tiempo 🙈. Lo arreglé copiando exactamente el mismo bloque de configuración del deployment principal. - 🐛 Bug n.º 2, el realmente nuevo, causado por la NetworkPolicy que acababa de añadir: una vez arreglado el bug n.º 1 y apuntado el pod del cron al nombre de host correcto de Redis, seguía fallando, pero esta vez con un simple «Connection refused» en lugar de un error fatal de WordPress, que es un sabor de fallo muy distinto y enseguida olía a problema de red más que de configuración. Resulta que el primerísimo intento de conexión saliente de un pod recién creado, aproximadamente en su primer segundo de vida, puede ser rechazado porque el agente Felix de Calico todavía no ha terminado de programar las reglas de cortafuegos de ese pod concreto ⏱️. Los pods de larga duración nunca lo notan, porque sus readiness probes tienen un generoso periodo de gracia de 20 segundos antes de que nadie vaya a mirar. Un pod de CronJob que intenta hablar con Redis en su primer medio segundo de vida no tiene ese lujo. Lo reproduje de forma fiable con una prueba aislada (conexión TCP a pelo, sin WordPress de por medio): falla al instante en un pod recién creado y funciona siempre con un sleep de 5 segundos antes. Solución: un
sleep 5 &&pegado delante del comando real del cron. No es elegante. Es tremendamente eficaz. 🛠️
6️⃣ 🤦 Aquella vez que desactivé sin querer una protección por defecto al añadir otra
Mi autogol favorito de todo el ejercicio. Añadí el plugin de admisiónEventRateLimit al servidor API para que un pod en bucle de fallos no pudiera inundar el flujo de eventos. Kubernetes estándar trata --enable-admission-plugins de forma aditiva: añade tu plugin a los que ya están activados por defecto. k3s, por lo visto, no comparte esa filosofía: establecer ese flag sustituye por completo su lista por defecto. Lo que significa que mi cambio de una línea, «vamos a añadir un poco de rate limiting», desactivó en silencio NodeRestriction, un plugin de admisión por defecto de k3s que impide que un kubelet comprometido manipule objetos de la API que no debería tocar. Solo me di cuenta porque volví a ejecutar kube-bench después, para comprobar, y vi cómo una comprobación que antes pasaba saltaba directamente a FAIL 📉. Moraleja: enumera siempre tus valores por defecto de forma explícita cuando toques un flag como este, y vuelve a ejecutar tu herramienta de validación después de cada cambio, no solo una vez al final del todo.7️⃣ 🗝️ Secretos como ficheros en lugar de variables de entorno
Las contraseñas de la base de datos estaban en variables de entorno en texto plano dentro de los contenedores, legibles a través de/proc/<pid>/environ, kubectl describe, volcados de memoria o cualquier gestor de errores lo bastante torpe como para volcar su entorno ante un error fatal. Tanto la imagen oficial de Docker de WordPress como la de MariaDB admiten leer los secretos desde una ruta de fichero (la convención del sufijo _FILE), así que lo cambié todo. La única trampa no evidente ⚠️: las propias sondas de health check de MariaDB leían la contraseña de root directamente de esa misma variable de entorno. Si conviertes la variable en fichero sin tocar las sondas, MariaDB empezaría a declararse permanentemente no saludable en cuanto lo despliegues. Lo pillé durante la revisión, actualicé las sondas para que lean el fichero directamente y lo probé en vivo antes de darlo por terminado. 👍8️⃣ 📦 Procedencia de imágenes, a la manera honesta
El CIS pide «Image Provenance using ImagePolicyWebhook», que es una funcionalidad real de Kubernetes, pero implementarla supone montar y alojar todo un servicio webhook externo que apruebe o rechace imágenes en el momento de la admisión. Eso es una buena cantidad de infraestructura nueva para el blog de un homelab, y no pensaba construir todo un microservicio solo para contentar una casilla de cumplimiento 🙅. En su lugar uséValidatingAdmissionPolicy, un mecanismo nativo integrado en el servidor API (GA desde Kubernetes 1.30, sin piezas móviles adicionales), para rechazar cualquier pod del namespace de WordPress cuya imagen de contenedor no esté en una lista explícita de permitidos con los repositorios exactos que usa realmente este stack. Mismo objetivo de control, cero infraestructura nueva que vigilar a las 2 de la madrugada. 😴🤷 Lo que dejé tal cual a propósito
No todo lo que sale en rojo hay que arreglarlo, y quiero ser franco sobre lo que sigue marcado y por qué, porque «lo hemos arreglado absolutamente todo» suele ser señal de que nadie ha mirado con suficiente atención, y porque nada de lo siguiente le da a un atacante algo útil de verdad:- 🗄️ Una comprobación de «etcd» que no se me aplica en absoluto. Este es un clúster k3s de un solo nodo que usa su almacén de datos SQLite integrado, no etcd. Una comprobación del CIS que pregunta por un fichero de CA de etcd está comprobando un componente que literalmente no se está ejecutando aquí. No es un hallazgo, solo un benchmark genérico.
- 🧩 Un permiso RBAC con comodín que pertenece al propio Calico, necesario para que gestione sus propios recursos personalizados. Es el coste visible de activar la aplicación de NetworkPolicies descrita arriba: recortarlo a mano corre el riesgo de que el propio motor de network policies no arranque, y eso me pareció peor trato que «un permiso con comodín para un componente al que ya se le confía la red del clúster».
- 🔧 Un puñado de componentes que siguen teniendo montados sus tokens de la API de Kubernetes: en concreto cert-manager, mi controlador ingress y los propios controladores de Calico. Los tres necesitan ese acceso para hacer su trabajo (vigilar certificados, objetos ingress y network policies, respectivamente). Quitárselo no bastionaría nada; simplemente rompería la emisión de certificados TLS.




