SRE: Gestión de Secretos en Kubernetes

2026-03-07 | Gabriel Garrido | 22 min de lectura
Share:

Apoya este blog

Si te resulta util este contenido, considera apoyar el blog.

Introducción

En los artículos anteriores cubrimos SLIs y SLOs, gestión de incidentes, observabilidad, ingeniería del caos, planificación de capacidad y GitOps. Construimos una base sólida para correr servicios confiables en Kubernetes, pero hay un tema que todavía no tocamos y que puede hacer o romper tu postura de seguridad: la gestión de secretos.


Si alguna vez commiteaste una contraseña de base de datos a un repositorio Git, hardcodeaste una API key en un manifiesto de deployment, o confiaste en los Secrets de Kubernetes pensando que estaban “encriptados”, conocés el dolor. Los secretos están en todos lados en la infraestructura moderna, y gestionarlos mal es una de las formas más rápidas de terminar en las noticias por las razones equivocadas.


En este artículo vamos a cubrir por qué los Secrets de Kubernetes no son suficientes por sí solos, y después recorrer las herramientas y estrategias que realmente resuelven el problema: Sealed Secrets, External Secrets Operator, HashiCorp Vault, rotación de secretos, SOPS, políticas de RBAC, y logging de auditoría. Al final vas a tener una imagen clara de qué enfoque se ajusta a tu situación y cómo implementarlo.


Vamos al tema.


El problema con los secrets de Kubernetes

Kubernetes tiene un recurso Secret incorporado, y a primera vista parece que resuelve el problema. Creás un Secret, lo referenciás en tu spec de Pod, y tu aplicación recibe el valor como variable de entorno o archivo montado. Bastante simple.


Pero hay un detalle. Los Secrets de Kubernetes están codificados en base64, no encriptados. Base64 es una codificación reversible, no un mecanismo de seguridad. Cualquiera con acceso al manifiesto o al API server puede decodificar tus secretos de manera trivial:


# Creando un "secret" en Kubernetes
apiVersion: v1
kind: Secret
metadata:
  name: my-app-secrets
  namespace: default
type: Opaque
data:
  # Esto es solo base64, NO encriptación
  database-password: cGFzc3dvcmQxMjM=
  api-key: c3VwZXItc2VjcmV0LWtleQ==

# Cualquiera puede decodificar esto al instante
$ echo "cGFzc3dvcmQxMjM=" | base64 -d
password123

$ echo "c3VwZXItc2VjcmV0LWtleQ==" | base64 -d
super-secret-key

Los problemas van más allá de la codificación:


  • Almacenamiento en etcd: Por defecto, los secrets se guardan sin encriptar en etcd. Cualquiera con acceso al datastore de etcd puede leer todos los secrets del cluster
  • Brechas de RBAC: La configuración de RBAC por defecto en muchos clusters es demasiado permisiva. Si una service account puede listar secrets en un namespace, puede leer todos
  • Exposición en Git: No podés commitear manifiestos de Secret a Git sin exponer los valores, lo que rompe los flujos de trabajo GitOps
  • Sin registro de auditoría: Kubernetes no registra quién accedió al valor de un secret por defecto, solo quién listó o watcheó el recurso
  • Sin rotación: No hay mecanismo incorporado para rotar secretos. Cambiás el valor, reiniciás los pods, y esperás que nada se rompa
  • Sin encriptación en reposo: A menos que configures explícitamente encriptación en reposo para etcd, los secrets están ahí en texto plano

Podés habilitar la encriptación en reposo en el API server con una EncryptionConfiguration:


# /etc/kubernetes/encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
    providers:
      - aescbc:
          keys:
            - name: key1
              secret: <clave-de-32-bytes-en-base64>
      - identity: {}

Esto ayuda con los datos en reposo en etcd, pero no resuelve el problema de Git, el problema de rotación, ni el problema de auditoría. Para esos necesitamos herramientas dedicadas.


Sealed Secrets

Bitnami Sealed Secrets es una de las soluciones más simples para el problema de “necesito guardar secretos en Git”. La idea es elegante: encriptás tus secretos con una clave pública que solo el controlador del cluster puede desencriptar. La versión encriptada (un SealedSecret) es segura para commitear a Git porque solo el controlador corriendo en tu cluster tiene la clave privada para desellarlo.


Primero, instalá el controlador de Sealed Secrets en tu cluster:


# Instalar el controlador
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm repo update

helm install sealed-secrets sealed-secrets/sealed-secrets \
  --namespace kube-system \
  --set-string fullnameOverride=sealed-secrets-controller

Después instalá el CLI kubeseal en tu estación de trabajo:


# Instalar kubeseal
brew install kubeseal

# O descargar directamente
wget https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.27.3/kubeseal-0.27.3-linux-amd64.tar.gz
tar -xvf kubeseal-0.27.3-linux-amd64.tar.gz
sudo install -m 755 kubeseal /usr/local/bin/kubeseal

Ahora el flujo de trabajo se ve así. Creás un Secret regular de Kubernetes y después lo sellás:


# Crear el secret regular (NO commitear este archivo)
kubectl create secret generic my-app-secrets \
  --namespace default \
  --from-literal=database-password=password123 \
  --from-literal=api-key=super-secret-key \
  --dry-run=client -o yaml > my-secret.yaml

# Sellarlo con la clave pública del cluster
kubeseal --format yaml < my-secret.yaml > my-sealed-secret.yaml

# Eliminar la versión sin encriptar
rm my-secret.yaml

El SealedSecret resultante es seguro para commitear:


# my-sealed-secret.yaml - esto es seguro para commitear a Git
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: my-app-secrets
  namespace: default
spec:
  encryptedData:
    database-password: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq...
    api-key: AgCtr8HZFBOGZ9Nk+HrKPHRf7A6WkXN0...
  template:
    metadata:
      name: my-app-secrets
      namespace: default
    type: Opaque

Cuando el controlador de Sealed Secrets ve este recurso en el cluster, lo desencripta y crea un Secret regular de Kubernetes que tus pods pueden usar normalmente.


Algunas cosas importantes sobre Sealed Secrets:


  • Alcance: Por defecto, un SealedSecret está vinculado a un nombre y namespace específico. No podés cambiar el nombre o namespace sin volver a sellar
  • Rotación de claves: El controlador rota sus claves de encriptación cada 30 días por defecto. Las claves viejas se mantienen para que los SealedSecrets existentes puedan seguir siendo desencriptados
  • Respaldá las claves: Si perdés la clave privada del controlador (por ejemplo, eliminando el namespace sin respaldar), perdés la capacidad de desencriptar todos tus SealedSecrets. Respaldá las claves
  • Re-encriptación: Después de la rotación de claves, los SealedSecrets existentes siguen funcionando pero usan la clave vieja. Deberías re-sellarlos periódicamente con la nueva clave

Así es como respaldás y restaurás las claves del controlador:


# Respaldar las claves de sellado
kubectl get secret -n kube-system \
  -l sealedsecrets.bitnami.com/sealed-secrets-key \
  -o yaml > sealed-secrets-keys-backup.yaml

# Guardá este respaldo de forma segura (no en Git!)
# Usá un password manager, cloud KMS, o una caja fuerte

# Restaurar claves en un cluster nuevo
kubectl apply -f sealed-secrets-keys-backup.yaml
# Reiniciar el controlador para que levante las claves restauradas
kubectl rollout restart deployment/sealed-secrets-controller -n kube-system

Sealed Secrets es una excelente opción cuando querés una solución simple y autocontenida que no dependa de servicios externos. Funciona perfecto con GitOps porque los manifiestos encriptados viven en tu repo. La desventaja principal es que solo resuelve el problema de “secretos en Git”. No ayuda con rotación, gestión centralizada, o secretos dinámicos.


External Secrets Operator

El External Secrets Operator (ESO) toma un enfoque diferente. En vez de encriptar secretos y guardarlos en Git, sincroniza secretos desde un almacén de secretos externo (como AWS Secrets Manager, HashiCorp Vault, Google Secret Manager, o Azure Key Vault) hacia Secrets de Kubernetes. Tu repositorio Git solo contiene la referencia al secreto, no el valor en sí.


Instalá ESO con Helm:


helm repo add external-secrets https://charts.external-secrets.io
helm repo update

helm install external-secrets external-secrets/external-secrets \
  --namespace external-secrets \
  --create-namespace \
  --set installCRDs=true

La arquitectura tiene tres componentes principales:


  • SecretStore / ClusterSecretStore: Configura la conexión a tu proveedor de secretos externo
  • ExternalSecret: Declara qué secretos traer y cómo mapearlos a Secrets de Kubernetes
  • El operador: Observa los recursos ExternalSecret y crea/actualiza Secrets de Kubernetes

Acá hay un ejemplo usando AWS Secrets Manager como backend. Primero, configurá el SecretStore:


# cluster-secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: aws-secrets-manager
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets-sa
            namespace: external-secrets

Después creá un ExternalSecret que referencia un secreto almacenado en AWS:


# external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: my-app-secrets
  namespace: default
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: my-app-secrets
    creationPolicy: Owner
    template:
      type: Opaque
      data:
        database-password: "{{ .database_password }}"
        api-key: "{{ .api_key }}"
  data:
    - secretKey: database_password
      remoteRef:
        key: production/my-app
        property: database_password
    - secretKey: api_key
      remoteRef:
        key: production/my-app
        property: api_key

Este manifiesto ExternalSecret es perfectamente seguro para commitear a Git porque solo contiene referencias, no valores. El operador trae los valores reales de AWS Secrets Manager y crea un Secret de Kubernetes.


También podés usar ESO con HashiCorp Vault como backend:


# vault-secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: vault-backend
spec:
  provider:
    vault:
      server: "https://vault.example.com"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "external-secrets"
          serviceAccountRef:
            name: external-secrets-sa
            namespace: external-secrets

# vault-external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: my-app-vault-secrets
  namespace: default
spec:
  refreshInterval: 15m
  secretStoreRef:
    name: vault-backend
    kind: ClusterSecretStore
  target:
    name: my-app-secrets
    creationPolicy: Owner
  data:
    - secretKey: database-password
      remoteRef:
        key: secret/data/production/my-app
        property: database_password
    - secretKey: api-key
      remoteRef:
        key: secret/data/production/my-app
        property: api_key

El refreshInterval es una de las funcionalidades estrella de ESO. El operador chequea periódicamente el almacén externo y actualiza el Secret de Kubernetes si el valor upstream cambió. Esta es la base para la rotación automatizada de secretos, que vamos a cubrir más adelante.


ESO es una excelente opción cuando ya tenés un almacén de secretos centralizado y querés traer esos secretos a Kubernetes sin pasos manuales. Funciona bien con GitOps porque solo las referencias viven en Git, y soporta prácticamente todos los proveedores de nube y herramientas de gestión de secretos.


Integración con HashiCorp Vault

HashiCorp Vault es el peso pesado de la gestión de secretos. Provee almacenamiento centralizado de secretos, generación de secretos dinámicos, encriptación como servicio, y logging de auditoría detallado. Mientras que ESO puede sincronizar secretos desde Vault hacia Kubernetes, Vault también ofrece integración nativa con Kubernetes a través del Vault Agent Injector y el proveedor CSI.


Vault Agent Injector

El Vault Agent Injector usa un mutating webhook para inyectar un sidecar de Vault Agent en tus pods. El agente se encarga de la autenticación, trae los secretos de Vault, y los escribe en un volumen compartido que tu aplicación puede leer.


Instalá el chart de Helm de Vault con el injector habilitado:


helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update

helm install vault hashicorp/vault \
  --namespace vault \
  --create-namespace \
  --set "injector.enabled=true" \
  --set "server.dev.enabled=false" \
  --set "server.ha.enabled=true" \
  --set "server.ha.replicas=3"

Configurá el método de autenticación Kubernetes de Vault para que los pods puedan autenticarse:


# Habilitar auth de Kubernetes en Vault
vault auth enable kubernetes

# Configurarlo para hablar con el API de Kubernetes
vault write auth/kubernetes/config \
  kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443" \
  token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
  kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt

# Crear una política para la app
vault policy write my-app-policy - <<EOF
path "secret/data/production/my-app" {
  capabilities = ["read"]
}
EOF

# Crear un rol que vincule la política a una service account de Kubernetes
vault write auth/kubernetes/role/my-app \
  bound_service_account_names=my-app-sa \
  bound_service_account_namespaces=default \
  policies=my-app-policy \
  ttl=1h

Ahora anotá tu deployment para usar el injector:


# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  namespace: default
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
      annotations:
        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/role: "my-app"
        vault.hashicorp.com/agent-inject-secret-db-password: "secret/data/production/my-app"
        vault.hashicorp.com/agent-inject-template-db-password: |
          {{- with secret "secret/data/production/my-app" -}}
          {{ .Data.data.database_password }}
          {{- end -}}
        vault.hashicorp.com/agent-inject-secret-api-key: "secret/data/production/my-app"
        vault.hashicorp.com/agent-inject-template-api-key: |
          {{- with secret "secret/data/production/my-app" -}}
          {{ .Data.data.api_key }}
          {{- end -}}
    spec:
      serviceAccountName: my-app-sa
      containers:
        - name: my-app
          image: my-app:latest
          # Los secretos están disponibles en /vault/secrets/db-password y /vault/secrets/api-key

Proveedor CSI de Vault

El proveedor CSI (Container Storage Interface) monta secretos como volúmenes usando el driver Secrets Store CSI. Este enfoque es más liviano que el Agent Injector porque no requiere un sidecar:


# Instalar el driver Secrets Store CSI
helm install csi-secrets-store secrets-store-csi-driver/secrets-store-csi-driver \
  --namespace kube-system

# Instalar el proveedor CSI de Vault
helm install vault hashicorp/vault \
  --namespace vault \
  --set "injector.enabled=false" \
  --set "csi.enabled=true"

# secret-provider-class.yaml
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: vault-my-app
  namespace: default
spec:
  provider: vault
  parameters:
    vaultAddress: "https://vault.vault.svc:8200"
    roleName: "my-app"
    objects: |
      - objectName: "database-password"
        secretPath: "secret/data/production/my-app"
        secretKey: "database_password"
      - objectName: "api-key"
        secretPath: "secret/data/production/my-app"
        secretKey: "api_key"
  secretObjects:
    - secretName: my-app-secrets
      type: Opaque
      data:
        - objectName: database-password
          key: database-password
        - objectName: api-key
          key: api-key

# pod-with-csi.yaml
apiVersion: v1
kind: Pod
metadata:
  name: my-app
  namespace: default
spec:
  serviceAccountName: my-app-sa
  containers:
    - name: my-app
      image: my-app:latest
      volumeMounts:
        - name: secrets
          mountPath: "/mnt/secrets"
          readOnly: true
  volumes:
    - name: secrets
      csi:
        driver: secrets-store.csi.k8s.io
        readOnly: true
        volumeAttributes:
          secretProviderClass: "vault-my-app"

Vault es la opción correcta cuando necesitás secretos dinámicos (como credenciales de base de datos que se generan al vuelo y expiran automáticamente), políticas de acceso granulares, logging de auditoría integral, o encriptación como servicio. La contrapartida es la complejidad. Vault es un sistema distribuido que necesita ser desplegado, gestionado, desellado, y respaldado. Para equipos más chicos, ESO con un almacén de secretos gestionado en la nube puede ser mejor opción.


Estrategias de rotación de secretos

Los secretos estáticos son un riesgo. Cuanto más tiempo existe un secreto sin ser cambiado, más tiempo tiene un atacante para encontrarlo y explotarlo. La rotación de secretos es la práctica de reemplazar secretos con valores nuevos regularmente, y es una de las cosas más impactantes que podés hacer por tu postura de seguridad.


¿Por qué rotar secretos?


  • Limitar radio de impacto: Si un secreto es comprometido, la rotación limita cuánto tiempo puede usarlo el atacante
  • Cumplimiento: Muchos marcos de cumplimiento (SOC2, PCI-DSS, HIPAA) requieren rotación regular de secretos
  • Reducir acceso obsoleto: Cuando la gente se va del equipo o los servicios se decomisionan, sus credenciales deberían dejar de funcionar
  • Defensa en profundidad: Incluso si tus otros controles fallan, la rotación limita la ventana de daño

Rotación automatizada con External Secrets Operator

El refreshInterval de ESO es la forma más simple de implementar rotación. Si actualizás el secreto en tu almacén externo, ESO levanta el nuevo valor en el siguiente ciclo de refresco:


# external-secret-with-rotation.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: rotating-secret
  namespace: default
spec:
  # Chequear nuevos valores cada 15 minutos
  refreshInterval: 15m
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: rotating-secret
    creationPolicy: Owner
  data:
    - secretKey: database-password
      remoteRef:
        key: production/my-app/database
        property: password

Del lado de AWS, podés configurar rotación automática con una función Lambda:


# terraform para rotación de AWS Secrets Manager
resource "aws_secretsmanager_secret" "db_password" {
  name = "production/my-app/database"
}

resource "aws_secretsmanager_secret_rotation" "db_password" {
  secret_id           = aws_secretsmanager_secret.db_password.id
  rotation_lambda_arn = aws_lambda_function.secret_rotation.arn

  rotation_rules {
    automatically_after_days = 30
  }
}

resource "aws_lambda_function" "secret_rotation" {
  function_name = "secret-rotation-db"
  handler       = "rotation.handler"
  runtime       = "python3.12"
  filename      = "rotation-lambda.zip"

  environment {
    variables = {
      DB_HOST = "mydb.cluster-xyz.us-east-1.rds.amazonaws.com"
    }
  }
}

Secretos dinámicos con Vault

Vault lleva la rotación un paso más allá con secretos dinámicos. En vez de rotar una credencial estática, Vault genera una credencial única y de corta vida en cada solicitud. Cuando el lease expira, Vault la revoca automáticamente:


# Habilitar el motor de secretos de base de datos
vault secrets enable database

# Configurar una conexión PostgreSQL
vault write database/config/my-postgres \
  plugin_name=postgresql-database-plugin \
  allowed_roles="my-app-role" \
  connection_url="postgresql://{{username}}:{{password}}@postgres.default.svc:5432/mydb?sslmode=disable" \
  username="vault_admin" \
  password="admin_password"

# Crear un rol que genera credenciales con TTL de 1 hora
vault write database/roles/my-app-role \
  db_name=my-postgres \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
    GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  revocation_statements="DROP ROLE IF EXISTS \"{{name}}\";" \
  default_ttl="1h" \
  max_ttl="24h"

# Ahora cada solicitud a este path genera una credencial nueva
$ vault read database/creds/my-app-role
Key                Value
---                -----
lease_id           database/creds/my-app-role/abcd1234
lease_duration     1h
lease_renewable    true
password           A1B2-C3D4-E5F6-G7H8
username           v-my-app-role-xyz123

Con secretos dinámicos, no hay nada que rotar en el sentido tradicional. Cada pod recibe su propia credencial única que expira automáticamente. Si una credencial es comprometida, solo funciona por una ventana corta, y solo da acceso a lo que ese rol específico permite.


El desafío principal con la rotación (tanto tradicional como dinámica) es asegurarte de que tu aplicación maneje los cambios de credenciales de forma elegante. Tu app necesita re-leer el archivo de secretos periódicamente, reconectarse con nuevas credenciales cuando las viejas se revocan, o usar un pool de conexiones que maneje la rotación de credenciales de forma transparente.


SOPS con age/GPG

Mozilla SOPS (Secrets OPerationS) toma otro enfoque. En vez de usar un controlador u operador separado, SOPS encripta valores específicos en tus archivos YAML o JSON mientras deja la estructura y las claves en texto plano. Esto significa que podés ver qué secretos contiene un archivo sin poder leer los valores, lo cual es genial para code review y para ver diffs.


Instalá SOPS y age (una herramienta de encriptación moderna que es más simple que GPG):


# Instalar sops
brew install sops

# Instalar age
brew install age

# Generar un par de claves age
age-keygen -o keys.txt
# Output: public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

Creá un archivo de configuración .sops.yaml en la raíz de tu repositorio:


# .sops.yaml
creation_rules:
  # Encriptar secretos en el directorio de producción
  - path_regex: secrets/production/.*\.yaml$
    encrypted_regex: "^(data|stringData)$"
    age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

  # Encriptar secretos en el directorio de staging con otra clave
  - path_regex: secrets/staging/.*\.yaml$
    encrypted_regex: "^(data|stringData)$"
    age: age1wrg9q5p84t03edh09vqnqv60xfmxqxfaslfcm2yln95jwzxqntrse2x8fq

  # También podés usar AWS KMS, GCP KMS, o Azure Key Vault
  - path_regex: secrets/production-aws/.*\.yaml$
    encrypted_regex: "^(data|stringData)$"
    kms: "arn:aws:kms:us-east-1:123456789:key/abcd-1234-efgh-5678"

Ahora creá un archivo de secreto y encriptalo:


# secrets/production/my-app.yaml (antes de encriptar)
apiVersion: v1
kind: Secret
metadata:
  name: my-app-secrets
  namespace: default
type: Opaque
stringData:
  database-password: password123
  api-key: super-secret-key

# Encriptar el archivo in place
sops --encrypt --in-place secrets/production/my-app.yaml

Después de la encriptación, el archivo se ve así:


# secrets/production/my-app.yaml (después de encriptar)
apiVersion: v1
kind: Secret
metadata:
  name: my-app-secrets
  namespace: default
type: Opaque
stringData:
  database-password: ENC[AES256_GCM,data:kJH7x9mN...,iv:abc...,tag:xyz...,type:str]
  api-key: ENC[AES256_GCM,data:pQR8y0oP...,iv:def...,tag:uvw...,type:str]
sops:
  age:
    - recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
      enc: |
        -----BEGIN AGE ENCRYPTED FILE-----
        YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+...
        -----END AGE ENCRYPTED FILE-----
  lastmodified: "2026-03-07T10:30:00Z"
  version: 3.9.0

Notá que las claves y la estructura son visibles, pero los valores están encriptados. Esto es perfecto para code review porque podés ver que alguien cambió el database-password sin ver el valor real.


Para desencriptar y aplicar:


# Desencriptar y aplicar al cluster
sops --decrypt secrets/production/my-app.yaml | kubectl apply -f -

# O editar el archivo encriptado directamente (desencripta en tu editor, re-encripta al guardar)
sops secrets/production/my-app.yaml

Integrando SOPS con ArgoCD

ArgoCD tiene soporte nativo para SOPS a través de plugins. Podés usar el argocd-vault-plugin o el soporte incorporado de Kustomize SOPS:


# argocd-repo-server con soporte SOPS
apiVersion: apps/v1
kind: Deployment
metadata:
  name: argocd-repo-server
  namespace: argocd
spec:
  template:
    spec:
      containers:
        - name: argocd-repo-server
          env:
            # Clave privada age para desencriptación
            - name: SOPS_AGE_KEY_FILE
              value: /sops/age/keys.txt
          volumeMounts:
            - name: sops-age
              mountPath: /sops/age
      volumes:
        - name: sops-age
          secret:
            secretName: sops-age-key

# Usando kustomize-sops con ArgoCD
# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

generators:
  - secret-generator.yaml

# secret-generator.yaml
apiVersion: viaduct.ai/v1
kind: ksops
metadata:
  name: my-app-secrets
files:
  - secrets/production/my-app.yaml

SOPS es una gran opción cuando querés mantener todo en Git (GitOps puro), tenés una cantidad chica a mediana de secretos, y no necesitás secretos dinámicos ni rotación compleja. Funciona bien para equipos que ya están cómodos con flujos de trabajo de Git y quieren mínima infraestructura adicional.


RBAC para secretos

Sin importar qué herramienta uses para gestionar secretos, la capa de RBAC de Kubernetes es tu última línea de defensa. Si tu RBAC es demasiado permisivo, un atacante que comprometa cualquier service account puede leer todos los secretos en el namespace o incluso en todo el cluster.


Estos son los principios clave:


  • Mínimo privilegio: Solo otorgá acceso a los secretos específicos que un servicio necesita
  • Aislamiento por namespace: Usá namespaces separados para diferentes ambientes y equipos
  • Sin acceso wildcard: Evitá resources: ["*"] en las reglas de RBAC para secretos
  • Separar lectura y escritura: La mayoría de los servicios solo necesitan leer secretos, no crearlos o modificarlos

Acá hay un Role restrictivo que solo permite leer un secreto específico:


# role-secret-reader.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: my-app-secret-reader
  namespace: default
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["my-app-secrets"]  # Solo este secreto específico
    verbs: ["get"]  # Solo get, no list ni watch

# rolebinding-secret-reader.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: my-app-secret-reader
  namespace: default
subjects:
  - kind: ServiceAccount
    name: my-app-sa
    namespace: default
roleRef:
  kind: Role
  name: my-app-secret-reader
  apiGroup: rbac.authorization.k8s.io

Para aislamiento de namespaces, creá una NetworkPolicy que evite que pods en un namespace se comuniquen con pods en otros namespaces, combinada con RBAC que restrinja las service accounts a su propio namespace:


# namespace-isolation.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: team-payments
  labels:
    team: payments
    environment: production
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-cross-namespace
  namespace: team-payments
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector: {}  # Solo permitir tráfico del mismo namespace
  egress:
    - to:
        - podSelector: {}  # Solo permitir tráfico al mismo namespace
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
      ports:
        - port: 53
          protocol: UDP  # Permitir resolución DNS

También deberías restringir quién puede crear o modificar Roles y RoleBindings, porque un atacante que puede crear un RoleBinding puede darse acceso a cualquier secreto:


# restrict-rbac-management.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: rbac-manager
rules:
  - apiGroups: ["rbac.authorization.k8s.io"]
    resources: ["roles", "rolebindings"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
# Solo vincular esto a administradores del cluster, no a service accounts regulares
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: rbac-manager-binding
subjects:
  - kind: Group
    name: cluster-admins
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: rbac-manager
  apiGroup: rbac.authorization.k8s.io

Un error común es darle el ClusterRole edit o admin a service accounts o desarrolladores. Estos roles incorporados incluyen la capacidad de leer todos los secretos en un namespace. En vez de eso, creá roles personalizados con solo los permisos que realmente se necesitan.


Auditoría de acceso a secretos

Incluso con RBAC fuerte, necesitás saber quién está accediendo a tus secretos y cuándo. El logging de auditoría de Kubernetes te da esta visibilidad, pero necesita ser configurado explícitamente porque no está habilitado por defecto en la mayoría de las distribuciones.


La política de auditoría define qué eventos registrar y a qué nivel:


# audit-policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  # Registrar todo acceso a secretos a nivel RequestResponse
  - level: RequestResponse
    resources:
      - group: ""
        resources: ["secrets"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]

  # Registrar solicitudes de tokens (tokens de service account)
  - level: Metadata
    resources:
      - group: ""
        resources: ["serviceaccounts/token"]
    verbs: ["create"]

  # Registrar cambios de RBAC
  - level: RequestResponse
    resources:
      - group: "rbac.authorization.k8s.io"
        resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
    verbs: ["create", "update", "patch", "delete"]

  # Registrar todo lo demás a nivel metadata
  - level: Metadata
    omitStages:
      - "RequestReceived"

Configurá el API server para usar esta política:


# flags del kube-apiserver
--audit-policy-file=/etc/kubernetes/audit-policy.yaml
--audit-log-path=/var/log/kubernetes/audit.log
--audit-log-maxage=30
--audit-log-maxbackup=10
--audit-log-maxsize=100

# O enviá logs de auditoría a un webhook (como Elasticsearch o Loki)
--audit-webhook-config-file=/etc/kubernetes/audit-webhook.yaml

Una entrada de log de auditoría para acceso a secretos se ve así:


{
  "kind": "Event",
  "apiVersion": "audit.k8s.io/v1",
  "level": "RequestResponse",
  "auditID": "abc-123-def-456",
  "stage": "ResponseComplete",
  "requestURI": "/api/v1/namespaces/default/secrets/my-app-secrets",
  "verb": "get",
  "user": {
    "username": "system:serviceaccount:default:my-app-sa",
    "groups": ["system:serviceaccounts", "system:serviceaccounts:default"]
  },
  "sourceIPs": ["10.244.0.15"],
  "objectRef": {
    "resource": "secrets",
    "namespace": "default",
    "name": "my-app-secrets",
    "apiVersion": "v1"
  },
  "responseStatus": {
    "metadata": {},
    "code": 200
  },
  "requestReceivedTimestamp": "2026-03-07T10:30:00.000000Z",
  "stageTimestamp": "2026-03-07T10:30:00.005000Z"
}

Podés construir alertas sobre los logs de auditoría para detectar actividad sospechosa:


# Regla de Falco para detectar acceso a secretos desde service accounts inesperadas
- rule: Unexpected Secret Access
  desc: Detectar cuando una service account que no está en la lista permitida accede a un secreto
  condition: >
    ka.verb in (get, list) and
    ka.target.resource = secrets and
    not ka.user.name in (allowed_secret_readers)
  output: >
    Acceso inesperado a secreto
    (user=%ka.user.name verb=%ka.verb
     secret=%ka.target.name ns=%ka.target.namespace
     source=%ka.sourceips)
  priority: WARNING
  source: k8s_audit
  tags: [security, secrets]

# Regla de alertas de Prometheus basada en métricas de logs de auditoría
# (requiere exportador de métricas de logs de auditoría)
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: secret-access-alerts
  namespace: monitoring
spec:
  groups:
    - name: secret.access
      rules:
        - alert: UnusualSecretAccessRate
          expr: |
            sum(rate(apiserver_audit_event_total{
              resource="secrets",
              verb="get"
            }[5m])) by (user) > 10
          for: 5m
          labels:
            severity: warning
          annotations:
            summary: "Tasa inusual de acceso a secretos por {{ $labels.user }}"
            description: "La service account {{ $labels.user }} está accediendo a secretos a una tasa inusualmente alta"

Combinar logging de auditoría con alertas te da la capacidad de detectar y responder a acceso no autorizado a secretos en casi tiempo real. Esto es crítico para cumplimiento y para atrapar service accounts comprometidas antes de que puedan hacer daño serio.


Juntando todo

Con todas estas herramientas y enfoques, ¿cómo decidís qué usar? Acá hay una matriz de decisión basada en las necesidades y nivel de madurez de tu equipo:


  1. Recién empezando, equipo chico: Usá Sealed Secrets. Es lo más simple de configurar, no requiere infraestructura externa, y resuelve el problema más grande (secretos en Git). Agregá restricciones de RBAC y logging de auditoría básico.
  2. Equipo en crecimiento, cloud-native: Usá External Secrets Operator con el almacén de secretos de tu proveedor de nube (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault). Te da gestión centralizada, rotación automática a través del proveedor de nube, y un flujo GitOps limpio.
  3. Organización grande, cumplimiento estricto: Usá HashiCorp Vault con el Agent Injector o proveedor CSI. Vault te da secretos dinámicos, logging de auditoría detallado, política como código, e integraciones con todo. Combinalo con ESO para un enfoque híbrido.
  4. Puristas de GitOps: Usá SOPS con age o KMS. Todo se queda en Git, encriptado a nivel de valor, con diffs claros en pull requests.
  5. Máxima seguridad: Combiná Vault para almacenamiento de secretos y credenciales dinámicas, ESO para integración con Kubernetes, RBAC con políticas de mínimo privilegio, logging de auditoría con alertas, y rotación automática con TTLs cortos.

Acá hay un modelo de madurez para guiar tu camino:


  • Nivel 0: Secretos hardcodeados en código o commiteados a Git en texto plano. Pará todo y arreglá esto primero.
  • Nivel 1: Kubernetes Secrets con encriptación en reposo habilitada en etcd. Mejor, pero los secretos siguen en manifiestos y no se auditan.
  • Nivel 2: Sealed Secrets o SOPS para secretos encriptados en Git. RBAC restringido a mínimo privilegio. Esta es una base sólida.
  • Nivel 3: External Secrets Operator con almacén de secretos centralizado. Rotación automatizada. Logging de auditoría habilitado.
  • Nivel 4: Vault con secretos dinámicos, credenciales de corta vida, y logging de auditoría integral. Alertas de acceso a secretos. Rotación regular. Controles de cumplimiento implementados.

La mayoría de los equipos van a encontrar que el Nivel 2 o Nivel 3 cubre sus necesidades. El Nivel 4 es para organizaciones con requerimientos estrictos de cumplimiento o blancos de alto valor. Lo importante es ser honesto sobre dónde estás y dar pasos incrementales para mejorar.


Notas finales

La gestión de secretos es uno de esos temas que parece simple en la superficie pero se vuelve complejo rápido. La buena noticia es que el ecosistema de Kubernetes tiene herramientas maduras y probadas en batalla para cada nivel de complejidad, desde Sealed Secrets para equipos chicos hasta Vault para secretos dinámicos de grado empresarial.


La conclusión más importante es esta: base64 no es encriptación, y los Secrets de Kubernetes solos no son suficientes. Elegí una herramienta que se ajuste al tamaño y necesidades de tu equipo, aplicá RBAC de mínimo privilegio, habilitá logging de auditoría, y rotá tus secretos regularmente. No necesitás implementar todo de una, pero deberías saber dónde estás en la escalera de madurez y tener un plan para subir.


¡Espero que te haya resultado útil y lo hayas disfrutado! ¡Hasta la próxima!


Errata

Si encontrás algún error o tenés alguna sugerencia, por favor mandame un mensaje para que se corrija.

También podés revisar el código fuente y los cambios en las fuentes acá



$ Comentarios

Online: 0

Por favor inicie sesión para poder escribir comentarios.

2026-03-07 | Gabriel Garrido