Módulo 6 de 16

Módulo 6: Normalización: del log crudo al campo consultable

Módulo 6: Normalización: del log crudo al campo consultable

Un EDR llama «Image» al ejecutable que arrancó un proceso. El registro de seguridad de Windows llama a ese mismo dato «NewProcessName». Sysmon repite el nombre «Image», pero añade un identificador que el registro de seguridad no tiene. Auditd, en Linux, lo llama «exe» y guarda el nombre corto del binario en otro campo aparte, «comm». Los cuatro describen el mismo hecho (alguien ejecutó un binario) y ninguno se pone de acuerdo en cómo llamarlo.

Ese desacuerdo no es un detalle estético. Una regla que busca «el proceso X se ejecutó con el argumento Y» solo dispara si el motor de detección sabe qué campo, de qué origen, equivale al campo que la regla nombra. Si alguien escribe la regla pensando en Sysmon y el dato real llega por el registro de seguridad de un equipo sin Sysmon instalado, la regla no falla con un error visible: simplemente no dispara nunca, y nadie lo nota hasta que revisa el incidente a mano con el log crudo delante. La normalización es el trabajo, casi siempre invisible, que hace que una detección escrita una vez sirva contra cualquier origen que produzca ese mismo hecho.

Qué aprenderás

  • Por qué una regla de detección solo es portable si apunta a un campo normalizado y no al nombre de campo de un producto concreto.
  • Cómo procesa un log Wazuh: la diferencia entre un decoder y una regla, y qué hace cada fase.
  • A escribir un decoder propio para un log que Wazuh no reconoce de fábrica, y a comprobar el resultado con wazuh-logtest antes de tocar producción.
  • Qué problema resuelve un esquema de campos común como Elastic Common Schema, y qué papel juega un esfuerzo paralelo como OCSF.
  • A leer la taxonomía logsource de Sigma (category, product, service) como el contrato entre quien escribe una regla y quien la traduce a un motor concreto.
  • A decidir si un parseo se hace en el origen, en la tubería de ingesta o en el momento de la consulta, y qué cuesta cada opción.
  • A enriquecer un evento con geolocalización, resolución de nombres e inventario de activos sin convertir la ingesta en un cuello de botella.
  • A reconocer los errores de normalización que más falsos negativos producen: tipos inconsistentes, truncado, doble escapado y pérdida de la marca de tiempo original.
  • Por qué un SOC necesita documentar su propio diccionario de campos en vez de confiar en la memoria de quien escribió la última regla.

El mismo hecho, dos nombres de campo

La tabla siguiente compara cómo describen la creación de un proceso cuatro fuentes distintas. Los nombres de campo de Windows salen del evento de seguridad 4688, «A new process has been created» (documentación archivada por Microsoft, con fecha de revisión de enero de 2022, pero el formato del evento no ha cambiado desde entonces). Los de Sysmon salen de la página oficial de Sysmon y de su tabla de esquema publicada por EventSentry. Los de auditd salen de un registro real capturado y publicado por SecOps Monkey. Los de la última columna son los que define Elastic Common Schema.

Origen Ejecutable Línea de comandos Proceso padre Usuario
Windows, evento 4688 NewProcessName CommandLine ParentProcessName SubjectUserName
Sysmon, evento 1 Image CommandLine ParentImage User
Linux auditd (SYSCALL + EXECVE) exe a0, a1… bajo argc ppid (solo un PID numérico) uid (numérico, sin resolver)
Elastic Common Schema process.executable process.command_line process.parent.executable user.name

Fíjate en dos cosas que la tabla deja ver. Primera: hasta el nombre coincidente es una trampa, «CommandLine» existe en Windows y en Sysmon, pero con orígenes de datos distintos (uno lo genera la auditoría del kernel, otro un driver de terceros), así que un mismo nombre de campo no garantiza el mismo pipeline de captura ni la misma fiabilidad. Segunda: auditd no da un nombre de proceso padre, solo un PID; para tener «quién es el padre» hay que correlar ese ppid con otro evento SYSCALL, un paso extra que Windows y Sysmon no exigen. Este tipo de matiz es el que un analista de SOC que empieza a escribir reglas descubre a base de reglas que no disparan.

Decoders y reglas en Wazuh: anatomía del procesado

Wazuh procesa cada log en dos fases que conviene distinguir bien porque cada una vive en un fichero distinto y se prueba de forma distinta. La predecodificación extrae lo genérico (marca de tiempo, host, nombre del programa si el log trae cabecera de tipo syslog) antes de que intervenga ningún decoder personalizado. La decodificación es donde entran los decoders, que reconocen el formato concreto del log y extraen los campos que interesan; después, las reglas deciden si ese evento decodificado merece una alerta y con qué nivel. Lo documenta así la propia guía de pruebas de decoders y reglas de Wazuh.

Escribir un decoder para un log propietario

Los decoders personalizados van en /var/ossec/etc/decoders/local_decoder.xml, según indica la guía de decoders personalizados. Un decoder puede encadenarse a otro con parent, condicionarse a que el log contenga cierto texto con prematch o a que el programa que lo generó coincida con program_name, y extraer campos con una expresión regular en regex cuyos grupos capturados se nombran, en orden, dentro de order. Este es el ejemplo que trae la propia documentación:

<decoder name="example">
  <program_name>^example</program_name>
</decoder>

<decoder name="example">
  <parent>example</parent>
  <regex>User '(w+)' logged from '(d+.d+.d+.d+)'</regex>
  <order>user, srcip</order>
</decoder>

order admite tanto los nombres de campo predefinidos de Wazuh (srcip, dstuser, url…) como nombres inventados por ti, que Wazuh trata como campos dinámicos. Un detalle que facilita mucho la vida al escribir reglas después: un nombre de campo dinámico con un punto, como audit.auid, se convierte en un objeto anidado en el evento decodificado. Lo describe así la guía de campos dinámicos, con este ejemplo:

"audit": {
  "type": "CONFIG_CHANGE",
  "id": "20",
  "auid": "0",
  "session": "6",
  "op": "add rule"
}

<field name="audit.auid">0</field>

Cuando el log de origen ya viene en JSON (cada vez más habitual: proxies, balanceadores, servidores web modernos), escribir una expresión regular campo a campo es perder el tiempo. Wazuh trae un decoder de tipo plugin_decoder pensado justo para esto, JSON_Decoder, que aplana el objeto JSON entero en campos dinámicos automáticamente, incluidos objetos anidados y arrays. Así lo ejemplifica la guía del decoder JSON:

<decoder name="raw_json">
    <program_name>nba_program</program_name>
    <prematch>player_information: "</prematch>
    <plugin_decoder offset="after_prematch">JSON_Decoder</plugin_decoder>
</decoder>

El atributo offset="after_prematch" le dice al decoder que empiece a interpretar JSON justo después de donde matcheó prematch, útil cuando el JSON viene precedido de una cabecera de texto (como en este ejemplo, donde antes del JSON hay literalmente el texto player_information: "). Si tu log es JSON puro desde el primer carácter, ese atributo sobra. Para los arrays existe, por otro lado, la etiqueta json_array_structure, que decide si un array se representa como array real o como una cadena separada por comas, algo que conviene fijar a propósito y no dejar al azar (vuelvo sobre esto en la sección de errores).

Comprobar el resultado con wazuh-logtest

Nunca subas un decoder a producción sin haberlo probado antes contra un log real. Para eso existe /var/ossec/bin/wazuh-logtest, una herramienta que ejecuta el mismo motor de análisis que usa el manager en un entorno aislado y te enseña, log a log, qué decoder ha matcheado, qué campos ha extraído y qué regla ha disparado (si ha disparado alguna). Lo explica la referencia de wazuh-logtest; la opción -v saca el resultado más detallado y -U rule-id:nivel:decoder permite comprobar en un script que el resultado es el esperado, sin mirarlo a ojo.

Este es un ejemplo real, tomado de la guía de pruebas de decoders y reglas, con un intento de login SSH con un usuario que no existe:

Phase 1: Completed pre-decoding.
    full event: 'Oct 15 21:07:00 linux-agent sshd[29205]: Invalid user blimey from 18.18.18.18 port 48928'
    timestamp: 'Oct 15 21:07:00'
    hostname: 'linux-agent'
    program_name: 'sshd'

Phase 2: Completed decoding.
    name: 'sshd'
    parent: 'sshd'
    srcip: '18.18.18.18'
    srcport: '48928'
    srcuser: 'blimey'

Phase 3: Completed filtering (rules).
    id: '5710'
    level: '5'
    description: 'sshd: Attempt to login using a non-existent user'
    firedtimes: '1'

Repetir el mismo log ocho veces en menos de dos minutos hace que, en vez de la regla 5710, dispare la 5712, la que detecta fuerza bruta por SSH desde el mismo origen. Es el mismo ejemplo de la documentación: te sirve para comprobar, con tus propios ojos y sin fabricar nada, que la ventana de frecuencia funciona antes de fiarte de ella en un caso real.

La regla que consume el campo normalizado

Las reglas personalizadas van en /var/ossec/etc/rules/local_rules.xml. La propia guía de reglas personalizadas recomienda reservar los identificadores entre 100000 y 120000 para no chocar con las reglas de fábrica, y trae este esqueleto mínimo:

<group name="custom_rules_example,">
  <rule id="100010" level="0">
    <program_name>example</program_name>
    <description>User logged</description>
  </rule>
</group>

Para que una regla apunte al campo que extrajo tu decoder (no a una subcadena del log crudo, sino al campo ya normalizado) se usa <field name="...">, según la sintaxis de reglas. También existe decoded_as, que dispara la regla solo si el evento fue decodificado por un decoder concreto, útil como primer filtro antes de mirar campos específicos. El nivel de alerta va de 0 a 16, y if_sid permite encadenar una regla más específica sobre otra más genérica que ya haya matcheado (así se construye, por ejemplo, la escalada de «intento fallido» a «fuerza bruta» del ejemplo anterior).

Esquemas de campos comunes: por qué hace falta uno

Wazuh, Elastic, Splunk y cualquier otro motor resuelven el mismo problema de normalización cada uno a su manera y con su propio vocabulario de campos. Si cada integración inventa sus propios nombres, correlar dentro del mismo SIEM un login sospechoso (que llega por un origen) con una conexión de red saliente (que llega por otro) exige que alguien memorice, para cada par de fuentes, qué campo equivale a qué campo. Un esquema común evita justamente eso. Lo resume así el repositorio de Elastic Common Schema: «ECS defines a common set of fields for ingesting data into Elasticsearch. A common schema helps you correlate data from sources like logs and metrics or IT operations analytics and security analytics».

ECS se publica con licencia Apache-2.0 y va, a fecha de julio de 2026, por la versión 9.4.0, que las publicaciones del repositorio fechan el 19 de mayo de 2025 (las notas de versión confirman que sigue siendo la última, pero no llevan fecha). Que el esquema lleve más de un año sin versión nueva dice algo bueno de él: ya no se mueve bajo tus reglas. Organiza los campos en «field sets»: conjuntos con nombre propio, cada uno cubriendo un aspecto del evento. La versión actual define 57 conjuntos según su referencia de campos; estos son los que más se usan al escribir detecciones:

Conjunto Qué agrupa
Base @timestamp, message y los campos que no encajan en ningún otro grupo
Event metadatos del propio evento: category, action, outcome, created, ingested
Host el equipo donde ocurre u observa el evento
Process el proceso implicado: name, executable, command_line, parent.*
User la identidad implicada: name, id, domain
File el fichero implicado: path, name, hash.*
Source / Destination / Network IP, puerto, protocolo y dirección del tráfico
DNS consulta y respuesta de una resolución de nombres
Rule la regla o firma que generó la detección
Related listas planas de IPs, hashes o usuarios, pensadas para búsquedas rápidas

Los campos event.created, event.ingested y @timestamp merecen mención aparte porque son la solución de ECS a un error muy concreto que trato más abajo. Vuelvo sobre ellos en la sección de errores de normalización.

OCSF, de pasada

Existe un segundo esfuerzo de estandarización, con gobierno independiente de Elastic: el Open Cybersecurity Schema Framework, que pasó a formar parte de la Linux Foundation el 19 de noviembre de 2024 según su nota de prensa. Va por la versión 1.8.0, publicada en marzo de 2026 según sus propias notas de versión. No lo vas a usar en Wazuh, pero conviene saber que existe porque aparece cada vez más en el lado de los proveedores de nube: Amazon Security Lake, por ejemplo, convierte automáticamente los logs de servicios de AWS a OCSF antes de almacenarlos, según su documentación. Si en el futuro trabajas con datos que ya llegan en OCSF, el reto es exactamente el mismo que resuelve este módulo: mapear esos campos a los que usa tu SIEM.

La taxonomía logsource de Sigma: el contrato entre quien escribe y quien traduce

Una regla Sigma no se ejecuta directamente contra ningún motor: se traduce. Alguien la escribe una vez, en un formato neutral, y un «backend» (el conversor específico de cada motor de destino) la convierte a la consulta real de Elastic, Splunk, Wazuh o lo que corresponda. Para que esa traducción sea posible sin ambigüedad, cada regla declara su logsource, formado por hasta tres campos: category, product y service. Lo define la especificación de Sigma, actualmente en la versión v2.1.0, publicada el 12 de septiembre de 2025 según el repositorio de SigmaHQ/sigma-specification.

category, product y service

category selecciona todos los logs escritos por un grupo de productos que cumplen una función parecida, como cortafuegos o servidores web. product selecciona todos los logs de un producto concreto, por ejemplo todos los tipos de canal de eventos de Windows (Security, System, Application, y los más recientes como AppLocker o Windows Defender). service restringe aún más, a un subconjunto de los logs de ese producto, como el servicio sshd en Linux o el canal Security en Windows. Los tres campos pueden combinarse o usarse solos, y se escriben en minúscula con guiones bajos en vez de espacios.

Así se ve en la práctica. Esta es la sección logsource (y parte de la detección) de una regla real del repositorio público de SigmaHQ, escrita por Florian Roth y Nasreddine Bencherchali, de Nextron Systems, publicada bajo la Detection Rule License 1.1 del propio proyecto:

title: Suspicious Process Created Via Wmic.EXE
id: 3c89a1e8-0fba-449e-8f1b-8409d6267ec8
author: Florian Roth (Nextron Systems), Nasreddine Bencherchali (Nextron Systems)
tags:
    - attack.execution
    - attack.t1047
logsource:
    category: process_creation
    product: windows
detection:
    selection:
        CommandLine|contains|all:
            - 'process '
            - 'call '
            - 'create '
    condition: selection

(Regla completa, con la lista entera de condiciones, en el fichero original.) Nota que el logsource no dice «Sysmon» en ningún sitio: dice category: process_creation, product: windows. Eso significa que esta regla debería disparar tanto si la telemetría de creación de procesos llega por Sysmon como si llega por el evento 4688 con auditoría avanzada activada, siempre que el backend que la traduzca sepa mapear CommandLine al campo real de cada origen. Fíjate también en las etiquetas tags: casi toda regla del repositorio de SigmaHQ apunta a una técnica de la matriz MITRE ATT&CK, aquí attack.t1047, lo que permite medir después qué parte de la matriz cubre tu conjunto de reglas y cuál queda sin tocar. La DRL 1.1 permite el uso comercial de reglas como esta, incluida su reproducción, siempre que se mantenga la atribución al autor, también en las vistas donde se muestran las coincidencias.

Qué pasa cuando el backend no tiene mapeo

Un backend de Sigma (pySigma es el más usado) no traduce a ciegas: se apoya en «pipelines» que definen, entre otras cosas, el mapeo de campos y el mapeo de logsources hacia el motor de destino concreto. Lo describe así la documentación de backends de Sigma: «pipelines control things like field-mapping (mapping Sigma fields, to fields within your SIEM), logsource mapping (mapping Sigma logsources to logsources in your SIEM) and other operations». Si el logsource de la regla no tiene pipeline para tu motor, el resultado depende de la implementación concreta del backend: algunos rechazan la conversión con un error explícito de campo o logsource no soportado, otros generan una consulta sintácticamente válida pero contra un nombre de campo que en tu índice no existe, y esa consulta no falla, solo no encuentra nunca nada. La diferencia importa: un error se ve en el pipeline de despliegue, una consulta silenciosamente vacía se descubre (si se descubre) meses después, en una auditoría de cobertura.

Parseo en la práctica: origen, tubería o consulta

Hay tres momentos posibles para convertir un texto plano en un campo consultable, y elegir mal sale caro de una forma distinta en cada caso.

Dónde se parsea Cuándo conviene Qué cuesta
En el origen (el propio agente o servicio ya emite JSON o campos estructurados) Cuando controlas la configuración de esa fuente y puedes activarla Hay que tocar la configuración de cada fuente y coordinarlo con quien la administra; no sirve para logs heredados que nadie va a tocar
En la tubería de ingesta (decoder de Wazuh, procesador de un pipeline de ingest, Logstash) Cuando no puedes tocar la fuente pero sí controlas la entrada al SIEM Consume tiempo de proceso en cada evento; una expresión mal escrita puede ralentizar toda la tubería, no solo el log que falla
En el momento de la consulta («search-time extraction») Para exploración puntual o campos que casi nunca se consultan y no compensa normalizar siempre Repite el coste del parseo en cada búsqueda; no sirve para una regla de detección que necesita el campo ya calculado para correlar cerca de tiempo real

Cuando el parseo se hace en la tubería con expresiones tipo grok, el riesgo de la fila del medio es real y está documentado: el procesador grok de Elasticsearch interrumpe y falla la expresión si tarda demasiado en evaluarse, con un límite por defecto de un segundo, según su documentación. Una expresión regular mal escrita, con backtracking descontrolado, puede tardar eso de sobra con una sola línea de log larga.

Un pipeline de ingest de Elasticsearch con un procesador grok se ve así (ejemplo de la propia documentación):

{
  "pipeline": {
    "description": "...",
    "processors": [
      {
        "grok": {
          "field": "message",
          "patterns": ["%{IP:client} %{WORD:method} %{URIPATHPARAM:request} %{NUMBER:bytes:int} %{NUMBER:duration:double}"]
        }
      }
    ]
  }
}

Fíjate en %{NUMBER:bytes:int} y %{NUMBER:duration:double}: el tercer valor, tras los dos puntos, fuerza el tipo del campo resultante. Sin ese casting explícito, grok deja el valor como texto aunque parezca un número, algo que retomo en la sección de errores.

Enriquecimiento con cabeza

Enriquecer es añadir al evento datos que no venían en el log original pero que hacen falta para decidir si importa. Los cuatro más habituales en un SOC son geolocalización, resolución de nombres, inventario de activos con su criticidad, e inteligencia de amenazas, y cada uno tiene su propio momento correcto para aplicarse.

La geolocalización por IP se resuelve normalmente con un procesador de enriquecimiento como el geoip de Elasticsearch, que por defecto usa las bases de datos GeoLite2 de MaxMind (City, Country y ASN), publicadas con licencia CC BY-SA 4.0 y actualizadas automáticamente cada tres días. Un pipeline mínimo se ve así:

{
  "description": "Add ip geolocation info",
  "processors": [
    {
      "geoip": {
        "field": "ip"
      }
    }
  ]
}

Ten en cuenta una limitación que sorprende a quien lo prueba por primera vez: una IP privada (10.0.0.0/8, 192.168.0.0/16…) nunca va a resolver a un país, porque esos rangos no están en ninguna base de datos de geolocalización pública. Si tu regla espera un país en source.geo.country_name para todo el tráfico interno, va a fallar siempre para ese tráfico, no de forma intermitente.

La resolución de nombres puede hacerse en el origen o en la tubería. Un ejemplo de hacerlo en origen: Sysmon trae una opción de configuración, DnsLookup, activada por defecto, que resuelve el nombre de host de las conexiones de red que registra, según su propia documentación. La ventaja de resolver ahí es que el nombre queda fijado en el momento del evento; la desventaja es que añade una consulta DNS por cada conexión que Sysmon observa, en el propio endpoint.

El inventario de activos y su criticidad casi nunca vive dentro del SIEM: suele estar en una CMDB, en el propio directorio, o en la configuración de agrupación de tu herramienta de gestión de endpoints. El enriquecimiento aquí consiste en unir ese dato externo al evento en el momento de correlar, no en copiarlo dentro de cada log (eso quedaría desactualizado en cuanto un activo cambiara de dueño o de criticidad). Un mismo hallazgo técnico, «conexión saliente sospechosa», pesa completamente distinto si el origen es un puesto de un becario o el controlador de dominio.

La inteligencia de amenazas es el caso donde el momento del enriquecimiento cambia el resultado, no solo el coste. Si comparas cada IP contra tu feed de indicadores en el instante de la ingesta y nunca vuelves a mirar ese evento, un indicador que se publica mañana no va a encontrar la conexión que ya pasó por tu tubería hoy: quedó comparada contra un feed que todavía no lo sabía. Por eso conviene separar «comparar contra lo que sé ahora mismo» (útil para detección casi en tiempo real) de «volver a comparar el histórico contra lo que sé hoy» (una tarea de caza retrospectiva, con su propio ciclo). Ninguna de las dos sustituye a la otra.

Errores que se pagan caros

Cuatro fallos de normalización explican, en mi experiencia revisando reglas ajenas, una parte desproporcionada de las detecciones que no funcionan y de las que nadie sabe explicar por qué.

El primero son los campos con tipos inconsistentes. El JSON_Decoder de Wazuh extrae números, cadenas, booleanos y arrays, según su documentación, pero el propio ejemplo de esa documentación muestra los valores numéricos entrecomillados en la salida de wazuh-logtest, y varios reportes de usuarios en el foro de Wazuh confirman que, sin una plantilla de mapeo explícita en el indexador, esos campos llegan como texto y no admiten comparaciones numéricas de rango. La solución no es «esperar que funcione»: hay que fijar el tipo a propósito, con el casting de grok (:int, :double) si parseas en una tubería de Elasticsearch, o con una plantilla de índice explícita si dejas que Wazuh indexe el campo tal cual. El mismo problema aparece con arrays: en el JSON real de un log de acceso de Caddy, una cabecera como User-Agent llega como ["curl/7.82.0"], un array de un elemento, no como una cadena suelta; si tu decoder no decide con json_array_structure cómo tratar eso, un día tendrás ese campo como array y otro día, si cambia el log de origen, como cadena, y una regla que espera una cosa fallará silenciosamente con la otra.

El segundo es el truncado de líneas de comandos largas. El registro de eventos de Windows tiene un límite documentado de 32.766 caracteres por cadena, según confirma Microsoft Q&A. Un comando de PowerShell fuertemente ofuscado o con una carga en base64 puede acercarse a ese límite; si tu regla busca un patrón cerca del final de la línea de comandos y el pipeline de por medio corta el campo antes de llegar ahí, la regla nunca va a ver ese fragmento y nunca va a disparar, sin que nada en el log te avise de que ha pasado.

El tercero es el doble escapado. Un valor de línea de comandos de Windows ya trae comillas y barras invertidas propias de la sintaxis de la shell. Si un decoder captura ese valor tal cual y una etapa posterior de la tubería lo serializa de nuevo en JSON o en XML sin saber que ya venía «medio escapado», el resultado son barras dobles o entidades duplicadas, y una regla escrita contra el valor «limpio» original no matchea nunca contra el valor real, doblemente codificado, que llega al motor de reglas.

El cuarto es la pérdida de la marca de tiempo original. Reescribir el momento en que ocurrió el evento con el momento en que se ingirió es un error tan común que ECS le dedica dos campos aparte de @timestamp: event.created, definido como «timestamp when an event arrived in the central data store. This is different from @timestamp, which is when the event originally occurred», y event.ingested, «the date/time when the event was first read by an agent, or by your pipeline», según la referencia de campos Event de ECS. Si tu pipeline sobrescribe el timestamp original con la hora de ingesta, pierdes la capacidad de ordenar correctamente eventos que llegaron con retraso (un agente offline que reenvía horas después, por ejemplo), y cualquier reconstrucción de la secuencia de un incidente queda torcida. Esa reconstrucción fina, con cadena de custodia incluida, es terreno del curso de DFIR; aquí basta con no destruir el dato de origen antes de que llegue a esa fase.

El diccionario de campos del SOC

Nada de lo anterior sirve de mucho si cada analista tiene que adivinar, cada vez, cómo se llama un campo en tu entorno concreto. Un diccionario de campos del SOC documenta, por cada campo normalizado que usas: el nombre exacto, el tipo, una descripción corta, un valor de ejemplo, y qué decoder o pipeline lo rellena. No hace falta una herramienta especial: un fichero versionado junto al repositorio de reglas, revisado en el mismo pull request que añade un decoder nuevo, es más que suficiente para empezar. En el fondo, ECS es exactamente eso a escala pública, un diccionario de campos publicado y versionado, y los pipelines de Sigma que vimos antes son diccionarios ejecutables (mapean un nombre genérico a tu nombre real). La diferencia es que el tuyo tiene que cubrir también tus decoders propios, los que ningún esquema público conoce porque los escribiste tú para un log que nadie más tiene.

Laboratorio: normaliza un log que Wazuh no trae soportado de fábrica

Vas a tomar un log real de un formato que Wazuh no decodifica de fábrica, escribir su decoder, comprobar con wazuh-logtest que los campos salen bien tipados, y escribir una regla contra el campo normalizado en vez de contra el texto crudo.

Necesitas un manager de Wazuh de pruebas. El propio proyecto publica un despliegue oficial en contenedores de un solo nodo en el repositorio wazuh-docker, con manager, indexador y dashboard, gratuito y reproducible en tu propio equipo.

  1. Parte de este ejemplo real de log de acceso en JSON de Caddy, un servidor web que registra así de fábrica:

    {
      "level": "info",
      "ts": 1646861401.5241024,
      "logger": "http.log.access",
      "msg": "handled request",
      "request": {
        "remote_ip": "127.0.0.1",
        "remote_port": "41342",
        "client_ip": "127.0.0.1",
        "proto": "HTTP/2.0",
        "method": "GET",
        "host": "localhost",
        "uri": "/",
        "headers": {
          "User-Agent": ["curl/7.82.0"],
          "Accept": ["*/*"],
          "Accept-Encoding": ["gzip, deflate, br"]
        },
        "tls": {
          "resumed": false,
          "version": 772,
          "cipher_suite": 4865,
          "proto": "h2",
          "server_name": "example.com"
        }
      },
      "bytes_read": 0,
      "user_id": "",
      "duration": 0.000929675,
      "size": 10900,
      "status": 200,
      "resp_headers": {
        "Server": ["Caddy"],
        "Content-Encoding": ["gzip"],
        "Content-Type": ["text/html; charset=utf-8"],
        "Vary": ["Accept-Encoding"]
      }
    }
  2. Escribe el decoder. Como es JSON puro desde el primer carácter, no hace falta offset="after_prematch". Basta con condicionar el decoder a que el log contenga el logger de acceso de Caddy y dejar que JSON_Decoder aplane el resto. Añádelo a /var/ossec/etc/decoders/local_decoder.xml:

    <decoder name="caddy-access-json">
      <prematch>"logger":"http.log.access"</prematch>
      <plugin_decoder>JSON_Decoder</plugin_decoder>
    </decoder>
  3. Compruébalo con wazuh-logtest. Entra en el contenedor del manager y pega el log completo del paso anterior (en una sola línea) en /var/ossec/bin/wazuh-logtest. Verifica en tu entorno (aquí no puedo darte una salida real que no haya generado yo mismo con software que no tengo instalado) que la fase 2 reconoce el decoder caddy-access-json y que aparecen como campos independientes, entre otros, status, request.method, request.uri, duration y size. Comprueba también, mirando las comillas de la salida, si status sale como texto (lo más probable, por lo visto en la sección de errores) y decide si te hace falta forzar el tipo numérico en la plantilla del indexador para poder hacer rangos.

  4. Escribe la regla. Añade a /var/ossec/etc/rules/local_rules.xml una regla base que confirme que el decoder funciona y una segunda que escale cuando el servidor responde con un error 5xx:

    <group name="caddy,">
      <rule id="100050" level="3">
        <decoded_as>caddy-access-json</decoded_as>
        <description>Caddy: peticion HTTP registrada</description>
      </rule>
    
      <rule id="100051" level="7">
        <if_sid>100050</if_sid>
        <field name="status">^5dd$</field>
        <description>Caddy: respuesta con error de servidor (5xx)</description>
      </rule>
    </group>
  5. Compara la misma búsqueda, antes y después. Antes de normalizar, la única forma de encontrar errores de servidor es un <match>"status":5</match> contra el log completo: frágil, porque se rompe si Caddy cambia el orden de las claves, si el valor de status aparece por coincidencia en otro sitio de la línea (por ejemplo dentro de una cabecera de respuesta), o si añades espacios distintos en la configuración de logging. Después de normalizar, <field name="status">^5dd$</field> apunta al campo ya extraído, sin depender de dónde cayó ese valor dentro del JSON original. Esa es, en una frase, toda la ganancia de este módulo.

Preguntas frecuentes

¿Tiene sentido escribir decoders de Wazuh si al final los datos también van a un SIEM tipo Elastic?

Sí, porque son dos capas distintas del mismo problema. El decoder de Wazuh decide qué campos existen y con qué nombre salen de tu manager; una vez indexados, el esquema de campos (ECS si usas Elastic) decide cómo se llaman esos campos a nivel de todo el SOC. Sin el primer paso, el segundo no tiene nada que mapear.

¿ECS y OCSF son lo mismo con otro nombre?

No. ECS lo gobierna Elastic, se publica con licencia Apache-2.0 y está pensado sobre todo para el ecosistema Elasticsearch. OCSF lo gobierna un proyecto independiente bajo la Linux Foundation desde noviembre de 2024 y aparece más en plataformas de datos de nube, como Amazon Security Lake. Cubren el mismo problema (un vocabulario común de campos) con comunidades y formatos distintos, y hoy no son intercambiables sin traducción.

¿Puedo reutilizar el mismo decoder si el fabricante cambia el formato del log en una actualización?

No sin revisarlo. Un cambio de formato del proveedor (un campo que pasa de cadena a objeto, una cabecera que se añade o se quita) puede romper tu prematch o desalinear tu order sin que Wazuh avise con un error: simplemente deja de extraer ese campo o lo extrae mal. Conviene volver a pasar el nuevo log por wazuh-logtest cada vez que el proveedor publique una nota de cambios, no solo la primera vez que escribiste el decoder.

¿Qué diferencia hay entre normalizar en el decoder y normalizar con el logsource de una regla Sigma?

El decoder define el campo dentro de tu SIEM concreto. El logsource de Sigma, combinado con el pipeline del backend, traduce el nombre genérico que usa la regla al nombre real que tu SIEM ya tiene definido gracias al decoder. Son capas distintas del mismo trabajo: si el decoder no existe, el pipeline de Sigma no tiene a qué campo apuntar.

¿Compensa parsear en el origen si ya tengo una tubería de ingesta potente?

Depende de si controlas esa fuente. Si la controlas (un servidor propio, un agente que configuras tú), activar la salida estructurada en origen reduce el trabajo de la tubería y evita duplicar la misma lógica de parseo en cada consumidor que lea ese log. Si no la controlas (un dispositivo de terceros que solo exporta syslog de texto plano), la tubería es tu única opción real, y ahí sí conviene vigilar el coste de cada expresión de parseo, no solo si funciona.