VT Security blog

El sync terminó bien y el pod montó la raíz del nodo

En un laboratorio de GitOps, un manifest privilegiado llegó a main y el reconciler lo aplicó. Qué detectaron la firma y Kyverno, qué bloqueó Kubernetes y qué revisar en una instalación real.

En este artículo
  1. De main al cluster
  2. Qué detectó la segunda cadena
  3. Qué pudo hacer la cuenta de sync
  4. Cuando cambia el cluster y cuando cambia Git
  5. Qué revisaría en una instalación real

El commit decía hotfix: debug access. El diff quitaba las restricciones del contenedor, activaba privileged: true y montaba la raíz del nodo en /host. Llegó a main; el reconciler lo aplicó sin errores. En el cluster aparecieron pods privilegiados con ese montaje.

Eso pasó en el laboratorio local que armé para mi charla en DevSecOps Space de Ekoparty. El fallo estaba antes del sync: el manifest inseguro ya había entrado a Git.

GITPOD SECURITYPODSGITPOD SECURITYPODS

Pods privilegiados en ejecución. Git tenía el manifest inseguro y Kubernetes lo ejecutó.

Laboratorio local con kind y kubectl apply; no configuré checks obligatorios para el merge.

De main al cluster #

GitOps usa Git como referencia para el estado del cluster. Por ejemplo, Argo CD puede sincronizar automáticamente cuando ve diferencias entre los manifests del repo y el estado actual. Eso facilita seguir los cambios y volver a una versión anterior, pero también hace que la ruta hacia main sea parte de la seguridad del cluster.

Para probarlo usé un repo de manifests, un cluster kind y un script que ejecuta kubectl apply con la service account del reconciler. No hay Argo CD ni un PR real detrás: los controles de la rama se probaron con comandos locales. Así podía ver por separado qué se aceptaba en Git y qué podía aplicar esa cuenta.

En la primera cadena, el cambio entró directo a main sin firma ni revisión. El extracto del diff era este:

diff
-            allowPrivilegeEscalation: false
-            runAsNonRoot: true
-            seccompProfile: { type: RuntimeDefault }
-            capabilities: { drop: [ALL] }
+            privileged: true
+          volumeMounts:
+            - { name: host, mountPath: /host }
+      volumes:
+        - name: host
+          hostPath: { path: / }

La cuenta del reconciler tenía cluster-admin y el namespace no exigía un nivel de Pod Security. kubectl apply respondió deployment.apps/app created; al consultar los pods, el laboratorio registró PRIVILEGED true y HOSTPATH /. El sync fue correcto con respecto a Git; el resultado era inseguro.

Qué detectó la segunda cadena #

En la segunda cadena dejé el manifest en una rama y probé cuatro controles:

  1. git verify-commit HEAD falló: el commit no estaba firmado.
  2. kyverno apply encontró dos violaciones: hostPath y privileged.
  3. La cuenta del reconciler tenía un Role limitado al namespace prod.
  4. Ese namespace tenía Pod Security Admission en nivel restricted.

El punto 2 merece cuidado: la salida del laboratorio dice fail: 2, pero el comando la pasa por grep y terminó con código 0. Encontrar una violación no equivale a impedir un merge. En un pipeline real, el check debe fallar y ser obligatorio para entrar a la rama protegida. Las reglas de protección de GitHub permiten exigir reviews, checks y commits firmados; el laboratorio no configuró esas reglas en un servidor Git.

El laboratorio también probó qué pasaría si el manifest llegara igual al API server. Kubernetes aceptó el objeto Deployment y mostró un warning, pero Pod Security Admission rechazó la creación de sus pods. La evidencia fue un evento FailedCreate con forbidden: violates PodSecurity "restricted:latest". Un Deployment creado no significa que la aplicación esté corriendo.

CINCO COMPROBACIONES DEL LAB

Dos violaciones. El check terminó en 0.

02 / 05
02 / MANIFESTKyverno vio dos violaciones

Kyverno reportó hostPath y privileged. El script filtró la salida con grep y terminó en 0: la detección no se convirtió en un check fallido.

REGISTRO DEL LABORATORIO
$ kyverno apply policies/ --resource prod/deployment.yaml
pass: 0, fail: 2, warn: 0
rc del script: 0
Resultados del lab local: la firma falló, Kyverno detectó dos violaciones y Pod Security rechazó los Pods.

Son momentos distintos. Un check obligatorio puede frenar el merge. Pod Security decide si se crean los Pods cuando el manifest llega al API server. El Role define qué operaciones puede pedirle a Kubernetes la cuenta del reconciler.

Qué pudo hacer la cuenta de sync #

En otro escenario, un manifest creó una service account en kube-system y la vinculó a cluster-admin. La cuenta amplia lo aplicó y la nueva cuenta terminó pudiendo hacer * * en el cluster. En la cadena acotada, la prueba con la service account del reconciler devolvió Forbidden para serviceaccounts en kube-system y para clusterrolebindings a nivel cluster.

El Role del laboratorio permitía operar deployments, services y configmaps en prod. No permitía crear cuentas ni bindings de alcance cluster. RBAC de Kubernetes recomienda conceder solo los recursos y verbos necesarios; en una instalación real habría que revisar qué recursos administra el controller antes de recortar permisos.

MISMO MANIFEST, DOS CUENTAS

Una creó un admin. La otra recibió Forbidden.

RBAC
prod/ci-helper.yamlCrear un administrador nuevo
ServiceAccount → kube-systemClusterRoleBinding → cluster-admin
CUENTA AMPLIAcluster-admin
ALCANCETodo el cluster
APPLY / CREATEDEl binding entró

La nueva cuenta terminó con permisos * *.

CUENTA ACOTADARole en prod
ALCANCEApps del namespace
DRY-RUN / FORBIDDENNo pudo aplicarlo

La API rechazó la service account y el binding fuera de su alcance.

La cuenta amplia aplicó el manifest. La acotada recibió Forbidden en un dry-run.

También probé un Secret con un valor ficticio en texto plano. La cadena amplia lo aplicó y git log -p -S todavía encontraba el valor en el historial. En la otra, la política lo marcó como fallo y el Role tampoco permitía tocar Secrets. Borrar el archivo en un commit posterior no borra el commit que ya lo contenía; el control útil es impedir que entre y gestionar los secretos fuera de ese manifest en claro.

Cuando cambia el cluster y cuando cambia Git #

Alguien cambió el cluster a mano: cinco réplicas y nginx:latest donde Git decía dos y una imagen fijada. El laboratorio mostró el desvío con kubectl diff; después ejecutó otra sincronización y volvió a dos réplicas con la imagen de Git. Esa vuelta fue un paso explícito del script. En Argo CD, los cambios hechos directamente en el cluster no disparan self-heal por defecto; hay que configurarlo si se espera esa conducta.

El último escenario fue diferente: un cambio firmado pasó las políticas, llegó a main y después resultó ser una mala decisión para la aplicación. La corrección fue un git revert firmado y una nueva sincronización. La firma confirmó el uso de la clave de prueba; no garantizaba que la decisión fuera buena. Las políticas tampoco pueden decidir si una versión sirve para todos tus usuarios.

GIT Y EL CLUSTER DESPUÉS DE UN CAMBIO MANUAL

Git decía 2 réplicas. El cluster tenía 5.

02 / 04
GIT / DESEADO
02RÉPLICAS
IMAGENnginxinc/nginx-unprivileged:1.27-alpine
CLUSTER / REAL
05RÉPLICAS
IMAGENnginx:latest
DRIFT

Alguien cambia el cluster a mano. Git sigue diciendo otra cosa.

COMANDO / RESULTADO
$ kubectl scale deploy/app --replicas=5 $ kubectl set image deploy/app web=nginx:latest
deployment.apps/app scaled
deployment.apps/app image updated
EL OTRO CASO: EL ERROR ESTÁ EN GITRevertir el commit y sincronizar

La firma prueba el uso de la clave; no juzga si la versión conviene. Se revirtió en Git y se volvió a sincronizar.

  1. 01Versión buena
  2. 02Cambio firmado
  3. 03git revert firmado

En Argo CD, selfHeal para cambios directos en el cluster no viene activado por defecto.

El script volvió a sincronizar de forma explícita. No simulé self-heal automático.

Qué revisaría en una instalación real #

  1. Quién puede cambiar main: PR obligatorio, revisión de la parte sensible y checks requeridos. Revisá quién tiene permiso de bypass. Una firma prueba posesión de una clave; la revisión evalúa el cambio.
  2. Si el check realmente bloquea: probá un manifest con privileged: true o hostPath en un entorno de prueba. Confirmá que el job falla y que el merge no se habilita. No alcanza con ver fail: 2 en el log.
  3. Qué puede aplicar el controller: corré kubectl auth can-i con su identidad. Revisá en especial secrets, roles, rolebindings y recursos de alcance cluster.
  4. Qué acepta el cluster: comprobá los niveles de Pod Security de los namespaces y las excepciones. Las políticas del PR y la admisión cubren momentos distintos.
  5. Cómo detectás y corregís desvíos: mirá el estado en vivo, la configuración real de self-heal y el procedimiento para revertir desde Git.

Que el cluster coincida con Git no prueba que el cambio sea seguro. Para saber si ese estado es aceptable, miraría el merge, los permisos del reconciler y lo que Kubernetes terminó creando.

Valentín Torassa. Hago videos de ciberseguridad en YouTube. Si encontraste un error, avisame en los comentarios y lo corrijo con una nota al pie.

Comentarios

Pasan por moderación antes de aparecer.

Lo próximo llega también por mail. Cada dos semanas, en Apuntes.