SRE: Gestión de Secretos en Kubernetes
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:
- 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.
- 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.
- 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.
- 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.
- 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: 0Por favor inicie sesión para poder escribir comentarios.