Las tres lecciones anteriores han asegurado la identidad, el canal y el código. Todo eso se ejecuta encima de una plataforma —imágenes, pods, secretos, red del clúster, permisos del API server, pipelines— y un fallo en esa capa deja sin efecto las demás: una imagen con una biblioteca vulnerable, un contenedor que corre como root y escapa al nodo, un Secret legible por cualquiera del namespace, un pod que puede hablar con quien quiera, o un token de CI con permisos de cluster-admin. Esta lección cierra el módulo endureciendo la plataforma de TechCorp: modelo de amenazas y las 4C, imágenes seguras (escaneo con Trivy, firma con cosign, digests), securityContext completo y Pod Security Admission, secretos (Kubernetes Secrets, External Secrets Operator, rotación, OIDC en CI), NetworkPolicy con deny-all por defecto (cerrando lo que 07-02 remitió aquí), RBAC y ServiceAccount por servicio, auditoría y detección, cadena de suministro, y una lista de comprobación con el estado real de TechCorp. Autenticación de usuarios (07-01), TLS/mTLS (07-02) y OWASP/código (07-03) solo se enlazan.

Aviso. Las políticas, versiones y valores de esta lección son didácticos. Antes de aplicarlos en un clúster real, revísalos con un profesional de seguridad de plataforma y con quien responda del cumplimiento; y prueba cada restricción en staging, porque muchas rompen cargas de trabajo que hasta ahora funcionaban.

Contenido

  1. Modelo de amenazas de la plataforma y las 4C
  2. Imágenes seguras: base mínima, escaneo, firma y digests
  3. Seguridad del pod: securityContext completo y Pod Security Admission
  4. Secretos: Kubernetes Secrets, External Secrets Operator y rotación
  5. Secretos en CI: GitHub Environments y OIDC
  6. Red: NetworkPolicy con deny-all por defecto
  7. RBAC de Kubernetes: ServiceAccount por servicio y roles mínimos
  8. Auditoría y detección
  9. Cadena de suministro: lockfile, SBOM y SLSA
  10. Lista de comprobación de plataforma para TechCorp

  1. Modelo de amenazas de la plataforma y las 4C

Antes de endurecer nada, qué puede salir mal y en qué capa:

Amenaza Cómo ocurre Capa Medida en esta lección
Imagen comprometida Dependencia con CVE, imagen base vieja, paquete malicioso en npm Container Base mínima, Trivy en CI, firma cosign, digest
Contenedor que escapa Proceso como root + kernel vulnerable o capabilities de más Container / Cluster securityContext restrictivo, seccomp, Pod Security restricted
Secreto filtrado kubectl get secret por alguien de más, etcd sin cifrar, secreto en un log o en git Cluster / Code RBAC de lectura, cifrado en etcd, External Secrets, rotación, gitleaks (07-03)
Pod que habla con quien no debe Sin NetworkPolicy, cualquier pod alcanza cualquier puerto Cluster Deny-all + reglas explícitas
Credenciales de CI robadas Token de larga duración en un secreto de GitHub Cloud / Cluster OIDC, Environments, GitOps sin kubeconfig fuera
RBAC excesivo cluster-admin para el pipeline o para Argo CD "porque así funciona" Cluster Roles mínimos por namespace
Nube mal configurada Nodos con IP pública, API server abierto, buckets públicos Cloud Responsabilidad de Plataforma/proveedor; fuera del alcance detallado

Las 4C de la seguridad cloud native (Cloud, Cluster, Container, Code) recuerdan que cada capa hereda la seguridad de la exterior: el mejor securityContext no salva un clúster con el API server abierto a Internet, y el mejor clúster no salva un servicio-pedidos con inyección SQL. Esta lección cubre Container y Cluster; Code fue 07-01/07-03; Cloud es del proveedor y de Plataforma.

  1. Imágenes seguras: base mínima, escaneo, firma y digests

El Dockerfile de 05-01 ya cumple lo esencial: node:20-alpine, multi-stage (nada de compiladores ni devDependencies en la imagen final), USER node, COPY --chown, HEALTHCHECK y npm ci --omit=dev con el .npmrc como secreto de build. Lo que se añade:

  • Base mínima: alpine reduce la superficie a unos pocos MB; una alternativa aún menor es distroless (gcr.io/distroless/nodejs20-debian12: sin shell, sin gestor de paquetes; kubectl exec ... sh deja de funcionar, lo que es una ventaja de seguridad y un coste de depuración —se resuelve con kubectl debug y contenedores efímeros—). TechCorp mantiene alpine en la fase 1 y evalúa distroless para Pagos.
  • Escaneo en CI con Trivy, que 05-03 dejó configurado con severity: CRITICAL. La política definitiva: fallar con CRITICAL y HIGH que tengan parche disponible, ignorar las que no lo tienen pero registrarlas, y volver a escanear las imágenes ya desplegadas cada noche (las CVE aparecen después de construir):
# .github/workflows/servicio-node-ci.yml (fragmento, sustituye al de 05-03)
      - name: Escaneo de vulnerabilidades
        uses: aquasecurity/[email protected]
        with:
          image-ref: ${{ env.IMAGEN }}:sha-${{ github.sha }}
          severity: CRITICAL,HIGH
          ignore-unfixed: true                 # sin parche disponible → aviso, no bloqueo (se revisa semanalmente)
          exit-code: "1"
          format: sarif
          output: trivy.sarif
      - uses: github/codeql-action/upload-sarif@v3      # los hallazgos aparecen en la pestaña Security del repo
        with: { sarif_file: trivy.sarif }
  • Firma de imágenes con cosign (Sigstore): tras el push, el pipeline firma la imagen con la identidad OIDC del workflow (sin claves que guardar, keyless), y el clúster verifica la firma antes de arrancar un pod, con un controlador de admisión (Kyverno o el policy controller de Sigstore). Solo mención con los comandos:
# En CI (permissions: id-token: write), tras docker/build-push-action:
cosign sign --yes ghcr.io/techcorp/servicio-pedidos@sha256:9f1c…          # firma por digest, keyless (Fulcio + Rekor)
# En el clúster o a mano, verificar que la firmó el workflow de TechCorp:
cosign verify ghcr.io/techcorp/servicio-pedidos@sha256:9f1c… \
  --certificate-identity-regexp 'https://github.com/techcorp/.*/.github/workflows/servicio-node-ci.yml@.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com
  • imagePullPolicy y digest: una etiqueta (1.0.1) puede reapuntarse; un digest (@sha256:…) no. En producción, el overlay de Kustomize fija newTag y digest (kustomize edit set image ghcr.io/techcorp/servicio-pedidos@sha256:9f1c…), imagePullPolicy: IfNotPresent es seguro porque el digest es inmutable, y el registro ghcr.io/techcorp es privado con imagePullSecrets (o identidad de nube) en el ServiceAccount. latest sigue prohibido (05-01).

  1. Seguridad del pod: securityContext completo y Pod Security Admission

El Deployment de 05-02 tenía runAsNonRoot, runAsUser: 1000, allowPrivilegeEscalation: false y readOnlyRootFilesystem: true. La versión completa, la que exige el perfil restricted:

# k8s/servicio-pedidos/base/deployment.yaml (fragmento spec.template.spec)
    spec:
      serviceAccountName: servicio-pedidos            # apartado 7: identidad propia, sin token montado
      automountServiceAccountToken: false
      securityContext:                                # a nivel de pod: se hereda por todos los contenedores
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000                                 # los volúmenes montados pertenecen al grupo 1000 (secretos como ficheros, apartado 4)
        seccompProfile: { type: RuntimeDefault }      # filtro de syscalls del runtime (bloquea las peligrosas y raras)
      containers:
        - name: servicio-pedidos
          image: ghcr.io/techcorp/servicio-pedidos@sha256:9f1c…     # digest en prod (apartado 2)
          securityContext:
            allowPrivilegeEscalation: false           # ni setuid ni ganar capabilities
            readOnlyRootFilesystem: true              # el sistema de ficheros de la imagen es de solo lectura
            capabilities: { drop: [ALL] }             # ninguna capability de Linux (Node no necesita ninguna en 3002 > 1024)
          volumeMounts:
            - { name: tmp, mountPath: /tmp }          # lo único escribible: temporal, vacío en cada arranque
          resources:                                  # también es seguridad: un contenedor sin límite puede agotar el nodo (05-02, 06-04)
            requests: { cpu: 100m, memory: 128Mi }
            limits:   { cpu: 500m, memory: 256Mi }
      volumes:
        - { name: tmp, emptyDir: { sizeLimit: 64Mi } }

Qué aporta cada línea nueva: seccompProfile: RuntimeDefault activa el perfil seccomp del runtime (containerd) que bloquea decenas de syscalls que ninguna aplicación normal usa y que son la vía de muchas fugas del contenedor; capabilities: drop: [ALL] quita hasta las capabilities que Docker concede por defecto (NET_RAW, CHOWN...): si un atacante ejecuta código dentro, no puede abrir sockets raw ni cambiar propietarios; emptyDir para /tmp hace compatible readOnlyRootFilesystem con librerías que escriben temporales (con sizeLimit para que no llene el nodo); fsGroup permite leer secretos montados como ficheros sin ser root.

Pod Security Admission (PSA) hace que el clúster rechace pods que no cumplan un perfil, en vez de confiar en que cada Deployment lo escriba bien. Tres perfiles: privileged (todo), baseline (sin lo peor: privilegiado, hostPath, hostNetwork), restricted (todo lo anterior más runAsNonRoot, drop ALL, seccomp, sin escalada). Se activa con etiquetas en el namespace:

# k8s/namespace.yaml (amplía el de 05-02)
apiVersion: v1
kind: Namespace
metadata:
  name: techcorp
  labels:
    pod-security.kubernetes.io/enforce: restricted        # rechaza pods que no cumplan
    pod-security.kubernetes.io/enforce-version: v1.30
    pod-security.kubernetes.io/warn: restricted            # avisa en kubectl apply
    pod-security.kubernetes.io/audit: restricted           # anota en el audit log (apartado 8)
kubectl label ns techcorp pod-security.kubernetes.io/warn=restricted --overwrite   # primero solo warn: ver qué se rompería
kubectl apply -k k8s/servicio-pedidos/overlays/dev                                # "Warning: would violate PodSecurity restricted: ..." si falta algo

El Job de migraciones de 05-02, el gateway, RabbitMQ y Keycloak deben cumplirlo también (o vivir en otro namespace con baseline justificado). Para reglas más finas que las de PSA (exigir digest, prohibir latest, exigir resources), se usa un motor de políticas como Kyverno u OPA Gatekeeper; mención.

  1. Secretos: Kubernetes Secrets, External Secrets Operator y rotación

Un Secret de Kubernetes (05-02: pedidos-db, pedidos-rabbitmq, y desde 07-01 pedidos-oidc; pagos-pasarela en 07-02) es base64, no cifrado: kubectl get secret pedidos-db -o jsonpath='{.data.PEDIDOS_DB_URL}' | base64 -d lo muestra a cualquiera con permiso de lectura. Tres capas para que sea seguro:

  1. Cifrado en reposo en etcd (EncryptionConfiguration del API server con aescbc/kms): responsabilidad de Plataforma o del proveedor gestionado (activo por defecto en los principales). Sin esto, una copia de etcd es una copia de todos los secretos.
  2. RBAC de lectura (apartado 7): solo los ServiceAccount de los pods que los montan y unas pocas personas de Plataforma pueden get secretos; los desarrolladores de Pedidos, no. Y el manifiesto del Secret nunca está en techcorp/plataforma (GitOps es público dentro de la empresa).
  3. Fuente de verdad externa con External Secrets Operator (ESO): el secreto vive en un gestor (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager...), y ESO crea y actualiza el Secret de Kubernetes a partir de él. Así git solo contiene la referencia:
# k8s/servicio-pedidos/base/externalsecret-db.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: pedidos-db
  namespace: techcorp
spec:
  refreshInterval: 1h                                  # ESO relee el gestor cada hora: si rotó, actualiza el Secret
  secretStoreRef: { name: techcorp-vault, kind: ClusterSecretStore }   # cómo hablar con el gestor (Vault con auth de Kubernetes)
  target:
    name: pedidos-db                                   # el Secret que espera el Deployment de 05-02: nada cambia en él
    creationPolicy: Owner
  data:
    - secretKey: PEDIDOS_DB_URL                        # clave en el Secret de Kubernetes
      remoteRef: { key: techcorp/prod/pedidos/db, property: url }   # ruta en el gestor
kubectl get externalsecret pedidos-db -n techcorp      # STATUS SecretSynced, READY True
kubectl get secret pedidos-db -n techcorp              # creado por ESO; el manifiesto en git no contiene el valor

Rotación de credenciales. Una contraseña de base de datos vive años porque rotarla da miedo; con esta arquitectura, el procedimiento es rutinario (y se ejecuta al menos semestralmente y siempre tras una fuga, 07-03):

Paso pedidos-db (PostgreSQL) pedidos-rabbitmq
1 Crear la nueva contraseña de svc_pedidos en el gestor (techcorp/prod/pedidos/db); PostgreSQL permite tener dos válidas creando un usuario svc_pedidos_b o cambiando en ventana rabbitmqctl change_password pedidos <nueva> (las conexiones abiertas siguen; las nuevas usan la nueva)
2 ESO actualiza el Secret en ≤ 1 h (o kubectl annotate externalsecret pedidos-db force-sync=$(date +%s)) Igual
3 Los pods no releen variables de entorno: kubectl rollout restart deploy/servicio-pedidos (rolling, sin pérdida, 05-04); con secretos como ficheros (apartado 5) y un config.js que los relea, no haría falta reinicio Igual; el reconectar de amqplib (06-03) usa la nueva URL tras el reinicio
4 Revocar la contraseña antigua; comprobar en logs que no hay password authentication failed rabbitmqctl no tiene "doble contraseña": la ventana es el rolling restart

Un Reloader (Stakater) o la anotación de Argo CD pueden automatizar el paso 3 cuando cambia el Secret.

  1. Secretos en CI: GitHub Environments y OIDC

En 05-03 el pipeline tenía GITHUB_TOKEN, PACT_BROKER_TOKEN, PLATAFORMA_TOKEN y KUBECONFIG_STAGING, y quedó pendiente la mención a OIDC. Lo que se cierra aquí:

  • Environments de GitHub (staging, production): secretos por entorno, required reviewers para producción, y deployment branches para que solo main pueda usarlos. Un workflow de una rama de funcionalidad no ve KUBECONFIG_STAGING.
  • OIDC en vez de tokens de larga duración: con permissions: id-token: write, el workflow obtiene un JWT firmado por GitHub que dice "soy el workflow X del repo Y en la rama Z", y la nube o el registro lo canjean por credenciales de minutos. Es el mismo client credentials de 07-01, con GitHub como emisor. Para el registro (ghcr.io) ya lo hace GITHUB_TOKEN; para un clúster gestionado en la nube:
# .github/workflows/servicio-node-cd.yml (fragmento: acceso a AWS/EKS sin access keys)
permissions: { id-token: write, contents: read }
steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/gha-techcorp-staging-deploy   # rol con permiso SOLO sobre el clúster de staging
      aws-region: eu-west-1
  - run: aws eks update-kubeconfig --name techcorp-staging && kubectl rollout status deploy/servicio-pedidos -n techcorp

Con esto KUBECONFIG_STAGING desaparece como secreto (la confianza es "este repo, esta rama, este workflow", configurada en la política del rol de la nube), y en producción no hay credencial ninguna: Argo CD hace pull (05-03). PLATAFORMA_TOKEN (para abrir el PR de promoción) se sustituye por una GitHub App con permisos acotados al repositorio plataforma. PACT_BROKER_TOKEN se mantiene como secreto de entorno, rotado por calendario.

Ficheros frente a variables de entorno. Las variables de entorno se ven en kubectl describe pod (no los valores de secretRef, pero sí de env: value), en /proc/<pid>/environ y las heredan los procesos hijos; los ficheros montados desde un Secret (volumes: - secret: { secretName: pedidos-db } + PEDIDOS_DB_URL_FILE=/etc/secretos/PEDIDOS_DB_URL) solo los lee quien tiene el UID/fsGroup adecuado, y Kubernetes los actualiza en caliente cuando el Secret cambia. La convención <VARIABLE>_FILE de 04-03 ya está implementada: TechCorp migra a ficheros los secretos de Pagos primero (auditoría) y el resto conforme se toque cada servicio.

  1. Red: NetworkPolicy con deny-all por defecto

Sin NetworkPolicy, cualquier pod del clúster puede abrir una conexión con cualquier otro: un pod comprometido en techcorp llega a servicio-inventario:3006 con X-Usuario-Roles: admin (07-01) o a postgres:5432 para probar contraseñas. Las NetworkPolicy son firewall por etiquetas; requieren un CNI que las aplique (Calico, Cilium; los gestionados suelen traerlo). Estrategia: denegar todo en el namespace y abrir explícitamente.

# k8s/red/00-deny-all.yaml — nadie entra ni sale, salvo lo que se permita después
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: deny-all, namespace: techcorp }
spec:
  podSelector: {}                         # todos los pods del namespace
  policyTypes: [Ingress, Egress]
---
# k8s/red/01-permitir-dns.yaml — todos necesitan resolver nombres (03-05); sin esto, nada funciona
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: permitir-dns, namespace: techcorp }
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: kube-system } }, podSelector: { matchLabels: { k8s-app: kube-dns } } }]
      ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }]
---
# k8s/red/gateway.yaml — el gateway es el ÚNICO que recibe del ingress controller, y solo habla con los servicios públicos
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: gateway, namespace: techcorp }
spec:
  podSelector: { matchLabels: { app: gateway } }
  policyTypes: [Ingress, Egress]
  ingress:
    - from: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: ingress-nginx } } }]
      ports: [{ port: 8080 }]
  egress:
    - to: [{ podSelector: { matchLabels: { app: servicio-catalogo } } }]
      ports: [{ port: 3001 }]
    - to: [{ podSelector: { matchLabels: { app: servicio-pedidos } } }]
      ports: [{ port: 3002 }]
    - to: [{ podSelector: { matchLabels: { app: servicio-clientes } } }]
      ports: [{ port: 3004 }]
    - to: [{ podSelector: { matchLabels: { app: bff-movil } } }]
      ports: [{ port: 3010 }]
    - to: [{ podSelector: { matchLabels: { app: keycloak } } }]        # JWKS (07-01)
      ports: [{ port: 8080 }]
---
# k8s/red/servicio-pedidos.yaml — recibe del gateway (y de Prometheus); habla con Catálogo, Clientes, RabbitMQ, PostgreSQL y Keycloak
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: servicio-pedidos, namespace: techcorp }
spec:
  podSelector: { matchLabels: { app: servicio-pedidos } }
  policyTypes: [Ingress, Egress]
  ingress:
    - from: [{ podSelector: { matchLabels: { app: gateway } } }]
      ports: [{ port: 3002 }]
    - from: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: monitoring } } }]   # /metrics (06-01)
      ports: [{ port: 3002 }]
  egress:
    - to: [{ podSelector: { matchLabels: { app: servicio-catalogo } } }]
      ports: [{ port: 3001 }]
    - to: [{ podSelector: { matchLabels: { app: servicio-clientes } } }]
      ports: [{ port: 3004 }]
    - to: [{ podSelector: { matchLabels: { app: rabbitmq } } }]
      ports: [{ port: 5671 }]                                          # amqps (07-02)
    - to: [{ podSelector: { matchLabels: { app: postgres } } }]
      ports: [{ port: 5432 }]
    - to: [{ podSelector: { matchLabels: { app: keycloak } } }]
      ports: [{ port: 8080 }]                                          # token de servicio y JWKS
    - to: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: observability } } }]   # OTLP a Jaeger (06-02)
      ports: [{ port: 4318 }]
---
# k8s/red/servicio-inventario.yaml — cierra lo que 07-02 dejó: a Inventario SOLO le habla Pedidos
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: servicio-inventario, namespace: techcorp }
spec:
  podSelector: { matchLabels: { app: servicio-inventario } }
  policyTypes: [Ingress]
  ingress:
    - from: [{ podSelector: { matchLabels: { app: servicio-pedidos } } }]
      ports: [{ port: 3006 }]
    - from: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: monitoring } } }]
      ports: [{ port: 3006 }]

Y servicio-pagos es el único con egress a Internet (la pasarela): ipBlock: { cidr: 0.0.0.0/0, except: [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16] } en el puerto 443, o mejor, si el CNI lo soporta (Cilium), una política por FQDN (api.pasarela-pagos.example). El resto de servicios no salen del clúster: un servicio-catalogo comprometido no puede exfiltrar datos ni descargar herramientas.

kubectl apply -f k8s/red/
# Comprobar: desde un pod cualquiera, Inventario no responde; desde Pedidos, sí
kubectl run prueba --rm -it --image=curlimages/curl -n techcorp -- curl -m 3 http://servicio-inventario:3006/health/live   # timeout
kubectl exec deploy/servicio-pedidos -n techcorp -- wget -qO- http://servicio-inventario:3006/health/live               # {"estado":"ok"}
flowchart LR
    IN[ingress-nginx] -->|8080| GW[gateway]
    GW -->|3001| CA[servicio-catalogo]
    GW -->|3002| PE[servicio-pedidos]
    GW -->|3004| CL[servicio-clientes]
    PE -->|3001| CA
    PE -->|3004| CL
    PE -->|3006| INV[servicio-inventario]
    PE -->|5671 amqps| MQ[(rabbitmq)]
    PE -->|5432| PG[(postgres)]
    PA[servicio-pagos] -->|443| EXT((pasarela externa))
    PR[prometheus] -.->|/metrics| PE & INV & CA & CL
    X[cualquier otro pod] -. bloqueado .-x INV

Con las políticas, la advertencia de 07-01 sobre X-Usuario-* pasa de "cualquier pod" a "solo el gateway y Prometheus pueden alcanzar Pedidos" —y mTLS con mesh (07-02) añadirá, cuando llegue, identidad criptográfica además de red. Las políticas viven en techcorp/plataforma/k8s/red/ y las revisa Plataforma; cada servicio nuevo llega con la suya en la plantilla.

  1. RBAC de Kubernetes: ServiceAccount por servicio y roles mínimos

Todo pod corre con un ServiceAccount (SA); por defecto, default, con un token montado en /var/run/secrets/kubernetes.io/serviceaccount que permite hablar con el API server. servicio-pedidos no necesita hablar con el API server, así que: SA propio (identidad clara para NetworkPolicy en algunos CNI, para mTLS con mesh en 07-02, y para imagePullSecrets) y sin token montado:

# k8s/servicio-pedidos/base/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata: { name: servicio-pedidos, namespace: techcorp }
automountServiceAccountToken: false      # y en el Deployment (apartado 3) también; si un día necesita la API, un Role mínimo y token proyectado con caducidad
imagePullSecrets: [{ name: ghcr-techcorp }]

Quien habla con el API server, con lo mínimo:

Identidad Necesita Rol
Workflow de CD en staging (vía OIDC, apartado 5) apply de Deployment/Service/ConfigMap/Job y rollout status en techcorp Role desplegador en techcorp (get/list/watch/create/update/patch sobre deployments, services, configmaps, jobs, pods solo lectura); RoleBinding al grupo/usuario federado. Nunca cluster-admin
Argo CD (producción) Aplicar los manifiestos de overlays/prod Argo CD trae su SA; se le da un Role por namespace gestionado (techcorp, monitoring), no el ClusterRole admin por defecto de la instalación rápida
Prometheus Descubrir pods y endpoints ClusterRole de solo lectura (get/list/watch de pods, services, endpoints)
External Secrets Operator Crear/actualizar Secret Role en cada namespace con secrets (create/update/get), no cluster-admin
Personas Desarrollador de Pedidos: leer pods/logs y hacer port-forward en techcorp (view + pods/portforward); Plataforma: admin en techcorp; nadie usa cluster-admin a diario (cuenta de emergencia auditada) RoleBinding a grupos del proveedor de identidad (Keycloak/SSO también aquí)
# k8s/rbac/desplegador.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: desplegador, namespace: techcorp }
rules:
  - { apiGroups: ["apps"],  resources: ["deployments"],           verbs: ["get", "list", "watch", "create", "update", "patch"] }
  - { apiGroups: [""],      resources: ["services", "configmaps"], verbs: ["get", "list", "create", "update", "patch"] }
  - { apiGroups: ["batch"], resources: ["jobs"],                  verbs: ["get", "list", "watch", "create", "delete"] }
  - { apiGroups: [""],      resources: ["pods", "pods/log"],      verbs: ["get", "list", "watch"] }
  # ni "secrets" ni "delete deployments": los secretos los pone ESO; borrar es manual y auditado
kubectl auth can-i list secrets -n techcorp --as=system:serviceaccount:techcorp:servicio-pedidos   # no
kubectl auth can-i create deployments -n techcorp --as=desplegador-staging                          # yes
kubectl auth can-i '*' '*' --as=desplegador-staging                                                 # no

  1. Auditoría y detección

  • Audit log del API server: registra quién hizo qué contra la API (kubectl get secret pedidos-db por parte de una persona, un create de Role, un pod que no cumple PSA). Se configura con una Policy de auditoría (nivel Metadata para todo, RequestResponse para secrets, roles y rolebindings) y se envía a Loki (06-01) con retención larga, separada de los logs de aplicación (07-03). En clústeres gestionados se activa desde el proveedor.
  • Detección en tiempo de ejecución: Falco (mención) observa syscalls en los nodos y alerta ante comportamientos anómalos —una shell abierta en un contenedor de producción, un proceso que lee /etc/shadow, una conexión saliente desde un pod que no debería salir— y se integra con Alertmanager (06-05). Con readOnlyRootFilesystem, drop ALL y NetworkPolicies, la mayoría de esos comportamientos ya fallan; Falco cuenta el intento.
  • Higiene: kube-bench (CIS Benchmark) para la configuración del clúster y kubectl get events -A --field-selector reason=FailedCreate para ver pods rechazados por PSA.

  1. Cadena de suministro: lockfile, SBOM y SLSA

La cadena de suministro es todo lo que hay entre el código de Luis y el pod en producción, y cada eslabón puede sustituirse por algo malicioso. Lo que TechCorp fija: package-lock.json y npm ci (05-01/07-03: exactamente lo que se probó es lo que se despliega); dependencias y acciones de CI fijadas por versión (actions/checkout@v4, mejor por SHA para las críticas) y la imagen base por digest (node:20-alpine@sha256:…, actualizada por Renovate); SBOM por imagen con Syft (syft ghcr.io/techcorp/servicio-pedidos:1.0.1 -o spdx-json > sbom.json) adjunto a la imagen (cosign attest) para responder en minutos "¿qué servicios llevan libxml2 2.9.x?"; firma cosign y verificación en admisión (apartado 2); y SLSA (Supply-chain Levels for Software Artifacts) como marco de referencia: TechCorp está en el nivel 2 (build en CI con procedencia firmada) y aspira al 3 (runners efímeros y aislados). Solo mención: lo esencial es que el pipeline de 05-03 sea el único camino a producción, y que produzca artefactos verificables.

  1. Lista de comprobación de plataforma para TechCorp

Estado al cierre del módulo 7, mantenido en techcorp/plataforma/SEGURIDAD.md:

Área Comprobación Estado
Imagen Base node:20-alpine por digest, multi-stage, USER node, sin devDependencies Hecho (05-01)
Trivy CRITICAL/HIGH bloqueante + escaneo nocturno de imágenes desplegadas Hecho / Pendiente (nocturno)
Firma cosign + verificación en admisión Pendiente (fase 2)
Pod securityContext completo (runAsNonRoot, drop ALL, seccomp, RO rootfs, emptyDir /tmp), límites de recursos Hecho en Pedidos y Catálogo; pendiente en gateway, Notificaciones
PSA restricted en techcorp (enforce) warn activo; enforce pendiente hasta migrar RabbitMQ/Keycloak a namespace propio
Secretos Cifrado en etcd (proveedor), RBAC de lectura, sin manifiestos de Secret en git Hecho
ESO desde el gestor externo para todos los Secret de aplicación; rotación semestral documentada Hecho pedidos-db, pedidos-rabbitmq, pedidos-oidc, pagos-pasarela; resto pendiente
Secretos como ficheros _FILE Pagos en curso
CI Environments con revisores; OIDC para nube y registro; sin kubeconfig en secretos; GitHub App en vez de PLATAFORMA_TOKEN Hecho / Pendiente (GitHub App)
Red Deny-all + DNS + políticas por servicio; solo Pagos con egress externo Hecho en techcorp
RBAC SA por servicio sin token; Role desplegador; Argo CD acotado; sin cluster-admin diario Hecho / Argo pendiente
Auditoría Audit log a Loki con retención larga; Falco Hecho / Pendiente
Cadena Lockfile, versiones fijadas, SBOM con Syft, SLSA 2 Hecho salvo SBOM (en curso)

Errores Comunes y Consejos

  • Aplicar deny-all sin la política de DNS: todo deja de resolver y parece que "Kubernetes se ha roto". DNS primero, siempre.
  • Olvidar el scraping de Prometheus, Jaeger o Keycloak en las políticas: métricas y trazas desaparecen en silencio. Revisa cada to/from con los diagramas de 06-01/06-02.
  • readOnlyRootFilesystem sin emptyDir en /tmp: la aplicación falla al arrancar con EROFS en la primera escritura de una dependencia.
  • PSA enforce de golpe en un namespace con RabbitMQ, Keycloak o el ingress controller: pods rechazados. warn/audit primero, luego enforce.
  • Secret en el repositorio GitOps "porque está en base64": es texto plano. Referencia con ExternalSecret, valor en el gestor.
  • Rotar la contraseña y no reiniciar los pods (variables de entorno): fallan al reconectar horas después, de madrugada. Rotación = cambio + sincronización + rollout restart (o ficheros en caliente).
  • cluster-admin para el pipeline o Argo CD "de momento": el momento dura años. Role por namespace desde el primer día.
  • Consejo: cada restricción nueva se prueba con un game day (06-03): intenta hacer lo prohibido (kubectl run con root, curl a Inventario desde otro pod, kubectl get secret como desarrollador) y comprueba que falla y que queda en el audit log.

Ejercicios

Ejercicio 1. Escribe la NetworkPolicy de servicio-notificaciones (puerto 3005): consume de RabbitMQ (amqps), envía correo a un proveedor externo por HTTPS, expone /metrics a Prometheus, y nadie del clúster debe poder llamar a su API salvo Prometheus. Indica el riesgo que introduce el egress externo y cómo acotarlo.

Ejercicio 2. Un desarrollador de Catálogo pide poder ejecutar kubectl exec en los pods de servicio-catalogo en staging "para depurar Mongo". Diseña el Role/RoleBinding mínimo, di qué no incluye y propón una alternativa mejor coherente con esta lección.

Ejercicio 3. Recorre el modelo de amenazas del apartado 1 para el escenario "un atacante consigue ejecutar código dentro del pod de servicio-catalogo gracias a una dependencia comprometida" y enumera, capa por capa, qué medidas de esta lección limitan el daño y qué podría hacer aún.

Soluciones

Solución 1.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: servicio-notificaciones, namespace: techcorp }
spec:
  podSelector: { matchLabels: { app: servicio-notificaciones } }
  policyTypes: [Ingress, Egress]
  ingress:
    - from: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: monitoring } } }]
      ports: [{ port: 3005 }]
  egress:
    - to: [{ podSelector: { matchLabels: { app: rabbitmq } } }]
      ports: [{ port: 5671 }]
    - to: [{ podSelector: { matchLabels: { app: keycloak } } }]        # si valida tokens o pide el suyo
      ports: [{ port: 8080 }]
    - to: [{ ipBlock: { cidr: 0.0.0.0/0, except: [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16] } }]
      ports: [{ port: 443 }]

(DNS lo cubre permitir-dns.) El riesgo: el egress a "cualquier IP pública en 443" permite exfiltrar datos (correos, nombres) si el pod se compromete. Acotarlo: política por FQDN del proveedor de correo con Cilium (toFQDNs: [{ matchName: api.correo.example }]), o un proxy de salida (egress gateway) con lista blanca de dominios que además da un único punto de auditoría; y la minimización de 07-03 (Notificaciones no persiste datos personales) reduce lo que hay para exfiltrar.

Solución 2. Role depurador-catalogo en techcorp con pods (get/list), pods/log (get), pods/exec (create) limitado a los pods de Catálogo — RBAC no filtra por etiqueta, así que se hace con resourceNames (frágil, los nombres cambian) o, mejor, dando el Role en un namespace techcorp-catalogo si se separa por equipo; RoleBinding al grupo equipo-experiencia-compra del SSO. No incluye secrets, exec en otros pods, ni nada de escritura. Alternativas mejores: (1) kubectl debug con un contenedor efímero (kubectl debug -it pod/servicio-catalogo-xxx --image=mongo:7 --target=servicio-catalogo) en vez de exec en el contenedor de la aplicación, que además funciona con distroless; (2) un port-forward a MongoDB (pods/portforward) y el cliente de Mongo en local con el usuario svc_catalogo_ro de solo lectura; (3) que lo que necesite verse esté en logs/métricas (06-01) para no entrar en producción nunca. Y todo exec/debug queda en el audit log (apartado 8).

Solución 3. Con código ejecutándose como el usuario node (uid 1000) en el pod de Catálogo: Contenedor: no es root, drop ALL y seccomp bloquean la mayoría de vías de escape; readOnlyRootFilesystem impide instalar herramientas (solo /tmp de 64 MB); resources.limits evitan tumbar el nodo. Secretos: ve las variables de entorno del proceso (MONGO_URL de Catálogo) —con ficheros _FILE seguiría viéndolas, es su propio secreto—; no puede leer otros Secret (SA sin token, y sin RBAC). Red: solo alcanza MongoDB de Catálogo, Keycloak y lo que su política permita; no llega a Pedidos, Inventario, PostgreSQL ni RabbitMQ, y no sale a Internet (sin egress externo): puede leer/modificar el catálogo (su daño real) pero no exfiltrarlo fácilmente ni pivotar. Identidad: sin cabeceras válidas ni token de servicio de otro (los X-Usuario-* no le sirven porque no llega a otros servicios; con mesh, tampoco tendría certificado). Detección: Falco alertaría de la shell o del proceso raro; el audit log no vería nada porque no toca la API. Qué podría hacer aún: cambiar precios o descripciones en MongoDB (mitigado por auditoría de 07-03 y el usuario svc_catalogo sin dropDatabase), servir respuestas falsas a Pedidos (mitigado por validación con zod del ACL en Pedidos y precios congelados en el pedido) y consumir CPU hasta el límite. Y la lección de fondo: la dependencia comprometida debió detectarla Trivy/npm audit/SBOM antes de llegar (apartados 2 y 9).

Conclusión

Con esta lección la plataforma deja de ser el eslabón débil bajo la identidad, el canal y el código. TechCorp tiene un modelo de amenazas por capas (4C); imágenes mínimas escaneadas con Trivy (CRITICAL/HIGH bloqueante) y referenciadas por digest, con cosign y verificación en admisión como siguiente paso; pods con securityContext completo (runAsNonRoot, drop ALL, seccompProfile: RuntimeDefault, readOnlyRootFilesystem con emptyDir en /tmp) y Pod Security Admission restricted en techcorp; secretos cifrados en etcd, restringidos por RBAC y provistos por External Secrets Operator (ExternalSecret pedidos-db desde el gestor externo) con un procedimiento de rotación y rollout restart; CI con Environments y OIDC en lugar de kubeconfig y tokens de larga duración, y secretos como ficheros _FILE donde más importa; NetworkPolicy deny-all con DNS, gateway como única entrada desde el Ingress, Pedidos hacia Catálogo/Clientes/RabbitMQ/PostgreSQL/Keycloak, Inventario solo desde Pedidos —cerrando lo que 07-02 remitió aquí— y solo Pagos con salida a Internet; ServiceAccount por servicio sin token, Role desplegador y Argo CD acotados, sin cluster-admin; audit log del API server y Falco como detección; lockfile, versiones fijadas, SBOM y SLSA como cadena de suministro; y una lista de comprobación con lo hecho y lo pendiente. El módulo 7 termina así donde empezó el 6: con un sistema observable, resiliente, escalable, operable y ahora también seguro por capas —autenticación y autorización con Keycloak y JWT, canal cifrado y firmado, código validado y auditado, plataforma endurecida—, y con una lista honesta de lo que queda para la fase 2 (mTLS con mesh, cosign en admisión, PSA enforce, ESO para todos los servicios). El módulo 8 recoge todo el recorrido: cómo se ejecutó la migración del monolito paso a paso, la implementación completa de los servicios, su despliegue y operación de extremo a extremo, y las lecciones aprendidas por Marta, Luis y los cuatro equipos de TechCorp.

Curso de Microservicios

Módulo 1: Introducción a los Microservicios

Módulo 2: Diseño de Microservicios

Módulo 3: Comunicación entre Microservicios

Módulo 4: Implementación de Microservicios

Módulo 5: Despliegue y Orquestación

Módulo 6: Monitoreo y Mantenimiento

Módulo 7: Seguridad en Microservicios

Módulo 8: Casos de Estudio y Ejemplos Prácticos

© Copyright 2026. Todos los derechos reservados