Guilgo Blog

Notas de mi quehacer diario con las técnologias.

En febrero publicamos cómo auditar Kubernetes con Wazuh: el API server envía eventos al webhook, el webhook los mete en el socket de analysisd y Wazuh dispara reglas + Telegram. Meses después, al revisar el pipeline antes de reactivarlo en el homelab (k3s + Wazuh-lite en Docker), la guía base seguía siendo el mapa… pero el terreno había cambiado.

En corto: la auditoría no falla solo por “no configurar el webhook”. Falla cuando la política registra el ruido del plano de control, cuando el puerto del tutorial ya está ocupado, o cuando el listener trata un EventList como un solo datagrama.

auditoría útil ≠ registrar todo

Este post documenta esa evolución: qué teníamos, qué rompía, qué se cambió y cómo volver atrás sin drama. Complementa la guía de febrero; no la sustituye.

Serie Wazuh-lite: Control parental · Grafana · Auditoría Kubernetes (guía base) · DNS tunneling · SOC proactivo · AUR malware · urlscan · Docker listener · Este post

Cómo evolucionó el pipeline

Fase Cuándo Qué había Qué aprendimos
1. Guía base feb 2026 Webhook Flask, política con catch-all Metadata, puerto 8080, reglas 110003–110006, Telegram El flujo API server → Wazuh funciona y se puede alertar create/delete
2. Homelab k3s mar–ago 2026 Adaptación a k3s (kube-apiserver-arg), overlay Docker, más integraciones en el mismo host El mismo host acumula servicios; 8080 deja de ser “el puerto libre del tutorial”
3. Revisión pre-reactivación oct 2026 Disco raíz al 72 %, política ruidosa, webhook que enviaba el EventList entero Activar “tal cual” era riesgo de DiskPressure y eventos perdidos en el socket
4. Hardening oct 2026 Política first-match, puerto 8088, un evento por mensaje + truncado de campos pesados Volumen útil (miles/día), no cientos de miles; checklist y vuelta atrás documentada

El hilo conductor no es “más features”. Es menos ruido, más señal, y un restart de k3s que no te deje sin clúster.

Arquitectura (recordatorio)

k3s (API server)
  → audit-policy.yaml (qué se registra)
  → audit-webhook.yaml (HTTPS al listener)
    → custom-webhook.py (Flask, puerto 8088)
      → socket analysisd (ubicación "k8s")
        → reglas 110003–110006
          → custom-k8s-audit-telegram.py → Telegram

En nuestro caso: un solo nodo k3s (raam), Wazuh-lite en Docker y el webhook como servicio del overlay docker-compose.k8s-audit.yml.

1. El catch-all que te come el disco

La política original cerraba así:

# Catch-all
- level: Metadata
  omitStages:
    - RequestReceived

Parece inocente. En un clúster real registra, entre otras cosas:

Recurso / verbo Por qué duele
leases (leader election) cientos de eventos por minuto
events el propio clúster se audita a sí mismo
endpoints / endpointslices reconciliación continua de servicios
get / list / watch lecturas constantes de controladores

En las pruebas y estimaciones de este homelab (un nodo pequeño con ~30 pods y CronJobs activos), ese catch-all se va a cientos de miles de eventos al día. Con JSON de ~1 KB, fácilmente cientos de MB/día si el audit log va a disco, o una inundación del socket/webhook si solo usas webhook. No es una cifra universal para “cualquier clúster de 30 pods”: depende de controladores, CronJobs y carga; aquí bastaba para no activar la política “tal cual”.

Con el raíz al 72 % (umbral operativo 75 %), activar esa política sin filtrar no era una mejora de seguridad: era un riesgo de DiskPressure.

Política endurecida (first-match)

apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  - level: None
    nonResourceURLs: ['/healthz*', '/logs', '/metrics', '/swagger*', '/version']

  - level: None
    resources:
      - group: coordination.k8s.io
        resources: [leases]
      - group: ''
        resources: [events, endpoints]
      - group: discovery.k8s.io
        resources: [endpointslices]

  - level: None
    verbs: [get, list, watch]

  - level: Metadata
    omitStages: [RequestReceived]
    resources:
      - group: authentication.k8s.io
        resources: [tokenreviews]

  - level: RequestResponse
    omitStages: [RequestReceived]
    resources:
      - group: authorization.k8s.io
        resources: [subjectaccessreviews]

  - level: RequestResponse
    omitStages: [RequestReceived]
    resources:
      - group: ''
        resources: [pods]
        verbs: [create, patch, update, delete]

  # Catch-all: solo mutaciones (get/list/watch ya descartados)
  - level: Metadata
    omitStages: [RequestReceived]

Con esto, en este homelab, el volumen útil baja al orden de miles de eventos/día (mutaciones + auth/authz), no cientos de miles.

El orden de las reglas importa

Kubernetes evalúa la política en modo first-match: gana la primera regla que coincide; el resto no se mira. Por eso el YAML no es “una lista de preferencias”, es un embudo:

  1. Primero descartas ruido (None: healthz, leases, events, endpoints*, get/list/watch).
  2. Después elevas lo que sí quieres con nivel explícito (tokenreviews, subjectaccessreviews, mutaciones de pods).
  3. Al final el catch-all Metadata solo ve lo que no matcheó antes — en la práctica, mutaciones del resto de recursos.

Si pones el catch-all arriba, o el None de verbos debajo de una regla más específica que ya no alcanza, te comes el diseño entero.

Por qué estos niveles (no “la política correcta”)

Regla Nivel Por qué
tokenreviews Metadata Basta saber quién se autenticó; el cuerpo completo suele ser ruido y volumen.
subjectaccessreviews RequestResponse Aquí la señal es qué se intentó autorizar y cuál fue la decisión (allowed: true/false en el response); sin cuerpo pierdes el valor forense.
mutaciones de pods RequestResponse create/patch/update/delete de pods es el impacto operativo habitual; el objeto ayuda a triar.
catch-all Metadata El resto de mutaciones: quién/qué/dónde sin inflar el payload.

El descarte global de get / list / watch es deliberado: prioriza mutaciones y corta el ruido. También significa que una lectura potencialmente sensible (Secrets, ConfigMaps, etc.) no queda auditada con este perfil. Si necesitas trazabilidad de lecturas concretas, añade reglas específicas antes de ese None de verbos. No es “la política correcta” en abstracto; es una política adaptada a un homelab donde el objetivo es alertar cambios, no grabar cada listado de controlador.

Antes vs ahora (política)

Antes (guía base) Ahora (hardening)
Catch-all Metadata sin exclusiones de ruido leases / events / endpoints* / get|list|watch en level: None
Todo lectura + mutación sale por el webhook Solo mutaciones (+ tokenreviews / SAR con nivel explícito)
Riesgo de llenar disco o saturar analysisd Volumen acotado al interés forense

2. El puerto 8080 ya estaba ocupado

La guía usa https://IP:8080. En el host, ese puerto lo tenía otro contenedor (en nuestro caso qbittorrent). El API server no habría podido entregar eventos aunque la política estuviera perfecta.

Cambio aplicado:

  • WEBHOOK_PORT=8088
  • audit-webhook.yaml → https://10.5.5.3:8088
  • compose del overlay alineado al mismo puerto

Antes de activar: ss -tlnp | grep 8088 debe estar vacío, y los certificados del webhook generados (gen-certs.sh).

3. EventList entero ≠ un evento para analysisd

Kubernetes envía un JSON tipo:

{
  "apiVersion": "audit.k8s.io/v1",
  "kind": "EventList",
  "items": [ { "...evento1..." }, { "...evento2..." } ]
}

El webhook original hacía un único send() del objeto completo al socket Unix de Wazuh (SOCK_DGRAM). Al crecer el lote, el mensaje puede superar los límites efectivos del socket o de Wazuh (dependen del SO, buffers y configuración) y perderse o provocar errores de envío.

Corrección: iterar items[] y enviar un evento por mensaje. Eso es lo importante: cada audit event llega a analysisd como datagrama independiente. Si un evento individual sigue siendo enorme (p. ej. requestObject de un Secret), truncamos campos pesados y dejamos metadata suficiente para las reglas (verb, requestURI, usuario, etc.).

items = data.get("items") or [data]
for item in items:
    send_to_wazuh_socket(item)  # "1:k8s:{json}"

Las reglas locales (110003 create, 110004 delete, 110005 patch, 110006 update) siguen haciendo match sobre "verb": "create" (etc.) con regex PCRE2, sin depender del orden exacto de campos del EventList completo.

Antes vs ahora (webhook)

Antes Ahora
Un send() del EventList completo Un mensaje por elemento de items[]
Puerto por defecto 8080 Puerto 8088 (configurable con WEBHOOK_PORT)
Sin techo explícito de tamaño Truncado de requestObject / responseObject si supera ~30 KB

4. Reinicio de k3s y vuelta atrás

Activar la auditoría implica tocar /etc/rancher/k3s/config.yaml y reiniciar k3s. En un single-node eso tumba todos los pods unos minutos.

Bloque típico:

kube-apiserver-arg:
  - audit-policy-file=/var/lib/rancher/k3s/server/audit-policy.yaml
  - audit-webhook-config-file=/var/lib/rancher/k3s/server/audit-webhook.yaml
  - audit-webhook-batch-max-size=1
  # Opcional si algún día usas audit-log-path:
  # - audit-log-maxsize=100
  # - audit-log-maxbackup=3
  # - audit-log-maxage=7

Vuelta atrás si el API server no arranca:

  1. Quitar el bloque kube-apiserver-arg de auditoría (o los flags mal puestos).
  2. sudo systemctl restart k3s
  3. kubectl get nodes → Ready

Marca el bloque con un comentario claro (# Auditoría Kubernetes -> Wazuh) para encontrarlo en dos segundos a las 3 de la mañana.

5. Checklist antes de activar

Paso Criterio
Política sin catch-all ruidoso leases / events / endpoints* / get|list|watch en level: None
Puerto libre p. ej. 8088, no el 8080 “por defecto del tutorial”
Certificados TLS del webhook gen-certs.sh ejecutado; server.crt / server.key presentes
Webhook arrancado y alcanzable POST HTTPS al listener responde
Disco raíz holgura razonable (> umbral operativo)
Prueba kubectl create deploy audit-test --image=nginx + delete → alertas 110003/110004

6. Qué no cubre este post

  • Filtrar en n8n/IRIS por service account de controladores (eso es triaje de SOC, no política de audit).
  • Sustituir Telegram por un pipeline SOAR completo.
  • Audit log a fichero local en k3s: si lo activas, configura rotación (maxsize / maxbackup / maxage) desde el día 1.

Cómo verificar (homelab)

# Tras activar política + webhook + k3s
kubectl get nodes
kubectl create deployment audit-test --image=nginx
sleep 5
kubectl delete deployment audit-test

# En Wazuh: alertas rule_id 110003 / 110004
# En Telegram (si custom-k8s-audit-telegram.py está activo): mensaje de create/delete

Código de referencia: wazuh-lite/k8s-audit/ (audit-policy.yaml, custom-webhook.py, setup-k3s-audit.sh, overlay docker-compose.k8s-audit.yml).

Conclusión

auditoría útil ≠ registrar todo

La guía de febrero montó el camino. Ocho meses después, el mismo homelab lo dejó escrito en la pared. Endurece la política, mide volumen, deja el webhook enviando eventos uno a uno y documenta la vuelta atrás del reinicio de k3s. Después, el post de febrero sigue siendo el mapa; este es el “qué mirar antes de darle a restart”.