Guilgo Blog

Notas de mi quehacer diario con las técnologias.

Kubernetes concentra credenciales, secretos y el plano de control en el API server. Un k delete namespace malicioso o un patch a un Role con privilegios es impacto real. Registrar y auditar lo que pasa en el clúster no es opcional si quieres saber quién hizo qué. Si ya usas Wazuh como SIEM (por ejemplo en Monitorización de Active Directory y Office365 con Wazuh o priorización de parches WSUS con Wazuh), tiene sentido enviar los audit logs de Kubernetes al mismo Wazuh para centralizar alertas.

En este post adaptamos el flujo que propone Wazuh: un listener tipo webhook en el servidor Wazuh que recibe los logs del clúster, la auditoría activada en Kubernetes con reenvío a ese webhook, y reglas en Wazuh para alertar ante eventos como creación o borrado de recursos. La fuente original es el artículo Auditing Kubernetes with Wazuh.


Requisitos

  • Servidor Wazuh (4.3.x o superior; probado en 4.3.x, debería funcionar igual en ramas 4.x posteriores salvo cambios en analysisd). Puedes seguir la guía de instalación de Wazuh o usar la OVA oficial.
  • Clúster Kubernetes bajo tu control (self-managed). Este ejemplo está orientado a clústeres tipo kubeadm o Minikube; en otros despliegues (K3s, etc.) solo cambia la ruta del manifest del API server. Para pruebas vale un clúster local; en producción, tu clúster real. El API server debe poder llegar por HTTPS al servidor Wazuh en el puerto del webhook.

En Kubernetes gestionados (EKS, GKE, AKS) el patrón es distinto: los audit logs suelen ir por CloudTrail, Cloud Logging o Azure Diagnostics, y se integran con Wazuh desde ahí. Lo dejo para otro post.


Configurar el servidor Wazuh: webhook para recibir logs de Kubernetes

La idea es levantar un listener (webhook) en el servidor Wazuh que reciba peticiones POST con los audit logs que envía Kubernetes, y que ese listener inyecte los eventos en la cola interna de Wazuh (socket de analysisd) para que se analicen con las reglas.

Certificados entre Wazuh y Kubernetes

Kubernetes envía los audit logs por HTTPS al webhook. Hay que crear certificados en el servidor Wazuh para ese servicio.

  1. Crear el directorio para certificados:
mkdir -p /var/ossec/integrations/kubernetes-webhook/
  1. Crear el archivo de configuración para la CSR, por ejemplo /var/ossec/integrations/kubernetes-webhook/csr.conf. Sustituye <IP_SERVIDOR_WAZUH> por la IP de tu servidor Wazuh:
[ req ]
prompt = no
default_bits = 2048
default_md = sha256
distinguished_name = req_distinguished_name
x509_extensions = v3_req
[req_distinguished_name]
C = ES
ST = Madrid
L = Madrid
O = Wazuh
OU = Seguridad
CN = <IP_SERVIDOR_WAZUH>
[ v3_req ]
authorityKeyIdentifier=keyid,issuer
basicConstraints = CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
IP.1 = <IP_SERVIDOR_WAZUH>
  1. Generar la CA raíz:
openssl req -x509 -new -nodes -newkey rsa:2048 \
  -keyout /var/ossec/integrations/kubernetes-webhook/rootCA.key \
  -out /var/ossec/integrations/kubernetes-webhook/rootCA.pem \
  -batch -subj "/C=ES/ST=Madrid/L=Madrid/O=Wazuh"
  1. Crear clave privada y CSR del servidor:
openssl req -new -nodes -newkey rsa:2048 \
  -keyout /var/ossec/integrations/kubernetes-webhook/server.key \
  -out /var/ossec/integrations/kubernetes-webhook/server.csr \
  -config /var/ossec/integrations/kubernetes-webhook/csr.conf
  1. Firmar el certificado del servidor:
openssl x509 -req -in /var/ossec/integrations/kubernetes-webhook/server.csr \
  -CA /var/ossec/integrations/kubernetes-webhook/rootCA.pem \
  -CAkey /var/ossec/integrations/kubernetes-webhook/rootCA.key -CAcreateserial \
  -out /var/ossec/integrations/kubernetes-webhook/server.crt \
  -extfile /var/ossec/integrations/kubernetes-webhook/csr.conf -extensions v3_req

Listener webhook en Python

El listener recibe JSON por POST y lo envía al socket de Wazuh con el prefijo k8s para poder filtrar por ubicación en las reglas.

  1. Instalar Flask (con el Python del framework de Wazuh):
/var/ossec/framework/python/bin/pip3 install --upgrade flask
  1. Crear el script del webhook, por ejemplo /var/ossec/integrations/custom-webhook.py. Sustituye <IP_SERVIDOR_WAZUH> por la IP en la que quieres escuchar (normalmente la del servidor Wazuh):
#!/var/ossec/framework/python/bin/python3
import json
from socket import socket, AF_UNIX, SOCK_DGRAM
from flask import Flask, request

PORT     = 8080   # configurable; si lo cambias, actualiza también firewall-cmd y la URL del webhook en el kubeconfig (audit-webhook.yaml)
CERT     = '/var/ossec/integrations/kubernetes-webhook/server.crt'
CERT_KEY = '/var/ossec/integrations/kubernetes-webhook/server.key'
socket_addr = '/var/ossec/queue/sockets/queue'

def send_event(msg):
    string = '1:k8s:{0}'.format(json.dumps(msg))
    sock = socket(AF_UNIX, SOCK_DGRAM)
    sock.connect(socket_addr)
    sock.send(string.encode())
    sock.close()
    return True

app = Flask(__name__)
context = (CERT, CERT_KEY)

@app.route('/', methods=['POST'])
def webhook():
    if request.method == 'POST':
        if send_event(request.json):
            print("Request sent to Wazuh")
        else:
            print("Failed to send request to Wazuh")
    return "Webhook received!"

if __name__ == '__main__':
    app.run(host='<IP_SERVIDOR_WAZUH>', port=PORT, ssl_context=context)
  1. Crear el servicio systemd /etc/systemd/system/wazuh-webhook.service:
[Unit]
Description=Wazuh webhook para Kubernetes
Wants=network-online.target
After=network.target network-online.target

[Service]
User=wazuh
Group=wazuh
ExecStart=/var/ossec/framework/python/bin/python3 /var/ossec/integrations/custom-webhook.py
Restart=on-failure
# Ajusta User/Group al usuario y grupo real de tu instalación (en algunas se llama ossec)

[Install]
WantedBy=multi-user.target
  1. Activar y arrancar el servicio:
systemctl daemon-reload
systemctl enable wazuh-webhook.service
systemctl start wazuh-webhook.service
systemctl status wazuh-webhook.service
  1. Abrir el puerto 8080 (o el que uses en PORT) en el firewall del servidor Wazuh si lo usas (por ejemplo con firewalld):
firewall-cmd --permanent --add-port=8080/tcp
firewall-cmd --reload

Seguridad del listener: Este webhook no tiene rate limiting ni autenticación del payload: cualquiera que llegue al puerto puede enviar POST e inyectar eventos falsos en Wazuh, y un atacante podría floodear el endpoint. En producción conviene restringir por IP de origen (solo los nodos del API server) en firewall o en el propio script, o validar un token compartido en un header (p. ej. X-Webhook-Token) antes de llamar a send_event. Siguiente nivel: mTLS con client-certificate y client-key en el kubeconfig del webhook y validación del certificado de cliente en Flask, de modo que solo el API server (con cert firmado por tu CA) pueda enviar al endpoint.

Wazuh en Docker: Si el manager corre en contenedor, el socket de analysisd está dentro de ese contenedor. Opciones: (A) Contenedor adicional del webhook que comparte un volumen con el manager donde está el socket (p. ej. /var/ossec/queue); el webhook debe ejecutarse con el mismo UID que el proceso que crea el socket o tendrás el Errno 13. (B) Ejecutar el webhook dentro del mismo contenedor del manager (entrypoint o proceso secundario). En ambos casos, expón el puerto del webhook (8080) para que el nodo master del clúster pueda hacer POST por HTTPS.


Configurar la auditoría en Kubernetes

Hay que definir qué se audita (política) y dónde se envía (webhook). Esto se hace en el nodo donde corre el API server (en Minikube/kubeadm suele ser el nodo master).

Política de auditoría

Crea /etc/kubernetes/audit-policy.yaml (o la ruta que use tu instalación). Este ejemplo no registra healthz/metrics/version, limita token reviews a Metadata para no loguear tokens, y registra a nivel RequestResponse los cambios en pods (create/patch/update/delete); el resto a Metadata:

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

  - 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']

  - level: Metadata
    omitStages:
      - RequestReceived

Configuración del webhook en Kubernetes

Crea /etc/kubernetes/audit-webhook.yaml con la URL del servidor Wazuh (sustituye <IP_SERVIDOR_WAZUH>). Para pruebas rápidas puedes usar insecure-skip-tls-verify: true:

apiVersion: v1
kind: Config
clusters:
  - name: wazuh-webhook
    cluster:
      insecure-skip-tls-verify: true
      server: https://<IP_SERVIDOR_WAZUH>:8080
current-context: webhook
contexts:
  - context:
      cluster: wazuh-webhook
      user: kube-apiserver
    name: webhook
users: []

Validar TLS con la CA en producción

En producción es recomendable validar el certificado del webhook en lugar de saltarse la verificación. Usa la CA que generaste antes (rootCA.pem). Copia rootCA.pem a un path accesible por el API server (por ejemplo /etc/kubernetes/wazuh-webhook-ca.pem) y en el kubeconfig del webhook sustituye el bloque del cluster por:

  - name: wazuh-webhook
    cluster:
      server: https://<IP_SERVIDOR_WAZUH>:8080
      certificate-authority: /etc/kubernetes/wazuh-webhook-ca.pem

Añade el volumen y volumeMount correspondientes en el manifest del API server para que monte ese CA en la ruta indicada. Ojo: ese path es el del contenedor del apiserver (cuando corre como static pod en kubeadm); el archivo tiene que existir dentro del contenedor, no solo en el host. Así el API server solo acepta el certificado firmado por tu CA. Persistencia: El rootCA.pem debe conservarse; si lo regeneras, el clúster deja de confiar en el webhook hasta que actualices el CA en todos los nodos. Guarda rootCA.pem en backup como parte del backup de Wazuh (config, reglas, integraciones) para no regenerarlo por error. Si tienes CA corporativa, úsala.

Aplicar auditoría en el API server

Edita el manifiesto del API server (en entornos kubeadm suele ser /etc/kubernetes/manifests/kube-apiserver.yaml) y añade los argumentos y volúmenes necesarios:

  • Argumentos:

    • --audit-policy-file=/etc/kubernetes/audit-policy.yaml
    • --audit-webhook-config-file=/etc/kubernetes/audit-webhook.yaml
    • --audit-webhook-batch-max-size=1
  • VolumeMounts para audit-policy.yaml y audit-webhook.yaml (readOnly: true).

  • Volumes con hostPath apuntando a esos dos ficheros en el host.

Luego reinicia kubelet para que recargue el API server:

systemctl restart kubelet

Si usas K3s

En K3s el API server no corre como Pod estático: va dentro del proceso k3s. No existe /etc/kubernetes/manifests/kube-apiserver.yaml. La auditoría se configura con los argumentos del API server vía kube-apiserver-arg. Según cómo tengas instalado K3s, esos args pueden ir en el archivo de configuración (típicamente /etc/rancher/k3s/config.yaml) o directamente en el unit k3s.service (por ejemplo --kube-apiserver-arg=audit-policy-file=...). Si usas config.yaml y ya tienes otros argumentos, añade las tres líneas de auditoría a la lista existente, no reemplaces ni dupliques la clave:

# Ejemplo de config.yaml cuando ya tienes otros argumentos; añade las líneas de audit a la lista
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

Los ficheros audit-policy.yaml y audit-webhook.yaml tienen que estar en el host en esas rutas (por ejemplo /var/lib/rancher/k3s/server/); k3s los lee directamente. Reinicio: systemctl restart k3s (o k3s-server según instalación; como root si no tienes sesión root), no kubelet. Para comprobar: k create deployment test --image=nginx y k delete deployment test; revisar alertas en Wazuh.


Reglas en Wazuh para eventos de Kubernetes

El API server envía los audit logs en formato EventList de audit.k8s.io: un JSON con items[] donde cada elemento tiene verb, requestURI, user, objectRef, etc. Las reglas que siguen están pensadas para ese formato.

En el servidor Wazuh añadimos reglas que reconocen los eventos que llegan con ubicación k8s y apiVersion de auditoría, y elevamos nivel para crear/borrar recursos.

Añade en /var/ossec/etc/rules/local_rules.xml:

<group name="k8s_audit,">
  <rule id="110002" level="0">
    <location>k8s</location>
    <regex type="pcre2">"kind":\s*"EventList"</regex>
    <regex type="pcre2">"apiVersion":\s*"audit\.k8s\.io/</regex>
    <description>Kubernetes audit log.</description>
  </rule>

  <rule id="110003" level="5">
    <if_sid>110002</if_sid>
    <regex type="pcre2">"verb":\s*"create"</regex>
    <description>Kubernetes request to create resource</description>
  </rule>

  <rule id="110004" level="5">
    <if_sid>110002</if_sid>
    <regex type="pcre2">"verb":\s*"delete"</regex>
    <description>Kubernetes request to delete resource</description>
  </rule>
</group>

La regla base usa regex sobre el JSON para no depender de que Wazuh parsee y exponga el campo apiVersion. Las reglas 110003/110004 buscan solo "verb": "create" y "verb": "delete", así no dependen del orden de los campos en el JSON (si el API server cambiara el orden de requestURI y verb, seguirían haciendo match).

Reglas adicionales (opcionales): Para eventos más sensibles puedes añadir reglas hijas de 110002, por ejemplo: patch y update (110005, 110006 con regex "verb":\s*"patch" y "verb":\s*"update"), create en Secrets, o delete en Namespaces (impacto crítico). Con regex sobre el JSON podrías hacer match en objectRef.resource y objectRef.name; si usas campos, <field name="objectRef.resource">secrets</field> para create de secrets, o similar según lo que exponga analysisd. Si añades 110005/110006 y usas Telegram, inclúyelas en el <rule_id> de la integración para recibir también patch/update por Telegram.

Las reglas con regex sobre el JSON funcionan, pero son frágiles si el formato del payload cambia (espacios, orden de campos). En versiones de Wazuh que parsean JSON y exponen campos del evento, puedes usar una alternativa más robusta por campo en lugar de regex. Ejemplo equivalente para create/delete usando campos (si tu Wazuh ya rellena verb desde el JSON):

  <rule id="110003" level="5">
    <if_sid>110002</if_sid>
    <field name="verb">create</field>
    <description>Kubernetes request to create resource</description>
  </rule>
  <rule id="110004" level="5">
    <if_sid>110002</if_sid>
    <field name="verb">delete</field>
    <description>Kubernetes request to delete resource</description>
  </rule>

Si verb no está disponible como campo, quédate con el regex; el formato estándar del EventList suele mantener "verb": "create" en el texto.

Reinicia el manager para cargar las reglas:

systemctl restart wazuh-manager

A partir de ahí, al crear o borrar recursos en el clúster (por ejemplo con k create deployment o k delete deployment) deberías ver las alertas en el dashboard de Wazuh. Cada evento que llega es un EventList; el regex hace match sobre el JSON completo (incluidos los items), donde vienen requestURI, verb, user, objectRef, responseStatus, etc., útil para investigar quién hizo qué.

Si quieres guardar todos los logs de Kubernetes en el archivo de Wazuh (no solo alertas), en ossec.conf puedes poner logall_json en yes dentro de <global>. El volumen depende de la actividad: un clúster con tráfico moderado puede generar del orden de decenas a cientos de MB/día; uno muy activo o con muchas políticas auditadas, bastante más. Para reducir volumen puedes afinar la política de auditoría (p. ej. solo RequestResponse en recursos concretos como secrets o namespaces, y Metadata en el resto) en lugar de loguear todo a nivel RequestResponse.


Prueba real: errores que salieron y cómo se solucionaron

En los ejemplos uso k (alias de kubectl). Lo monté en mi máquina con un clúster local y un servidor Wazuh en contenedor para probar el flujo de punta a punta. Estos son los fallos que aparecieron hasta dejarlo funcionando.

1. El webhook no arrancaba: permiso denegado en el socket de Wazuh.

El servicio wazuh-webhook hacía systemctl start pero al instante fallaba. En los logs del servicio:

OSError: [Errno 13] Permission denied: '/var/ossec/queue/sockets/queue'

El proceso del webhook no corría con el mismo usuario que el manager de Wazuh, así que no tenía permiso para escribir en el socket. Solución: ejecutar el servicio con el usuario de Wazuh (por ejemplo User=wazuh en el unit de systemd, o el usuario que use tu instalación) o dar al grupo de ese usuario permiso sobre el directorio de sockets. Ajusté el wazuh-webhook.service con User=wazuh y el servicio arrancó.

2. Kubernetes no enviaba nada al webhook: API server no encontraba la política de auditoría.

Tras reiniciar kubelet con los nuevos argumentos de auditoría, el API server entraba en crashloop. En los logs del contenedor del API server (o de kubelet):

error: failed to load audit policy file: open /etc/kubernetes/audit-policy.yaml: no such file or directory

En kubeadm el manifest montaba el archivo desde el host; la ruta no coincidía con donde había creado el audit-policy.yaml. Solución: que audit-policy.yaml y audit-webhook.yaml existan en el host exactamente en las rutas que declara el manifest (p. ej. /etc/kubernetes/). En K3s las rutas típicas son /var/lib/rancher/k3s/server/ y se reinicia con systemctl restart k3s, no kubelet. Creé los ficheros en la ruta correcta y reinicié; el API server arrancó bien.

3. Firewall bloqueaba el puerto 8080.

Con el webhook y la auditoría ya configurados, al hacer k create deployment no llegaba ninguna alerta a Wazuh. El API server intentaba enviar al webhook por HTTPS y, al no poder conectar, los audit logs no se reenvían. Solución: abrir el puerto 8080 en el servidor donde corre Wazuh (o el listener) para que el nodo master del clúster pueda hacer POST al webhook. Con firewalld: firewall-cmd --permanent --add-port=8080/tcp y firewall-cmd --reload.

4. Las reglas no hacían match: el JSON que envía Kubernetes es un EventList.

Al principio las alertas de “create”/“delete” no salían. El API server envía un único JSON que es un objeto con items[]; cada elemento del array es un evento de auditoría. Las reglas hacen match sobre el contenido del JSON (el string completo incluye los items). Usar un regex que busque solo "verb": "create" y "verb": "delete" evita depender del orden de los campos; si tu versión de Kubernetes envía el payload con pequeñas diferencias (espacios, orden), ese patrón suele seguir funcionando.


Tras corregir lo anterior, al crear y borrar un deployment de prueba:

k create deployment hello-audit-test --image=registry.k8s.io/e2e-test-images/agnhost:2.45 -- /bin/sleep 3600
# ... esperar a que el pod esté Running ...
k delete deployment hello-audit-test

las alertas aparecieron en el dashboard de Wazuh (reglas 110003 y 110004). Los eventos que llegan tienen la estructura audit.k8s.io; un ejemplo de lo que dispara la regla de create (110003):

{
  "kind": "EventList",
  "apiVersion": "audit.k8s.io/v1",
  "metadata": {},
  "items": [
    {
      "level": "RequestResponse",
      "auditID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "stage": "ResponseComplete",
      "requestURI": "/apis/apps/v1/namespaces/default/deployments?fieldManager=kubectl-create&fieldValidation=Strict",
      "verb": "create",
      "user": {
        "username": "system:serviceaccount:default:default",
        "groups": ["system:serviceaccounts", "system:serviceaccounts:default", "system:authenticated"]
      },
      "sourceIPs": ["127.0.0.1"],
      "userAgent": "kubectl/1.28 (linux/amd64) kubernetes-...",
      "objectRef": {
        "resource": "deployments",
        "namespace": "default",
        "name": "hello-audit-test",
        "apiGroup": "apps",
        "apiVersion": "v1"
      },
      "responseStatus": { "metadata": {}, "code": 201 },
      "requestReceivedTimestamp": "2026-02-26T12:00:00.000000Z",
      "stageTimestamp": "2026-02-26T12:00:00.020000Z",
      "annotations": {
        "authorization.k8s.io/decision": "allow",
        "authorization.k8s.io/reason": ""
      }
    }
  ]
}

Ese JSON es el que el API server envía al webhook en cada petición auditada; el listener lo reenvía a Wazuh con prefijo k8s y las reglas 110003/110004 hacen match en verb create/delete. Con Minikube, K3s o kubeadm la forma del evento es la misma.

Notificaciones por Telegram (opcional)

Para recibir en Telegram las alertas de auditoría (create/delete, etc.) añade en ossec.conf un bloque de integración que se dispare solo para las reglas de k8s (110003, 110004; si añadiste 110005/110006 para patch/update, inclúyelas también en rule_id). Ejemplo de dónde enganchar y qué filtra:

<integration>
  <name>custom</name>
  <command>/var/ossec/integrations/k8s-audit-telegram.py</command>
  <rule_id>110003,110004</rule_id>
  <alert_format>json</alert_format>
</integration>

En algunas instalaciones de Wazuh 4.x el manager invoca la integración por nombre de script y no usa <command>. En ese caso usa <name>k8s-audit-telegram.py</name> (sin <command>); el script ha de estar en /var/ossec/integrations/k8s-audit-telegram.py, ser ejecutable y con permisos correctos (evita el “no envía nada y no hay logs”):

chmod 750 /var/ossec/integrations/k8s-audit-telegram.py
chown root:wazuh /var/ossec/integrations/k8s-audit-telegram.py

Si el grupo real de tu instalación es ossec, ajusta en el chown. El script recibe el archivo de alerta en JSON, extrae la información relevante (verb, resource, namespace, name, user, sourceIPs) y envía el mensaje al bot de Telegram. Debe leer el JSON del evento (p. ej. desde data.full_log u otro campo que contenga el EventList según cómo Wazuh decodifique el evento) y parsear items[] para formatear create/delete/patch/update. Mismo token y chat que otras integraciones o uno dedicado.


Ventajas de auditar Kubernetes con Wazuh

  • Quién borró el deployment y desde qué IP: El evento trae usuario, sourceIPs y objectRef; no hace falta entrar en los nodos para investigar.
  • Quién creó un secret en producción: Con una regla sobre el recurso secrets y el verb create ves quién y cuándo.
  • Quién hizo patch a un Role o RoleBinding: Cambios en permisos quedan registrados; puedes alertar sobre modificaciones en RBAC.
  • Mismo SIEM que el resto: Los audit logs van al mismo Wazuh que AD, Office365 o parches; correlacionas eventos del clúster con el resto de la red.
  • El API server envía en asíncrono: Si Wazuh cae, el clúster sigue; solo se pierden esos audit logs mientras tanto.

Resumen

  • Servidor Wazuh: webhook HTTPS (Flask) en el puerto 8080 que recibe el JSON de audit y lo envía al socket de analysisd con ubicación k8s.
  • Kubernetes: política de auditoría y configuración de audit webhook apuntando al servidor Wazuh; el API server envía los eventos al webhook.
  • Reglas: una regla base para todos los audit logs de Kubernetes y reglas hijas para crear/borrar recursos (y opcionalmente update/patch si lo necesitas).

Fuente: Auditing Kubernetes with Wazuh (Wazuh blog, diciembre 2022).