SRE: Seguridad como Código
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, GitOps, gestión de secretos, optimización de costos, gestión de dependencias, confiabilidad de bases de datos, y ingeniería de releases. Todos esos temas asumen que tu cluster y tus workloads son seguros, pero la seguridad muchas veces se trata como algo que se ve después o como problema de otro.
Eso se termina hoy. La seguridad es una preocupación de SRE porque un incidente de seguridad es simplemente otro tipo de incidente que quema tu presupuesto de error, erosiona la confianza de los usuarios y genera caos operacional. El enfoque shift-left significa que definimos políticas de seguridad como código, las aplicamos automáticamente y tratamos las violaciones de seguridad de la misma forma que tratamos las violaciones de SLOs: con indicadores medibles, respuestas automatizadas y mejora continua.
En este artículo vamos a cubrir el stack completo de seguridad como código para Kubernetes: control de admisión con OPA Gatekeeper, Pod Security Standards, network policies, escaneo de imágenes en CI, hardening de RBAC, audit logging, seguridad en runtime con Falco, y seguridad de la cadena de suministro con Cosign y Kyverno. Todo como código, todo automatizado.
Vamos al tema.
Políticas con OPA y Gatekeeper
Open Policy Agent (OPA) es un motor de políticas de uso general, y Gatekeeper es la forma nativa de Kubernetes de usarlo. Gatekeeper actúa como un controlador de admisión que intercepta cada request al API server de Kubernetes y la evalúa contra tus políticas antes de permitirla o denegarla.
Lo bueno de este enfoque es que tus políticas de seguridad se vuelven código que vive en Git, se revisa en PRs y se aplica automáticamente. No más rezar para que los desarrolladores se acuerden de poner los labels correctos o evitar contenedores privilegiados.
Instalando Gatekeeper
Instalar Gatekeeper en tu cluster es sencillo con Helm:
# Instalar Gatekeeper via Helm
helm repo add gatekeeper https://open-policy-agent.github.io/gatekeeper/charts
helm repo update
helm install gatekeeper gatekeeper/gatekeeper \
--namespace gatekeeper-system \
--create-namespace \
--set replicas=3 \
--set audit.replicas=2 \
--set audit.logLevel=INFO
O si preferís un enfoque declarativo con ArgoCD:
# argocd/gatekeeper-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: gatekeeper
namespace: argocd
spec:
project: default
source:
repoURL: https://open-policy-agent.github.io/gatekeeper/charts
chart: gatekeeper
targetRevision: 3.15.0
helm:
values: |
replicas: 3
audit:
replicas: 2
logLevel: INFO
destination:
server: https://kubernetes.default.svc
namespace: gatekeeper-system
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
ConstraintTemplate: Requerir labels
Gatekeeper usa dos recursos: ConstraintTemplates (la lógica de la política en Rego) y Constraints (cómo aplicarlas). Acá hay un template que requiere labels específicos en todos los recursos:
# policies/templates/require-labels.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequiredlabels
spec:
crd:
spec:
names:
kind: K8sRequiredLabels
validation:
openAPIV3Schema:
type: object
properties:
labels:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequiredlabels
violation[{"msg": msg, "details": {"missing_labels": missing}}] {
provided := {label | input.review.object.metadata.labels[label]}
required := {label | label := input.parameters.labels[_]}
missing := required - provided
count(missing) > 0
msg := sprintf("Al recurso le faltan labels requeridos: %v", [missing])
}
Y el constraint que lo aplica a todos los namespaces:
# policies/constraints/require-labels.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
name: all-must-have-owner
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Namespace"]
- apiGroups: ["apps"]
kinds: ["Deployment", "StatefulSet"]
parameters:
labels:
- "app.kubernetes.io/name"
- "app.kubernetes.io/managed-by"
- "team"
ConstraintTemplate: Bloquear pods privilegiados
Esta es crítica. Los contenedores privilegiados tienen acceso total al host, lo que significa que un escape de contenedor le da al atacante root en el nodo:
# policies/templates/block-privileged.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8sblockprivileged
spec:
crd:
spec:
names:
kind: K8sBlockPrivileged
validation:
openAPIV3Schema:
type: object
properties:
allowedImages:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8sblockprivileged
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
container.securityContext.privileged == true
msg := sprintf("Los contenedores privilegiados no están permitidos: %v", [container.name])
}
violation[{"msg": msg}] {
container := input.review.object.spec.initContainers[_]
container.securityContext.privileged == true
msg := sprintf("Los init containers privilegiados no están permitidos: %v", [container.name])
}
# policies/constraints/block-privileged.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sBlockPrivileged
metadata:
name: no-privileged-containers
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
- apiGroups: ["apps"]
kinds: ["Deployment", "StatefulSet", "DaemonSet"]
excludedNamespaces:
- kube-system
- gatekeeper-system
ConstraintTemplate: Forzar registry de imágenes
Probablemente no querés imágenes random de Docker Hub corriendo en producción. Esta política restringe las imágenes a tus registries de confianza:
# policies/templates/allowed-registries.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8sallowedregistries
spec:
crd:
spec:
names:
kind: K8sAllowedRegistries
validation:
openAPIV3Schema:
type: object
properties:
registries:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8sallowedregistries
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not registry_allowed(container.image)
msg := sprintf("La imagen '%v' es de un registry no confiable. Registries permitidos: %v",
[container.image, input.parameters.registries])
}
violation[{"msg": msg}] {
container := input.review.object.spec.initContainers[_]
not registry_allowed(container.image)
msg := sprintf("La imagen del init container '%v' es de un registry no confiable. Registries permitidos: %v",
[container.image, input.parameters.registries])
}
registry_allowed(image) {
registry := input.parameters.registries[_]
startswith(image, registry)
}
# policies/constraints/allowed-registries.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRegistries
metadata:
name: trusted-registries-only
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
- apiGroups: ["apps"]
kinds: ["Deployment", "StatefulSet", "DaemonSet"]
excludedNamespaces:
- kube-system
parameters:
registries:
- "ghcr.io/kainlite/"
- "docker.io/kainlite/"
- "registry.k8s.io/"
- "quay.io/"
Con estas tres políticas solas ya tenés una base sólida: cada recurso necesita labels de ownership, nadie puede correr contenedores privilegiados, y solo imágenes de registries de confianza están permitidas.
Pod Security Standards
Kubernetes trae incorporados los Pod Security Standards (PSS) que proporcionan tres niveles de perfiles de seguridad. Funcionan a nivel de namespace y no requieren ningún controlador externo como Gatekeeper. Son un excelente punto de partida si querés algo simple que cubra lo básico.
Los tres perfiles son:
- Privileged: Sin restricciones. Permite todo. Se usa para workloads a nivel de sistema como plugins de CNI y agentes de monitoreo.
- Baseline: Previene escalaciones de privilegios conocidas. Bloquea hostNetwork, hostPID, contenedores privilegiados y la mayoría de las capabilities peligrosas. Buen default para la mayoría de los workloads.
- Restricted: Altamente restringido. Requiere non-root, dropea todas las capabilities, no permite escalación de privilegios. El estándar de oro para workloads de aplicaciones.
Enforcement a nivel de namespace
Aplicás los perfiles PSS usando labels en los namespaces. Hay tres modos:
- enforce: Rechaza pods que violan la política
- audit: Permite pods pero registra las violaciones
- warn: Permite pods pero muestra un warning al usuario
Una buena estrategia de rollout es empezar con warn y audit, revisar las violaciones, corregirlas, y después cambiar a enforce:
# namespaces/production.yaml
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
# namespaces/staging.yaml
apiVersion: v1
kind: Namespace
metadata:
name: staging
labels:
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
Haciendo tus pods compatibles
Para el perfil restricted, tus pods necesitan cumplir varios requisitos. Así se ve un pod spec compatible:
# deployments/tr-web.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: tr-web
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: tr-web
template:
metadata:
labels:
app.kubernetes.io/name: tr-web
app.kubernetes.io/managed-by: argocd
team: platform
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: tr-web
image: ghcr.io/kainlite/tr:latest
ports:
- containerPort: 4000
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Las configuraciones de seguridad clave son: runAsNonRoot, allowPrivilegeEscalation: false, dropear todas las
capabilities, filesystem root de solo lectura, y un perfil de seccomp. Si falta alguna, el perfil restricted va
a rechazar el pod.
Network policies
Por defecto, cada pod en Kubernetes puede hablar con todos los demás pods. Eso es terrible para la seguridad. Si un atacante compromete un pod, puede moverse libremente de forma lateral a cualquier otro servicio en el cluster. Las network policies solucionan esto definiendo qué tráfico está permitido.
Default deny para todo
Lo primero que deberías hacer es crear una política default deny para cada namespace. Esto bloquea todo el tráfico que no esté explícitamente permitido:
# network-policies/default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
Ahora nada puede hablar con nada. Es hora de permitir el tráfico que realmente necesitás.
Permitir tráfico específico
Acá hay una política que permite al frontend web recibir tráfico del ingress controller y hablar con la base de datos:
# network-policies/tr-web.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-tr-web
namespace: production
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: tr-web
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
podSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
ports:
- protocol: TCP
port: 4000
egress:
# Permitir DNS
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Permitir acceso a la base de datos
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: postgresql
ports:
- protocol: TCP
port: 5432
Network policies con Cilium
Si estás usando Cilium como tu CNI, tenés acceso a network policies más poderosas que pueden filtrar a nivel L7 (HTTP, gRPC, DNS):
# cilium-policies/tr-web-l7.yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: tr-web-l7-policy
namespace: production
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: tr-web
ingress:
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: ingress-nginx
io.kubernetes.pod.namespace: ingress-nginx
toPorts:
- ports:
- port: "4000"
protocol: TCP
rules:
http:
- method: GET
- method: POST
path: "/api/.*"
- method: HEAD
egress:
- toEndpoints:
- matchLabels:
app.kubernetes.io/name: postgresql
toPorts:
- ports:
- port: "5432"
protocol: TCP
# Política de DNS
- toEndpoints:
- matchLabels:
k8s-app: kube-dns
io.kubernetes.pod.namespace: kube-system
toPorts:
- ports:
- port: "53"
protocol: ANY
rules:
dns:
- matchPattern: "*.production.svc.cluster.local"
- matchPattern: "*.kube-system.svc.cluster.local"
El filtrado L7 es increíblemente poderoso. Podés restringir no solo qué pods pueden hablar entre sí, sino también qué métodos HTTP y paths están permitidos. Esto significa que incluso si un atacante compromete el pod web, solo puede hacer exactamente las llamadas API que el pod web se supone que hace.
Escaneo de imágenes en CI
Detectar vulnerabilidades antes de que lleguen a tu cluster es mucho mejor que detectarlas en runtime. Trivy es un excelente escáner open-source que chequea imágenes de contenedores por CVEs conocidos, misconfiguraciones y secretos expuestos.
Trivy en GitHub Actions
Acá hay un workflow completo de CI que escanea tus imágenes y bloquea el deployment si se encuentran vulnerabilidades de alta severidad:
# .github/workflows/security-scan.yaml
name: Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
trivy-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: |
docker build -t ghcr.io/kainlite/tr:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/kainlite/tr:${{ github.sha }}
format: table
exit-code: 1
ignore-unfixed: true
vuln-type: os,library
severity: CRITICAL,HIGH
output: trivy-results.txt
- name: Run Trivy for SARIF output
uses: aquasecurity/trivy-action@master
if: always()
with:
image-ref: ghcr.io/kainlite/tr:${{ github.sha }}
format: sarif
output: trivy-results.sarif
ignore-unfixed: true
vuln-type: os,library
severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
- name: Scan Kubernetes manifests
uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: ./k8s/
format: table
exit-code: 1
severity: CRITICAL,HIGH
Las partes clave son: exit-code: 1 hace que el pipeline falle cuando se encuentran vulnerabilidades,
ignore-unfixed: true saltea CVEs que todavía no tienen fix (para no bloquearte en cosas que no podés
arreglar), y la subida SARIF manda los resultados a la pestaña Security de GitHub para visibilidad.
Escaneando Helm charts e IaC
Trivy también puede escanear tus manifiestos de Kubernetes, Helm charts y archivos de Terraform por misconfiguraciones:
# .github/workflows/iac-scan.yaml
name: IaC Security Scan
on:
pull_request:
paths:
- 'k8s/**'
- 'terraform/**'
- 'charts/**'
jobs:
trivy-config-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan Kubernetes manifests
uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: ./k8s/
format: table
exit-code: 1
severity: CRITICAL,HIGH
- name: Scan Terraform
uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: ./terraform/
format: table
exit-code: 1
severity: CRITICAL,HIGH
- name: Scan Helm charts
uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: ./charts/
format: table
exit-code: 0
severity: CRITICAL,HIGH,MEDIUM
Esto detecta problemas como contenedores corriendo como root, limits de recursos faltantes, network policies faltantes y RBAC demasiado permisivo antes de que se mergeen.
Buenas prácticas de RBAC
Role-Based Access Control (RBAC) es cómo controlás quién puede hacer qué en tu cluster de Kubernetes. El principio de menor privilegio es simple: dale a cada usuario, service account y automatización solo los permisos que realmente necesita y nada más.
ClusterRole vs Role
La primera regla: preferí Role sobre ClusterRole siempre que sea posible. Un Role tiene alcance de namespace, así que un service account comprometido solo puede afectar ese namespace. Un ClusterRole aplica a todo el cluster.
# rbac/tr-web-role.yaml
# Role con alcance de namespace para la aplicación
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: tr-web
namespace: production
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["tr-web-config"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: tr-web
namespace: production
subjects:
- kind: ServiceAccount
name: tr-web
namespace: production
roleRef:
kind: Role
name: tr-web
apiGroup: rbac.authorization.k8s.io
Hardening de service accounts
Cada pod debería tener su propio service account con solo los permisos que necesita. El service account por defecto en cada namespace no debería tener permisos y el automount debería estar deshabilitado:
# rbac/default-sa-lockdown.yaml
# Deshabilitar automounting para el service account por defecto
apiVersion: v1
kind: ServiceAccount
metadata:
name: default
namespace: production
automountServiceAccountToken: false
---
# Crear un service account dedicado para la app
apiVersion: v1
kind: ServiceAccount
metadata:
name: tr-web
namespace: production
labels:
app.kubernetes.io/name: tr-web
team: platform
automountServiceAccountToken: true
ClusterRoles agregados para acceso de equipo
Para el acceso humano al cluster, usá ClusterRoles agregados que componen permisos de varios roles más chicos. Esto hace fácil agregar nuevos permisos sin editar un role monolítico:
# rbac/team-roles.yaml
# Role base de solo lectura para todos los miembros del equipo
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: team-readonly
labels:
rbac.kainlite.com/aggregate-to-developer: "true"
rbac.kainlite.com/aggregate-to-sre: "true"
rules:
- apiGroups: [""]
resources: ["pods", "services", "configmaps", "events"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "daemonsets", "replicasets"]
verbs: ["get", "list", "watch"]
- apiGroups: ["batch"]
resources: ["jobs", "cronjobs"]
verbs: ["get", "list", "watch"]
---
# Permisos adicionales para desarrolladores
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: developer-extra
labels:
rbac.kainlite.com/aggregate-to-developer: "true"
rules:
- apiGroups: [""]
resources: ["pods/log", "pods/portforward"]
verbs: ["get", "create"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["create"]
---
# Permisos adicionales para SREs
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: sre-extra
labels:
rbac.kainlite.com/aggregate-to-sre: "true"
rules:
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets"]
verbs: ["patch", "update"]
- apiGroups: ["apps"]
resources: ["deployments/rollback"]
verbs: ["create"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list", "watch", "cordon", "uncordon"]
---
# Role agregado para desarrolladores
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: developer
aggregationRule:
clusterRoleSelectors:
- matchLabels:
rbac.kainlite.com/aggregate-to-developer: "true"
rules: []
---
# Role agregado para SREs
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: sre
aggregationRule:
clusterRoleSelectors:
- matchLabels:
rbac.kainlite.com/aggregate-to-sre: "true"
rules: []
El patrón de agregación significa que podés agregar un nuevo ClusterRole con el label correcto y automáticamente se incluye en el role agregado. No necesitás editar el role padre, lo que significa menos conflictos de merge y un historial de Git más limpio.
Audit logging
El audit logging de Kubernetes registra cada request al API server. Esto es esencial para investigaciones de seguridad, requisitos de compliance y entender quién hizo qué y cuándo. Sin audit logs, un incidente de seguridad se convierte en adivinanzas.
Política de auditoría
Necesitás una política de auditoría que defina qué loguear y a qué nivel. Acá hay una política práctica que captura los eventos importantes sin ahogarte en ruido:
# audit/audit-policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
# No loguear requests a ciertos paths de URLs no-resource
- level: None
nonResourceURLs:
- /healthz*
- /readyz*
- /livez*
- /metrics
# No loguear requests de watch (demasiado ruidosas)
- level: None
verbs: ["watch"]
# No loguear kube-proxy y system:nodes
- level: None
users:
- system:kube-proxy
verbs: ["get", "list"]
# Loguear acceso a secrets a nivel Metadata (no loguear los valores del secret)
- level: Metadata
resources:
- group: ""
resources: ["secrets"]
# Loguear todos los cambios en pods y deployments a nivel RequestResponse
- level: RequestResponse
verbs: ["create", "update", "patch", "delete"]
resources:
- group: ""
resources: ["pods", "pods/exec", "pods/portforward"]
- group: "apps"
resources: ["deployments", "statefulsets", "daemonsets"]
# Loguear cambios de RBAC a nivel RequestResponse
- level: RequestResponse
verbs: ["create", "update", "patch", "delete"]
resources:
- group: "rbac.authorization.k8s.io"
resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"]
# Loguear cambios de namespaces
- level: RequestResponse
verbs: ["create", "update", "patch", "delete"]
resources:
- group: ""
resources: ["namespaces"]
# Loguear todo lo demás a nivel Metadata
- level: Metadata
omitStages:
- RequestReceived
Enviando audit logs a tu stack de observabilidad
Los audit logs necesitan ir a algún lugar útil. Si estás usando el stack de Loki del artículo de observabilidad, podés configurar el API server para escribir audit logs a un archivo y hacer que Promtail los envíe a Loki:
# audit/promtail-audit-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: promtail-audit-config
namespace: monitoring
data:
promtail.yaml: |
server:
http_listen_port: 3101
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: kubernetes-audit
static_configs:
- targets:
- localhost
labels:
job: kubernetes-audit
__path__: /var/log/kubernetes/audit/*.log
pipeline_stages:
- json:
expressions:
level: level
verb: verb
user: user.username
resource: objectRef.resource
namespace: objectRef.namespace
name: objectRef.name
responseCode: responseStatus.code
- labels:
level:
verb:
user:
resource:
namespace:
- timestamp:
source: stageTimestamp
format: RFC3339Nano
Con audit logs en Loki, podés crear dashboards de Grafana que muestren quién está accediendo a tu cluster, qué cambios se están haciendo, y alertar sobre actividad sospechosa como alguien creando un ClusterRoleBinding o haciendo exec en un pod de producción.
Falco para seguridad en runtime
Gatekeeper y PSS previenen que configuraciones malas entren al cluster, pero, ¿qué pasa con ataques en runtime? Ahí es donde entra Falco. Falco monitorea system calls a nivel de kernel y alerta cuando detecta comportamiento sospechoso como un shell siendo creado en un contenedor, archivos sensibles siendo leídos, o conexiones de red inesperadas.
Instalando Falco
Falco se puede instalar como DaemonSet usando Helm:
# Instalar Falco con Helm
helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update
helm install falco falcosecurity/falco \
--namespace falco \
--create-namespace \
--set falcosidekick.enabled=true \
--set falcosidekick.config.slack.webhookurl="https://hooks.slack.com/services/XXX" \
--set driver.kind=ebpf \
--set collectors.kubernetes.enabled=true
Reglas custom de Falco
Falco viene con un conjunto completo de reglas por defecto, pero deberías agregar reglas custom específicas para tu entorno. Acá van algunos ejemplos prácticos:
# falco/custom-rules.yaml
# Detectar exec en pods de producción
- rule: Exec en pod de producción
desc: Detectar cuando alguien hace exec en un pod del namespace production
condition: >
spawned_process
and container
and k8s.ns.name = "production"
and proc.pname = "runc:[2:INIT]"
output: >
Shell creado en pod de producción
(user=%ka.user.name pod=%k8s.pod.name ns=%k8s.ns.name
container=%container.name command=%proc.cmdline)
priority: WARNING
tags: [security, shell, production]
# Detectar lectura de archivos sensibles
- rule: Lectura de archivo sensible en contenedor
desc: Detectar lectura de archivos sensibles como /etc/shadow o claves privadas
condition: >
open_read
and container
and (fd.name startswith /etc/shadow
or fd.name startswith /etc/gshadow
or fd.name contains id_rsa
or fd.name contains id_ed25519
or fd.name endswith .pem
or fd.name endswith .key)
output: >
Archivo sensible leído en contenedor
(user=%user.name file=%fd.name pod=%k8s.pod.name
ns=%k8s.ns.name container=%container.name)
priority: WARNING
tags: [security, filesystem, sensitive]
# Detectar conexiones salientes inesperadas
- rule: Conexión saliente inesperada desde producción
desc: Detectar conexiones salientes a IPs que no están en la lista permitida
condition: >
outbound
and container
and k8s.ns.name = "production"
and not (fd.sip in (allowed_outbound_ips))
and not (fd.sport in (53, 443, 5432))
output: >
Conexión saliente inesperada desde producción
(pod=%k8s.pod.name ns=%k8s.ns.name ip=%fd.sip port=%fd.sport
command=%proc.cmdline container=%container.name)
priority: NOTICE
tags: [security, network, production]
# Detectar drift de contenedor (nuevos ejecutables escritos y ejecutados)
- rule: Drift de contenedor detectado
desc: Detectar cuando nuevos ejecutables son escritos en el filesystem de un contenedor y luego ejecutados
condition: >
spawned_process
and container
and proc.is_exe_upper_layer = true
output: >
Drift detectado: nuevo ejecutable corrido en contenedor
(user=%user.name command=%proc.cmdline pod=%k8s.pod.name
ns=%k8s.ns.name container=%container.name image=%container.image.repository)
priority: ERROR
tags: [security, drift]
# Detectar minería de criptomonedas
- rule: Detectar actividad de minería de criptomonedas
desc: Detectar procesos conocidos asociados con minería de criptomonedas
condition: >
spawned_process
and container
and (proc.name in (xmrig, minerd, cpuminer, cryptonight)
or proc.cmdline contains "stratum+tcp"
or proc.cmdline contains "pool.minexmr")
output: >
Posible minería de criptomonedas detectada
(pod=%k8s.pod.name ns=%k8s.ns.name process=%proc.name
command=%proc.cmdline container=%container.name)
priority: CRITICAL
tags: [security, crypto, mining]
Falco te da visibilidad de lo que realmente está pasando dentro de tus contenedores a nivel de system calls. Combinado con network policies (que controlan qué tráfico está permitido) y Gatekeeper (que controla qué configuraciones están permitidas), tenés defensa en profundidad cubriendo tiempo de configuración, capa de red y runtime.
Seguridad de la cadena de suministro
Tus imágenes de contenedores son tan confiables como el proceso que las construyó. Los ataques a la cadena de suministro, donde un atacante compromete una dependencia o pipeline de build para inyectar código malicioso, se volvieron cada vez más comunes. La solución es firmar tus imágenes y verificar esas firmas antes de permitir que corran.
Firmando imágenes con Cosign
Cosign del proyecto Sigstore hace fácil firmar y verificar imágenes de contenedores. Así se integra en tu pipeline de CI:
# .github/workflows/build-and-sign.yaml
name: Build, Sign, and Push
on:
push:
branches: [main]
permissions:
contents: read
packages: write
id-token: write # Requerido para firma keyless
jobs:
build-sign-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Cosign
uses: sigstore/cosign-installer@main
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/kainlite/tr:${{ github.sha }}
- name: Sign the image with Cosign (keyless)
env:
COSIGN_EXPERIMENTAL: "true"
run: |
cosign sign --yes \
ghcr.io/kainlite/tr@${{ steps.build.outputs.digest }}
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: ghcr.io/kainlite/tr:${{ github.sha }}
format: spdx-json
output-file: sbom.spdx.json
- name: Attach SBOM to image
run: |
cosign attach sbom \
--sbom sbom.spdx.json \
ghcr.io/kainlite/tr@${{ steps.build.outputs.digest }}
El flag --yes usa firma keyless, lo que significa que Cosign obtiene un certificado de corta duración de la
CA Fulcio de Sigstore vinculado a tu identidad OIDC de GitHub Actions. No hay claves de larga duración para
manejar o rotar.
Generación de SBOM
Un Software Bill of Materials (SBOM) es una lista de cada componente en tu imagen. Es esencial para rastrear cuáles de tus imágenes se ven afectadas cuando se publica un nuevo CVE. El workflow de arriba genera un SBOM en formato SPDX y lo adjunta a la imagen en el registry.
Verificando firmas con Kyverno
Ahora que tus imágenes están firmadas, necesitás forzar que solo imágenes firmadas puedan correr en el cluster. Kyverno es un motor de políticas de Kubernetes que puede verificar firmas de Cosign en el momento de admisión:
# kyverno/verify-image-signature.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signatures
annotations:
policies.kyverno.io/title: Verificar Firmas de Imágenes
policies.kyverno.io/description: >
Verificar que todas las imágenes de contenedores estén firmadas con Cosign
usando firma keyless de nuestros workflows de GitHub Actions.
spec:
validationFailureAction: Enforce
background: false
webhookTimeoutSeconds: 30
rules:
- name: verify-signature
match:
any:
- resources:
kinds:
- Pod
namespaces:
- production
- staging
verifyImages:
- imageReferences:
- "ghcr.io/kainlite/*"
attestors:
- entries:
- keyless:
subject: "https://github.com/kainlite/tr/.github/workflows/*"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: https://rekor.sigstore.dev
mutateDigest: true
required: true
# kyverno/require-sbom.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-sbom-attestation
spec:
validationFailureAction: Audit
background: false
rules:
- name: check-sbom
match:
any:
- resources:
kinds:
- Pod
namespaces:
- production
verifyImages:
- imageReferences:
- "ghcr.io/kainlite/*"
attestations:
- type: https://spdx.dev/Document
attestors:
- entries:
- keyless:
subject: "https://github.com/kainlite/tr/.github/workflows/*"
issuer: "https://token.actions.githubusercontent.com"
conditions:
- all:
- key: "{{ creationInfo.created }}"
operator: NotEquals
value: ""
Con este setup, el flujo completo de la cadena de suministro es: GitHub Actions construye la imagen, la firma con Cosign usando firma keyless, genera y adjunta un SBOM, y Kyverno verifica la firma antes de permitir que la imagen corra en el cluster. Si alguien pushea una imagen sin firmar o una imagen que no fue construida por tu pipeline de CI, Kyverno la rechaza.
SLOs de seguridad
Si venís siguiendo la serie de SRE, sabés que si no podés medirlo, no podés mejorarlo. La seguridad no es diferente. Igual que rastreás SLOs de disponibilidad y latencia, deberías rastrear métricas de seguridad como SLIs.
Tiempo de remediación de vulnerabilidades
¿Cuánto le lleva a tu equipo parchear un CVE crítico después de que se descubre? Esta es una de las métricas de seguridad más importantes:
# prometheus-rules/security-slis.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: security-slis
namespace: monitoring
spec:
groups:
- name: security.slis
interval: 1h
rules:
# Rastrear conteo de vulnerabilidades críticas a lo largo del tiempo
- record: security:critical_cves:total
expr: |
sum(trivy_vulnerability_count{severity="CRITICAL"})
# Rastrear conteo de vulnerabilidades altas
- record: security:high_cves:total
expr: |
sum(trivy_vulnerability_count{severity="HIGH"})
# Rastrear tiempo desde el CVE crítico sin parchear más viejo
- record: security:oldest_critical_cve_age_days
expr: |
(time() - min(trivy_vulnerability_first_seen{severity="CRITICAL"})) / 86400
# Violaciones de política detectadas por auditoría de Gatekeeper
- record: security:policy_violations:total
expr: |
sum(gatekeeper_violations)
# Tasa de alertas de Falco
- record: security:falco_alerts:rate1h
expr: |
sum(rate(falco_events_total{priority=~"WARNING|ERROR|CRITICAL"}[1h]))
Definición de SLOs de seguridad
Definí SLOs concretos para tu postura de seguridad:
# security-slos.yaml
security_slos:
vulnerability_remediation:
description: "Los CVEs críticos deben parchearse en 7 días"
sli: security:oldest_critical_cve_age_days
objective: 7
measurement: "Días desde el CVE crítico sin parchear más viejo"
policy_compliance:
description: "Cero violaciones de políticas de Gatekeeper en producción"
sli: security:policy_violations:total
objective: 0
measurement: "Total de violaciones de política activas"
runtime_security:
description: "Cero alertas críticas de Falco en producción"
sli: security:falco_alerts:rate1h
objective: 0
measurement: "Alertas críticas y de error de Falco por hora"
image_signing:
description: "100% de las imágenes de producción deben estar firmadas"
sli: kyverno:policy_violations:image_signature
objective: 0
measurement: "Imágenes sin firmar bloqueadas o corriendo"
Alertando sobre SLOs de seguridad
Configurá alertas que se disparen cuando tus SLOs de seguridad estén en riesgo:
# prometheus-rules/security-alerts.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: security-alerts
namespace: monitoring
spec:
groups:
- name: security.alerts
rules:
- alert: CriticalCVEUnpatchedTooLong
expr: security:oldest_critical_cve_age_days > 5
for: 1h
labels:
severity: warning
team: platform
annotations:
summary: "CVE crítico sin parchear por más de 5 días"
description: "El CVE crítico sin parchear más viejo tiene {{ $value }} días. El objetivo del SLO es 7 días."
runbook: "https://runbooks.example.com/patch-critical-cve"
- alert: GatekeeperPolicyViolations
expr: security:policy_violations:total > 0
for: 5m
labels:
severity: warning
team: platform
annotations:
summary: "Violaciones de políticas de Gatekeeper detectadas"
description: "Se encontraron {{ $value }} violaciones de política en el cluster."
- alert: FalcoCriticalAlert
expr: security:falco_alerts:rate1h > 0
for: 0m
labels:
severity: critical
team: platform
annotations:
summary: "Falco detectó evento de seguridad crítico"
description: "Falco está reportando {{ $value }} eventos críticos/error por hora."
Tratar las métricas de seguridad como SLIs te da los mismos beneficios que los SLOs de confiabilidad: podés medir el progreso, definir objetivos, alertar cuando las cosas se desvían, y tomar decisiones basadas en datos sobre dónde invertir tus esfuerzos de seguridad.
Juntando todo
Acá hay un resumen del stack completo de seguridad como código que construimos:
- OPA Gatekeeper: Políticas de control de admisión que fuerzan labels, bloquean contenedores privilegiados y restringen registries de imágenes
- Pod Security Standards: Perfiles de seguridad a nivel de namespace incluidos en Kubernetes (Privileged, Baseline, Restricted)
- Network policies: Default deny con reglas de allow explícitas, filtrado L7 con Cilium
- Escaneo de imágenes con Trivy: Pipeline de CI que bloquea deployments con vulnerabilidades críticas
- Hardening de RBAC: Roles de menor privilegio, aislamiento de service accounts, ClusterRoles agregados
- Audit logging: Registrando actividad del API server y enviándola a tu stack de observabilidad
- Seguridad en runtime con Falco: Detectando comportamiento sospechoso a nivel de system calls
- Seguridad de la cadena de suministro: Firma de imágenes con Cosign, generación de SBOM, verificación con Kyverno
- SLOs de seguridad: Midiendo y alertando sobre tiempo de remediación de vulnerabilidades y métricas de compliance
Cada capa cubre una fase diferente de la superficie de ataque: Gatekeeper y PSS previenen configuraciones malas, las network policies limitan el radio de explosión, Trivy detecta vulnerabilidades conocidas, RBAC restringe el acceso, los audit logs proporcionan evidencia forense, Falco detecta ataques en runtime, y la seguridad de la cadena de suministro asegura la integridad de las imágenes.
Ninguna capa sola es perfecta, pero juntas crean defensa en profundidad que hace significativamente más difícil que un atacante tenga éxito y mucho más fácil para vos detectar y responder cuando algo sale mal.
Notas finales
Seguridad como código no se trata de comprar herramientas caras o lograr puntajes de compliance perfectos. Se trata de aplicar la misma disciplina de ingeniería que usamos para confiabilidad a la seguridad: definir políticas como código, aplicarlas automáticamente, medir el compliance y mejorar continuamente.
Empezá de a poco. Si no hacés nada más, agregá una network policy de default deny a tu namespace de producción y habilitá el Pod Security Standard restricted. Esos dos cambios solos van a reducir significativamente tu superficie de ataque. Después podés ir sumando políticas de Gatekeeper, escaneo de imágenes, Falco y seguridad de la cadena de suministro a medida que la madurez de tu equipo crece.
Lo importante es hacer de la seguridad un proceso continuo, no una auditoría de una sola vez. Tratá el tiempo de remediación de CVEs como tratás los SLOs de latencia. Medilo, alertá sobre eso e invertí en mejorarlo. Tu yo del futuro durante el próximo incidente de seguridad te lo va a agradecer.
¡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.