
🌐 También en: English · Deutsch · Français
Una herramienta de control de integridad vale lo que valen los correos que envía. Si cada mañana avisa de cientos de cambios, al cabo de una semana nadie los lee, y el único hallazgo real se pierde con ellos. Ese era mi problema con AIDE y rkhunter en tres hosts K3s: los dos funcionaban, pero sobre todo avisaban de cosas que había provocado yo mismo.
Este deep dive muestra qué hacen las dos herramientas, de dónde sale el ruido en un host de Kubernetes y cómo la base de referencia acompaña cada cambio intencionado.
🧾 Qué hace AIDE
AIDE (Advanced Intrusion Detection Environment) crea una base de datos: para cada archivo vigilado guarda sumas de verificación, permisos, propietario, tamaño y marcas de tiempo. La comprobación diaria compara el sistema de archivos con esa base. Si alguien modifica un programa en /usr/bin o una configuración en /etc, se nota, aunque el atacante haya limpiado los registros.
En mis hosts se ejecuta una vez al día y solo envía un correo si AIDE encuentra diferencias de verdad. Dos detalles lo hacen útil en el día a día:
- Varios hilos: AIDE 0.19 puede calcular sumas en paralelo. Con un solo hilo, una pasada tardaba entre 38 segundos y minuto y medio; ahora usa un hilo por vCPU, con prioridad baja.
- El código de salida es una máscara de bits: 1 = archivos añadidos, 2 = eliminados, 4 = modificados. Los valores de 1 a 7 significan, por tanto, «diferencias encontradas»; solo 14 o más indica un error del propio AIDE. Tratarlo como un código de error normal es abortar justo cuando AIDE ha hecho su trabajo, que es lo que le pasó a mi primera versión.
Lo que queda: AIDE no dice «bueno» o «malo», sino «distinto de ayer». Que eso sea un hallazgo depende de que la base esté al día.
🐳 De dónde sale el ruido en un host K3s
Un host de Kubernetes cambia constantemente sin que ocurra nada sospechoso. Tres lugares producían falsas alarmas con regularidad:
/var/log/pods: K3s crea un subdirectorio en cada arranque de pod y en cada ejecución de un CronJob. Eso cambia el número de enlaces del directorio padre, y AIDE lo notificaba prácticamente a diario./var/log/containers: un enlace simbólico por contenedor que apunta a/var/log/pods. Si un pod de CronJob se limpia durante la comprobación, el enlace apunta al vacío, y el correo empezaba con «failed to access …»./boot/efi: un sistema de archivos FAT que AIDE no puede leer bajo SELinux. De todos modos se saltaba la partición, pero cada pasada generaba una serie de rechazos de SELinux, 13 por pasada en uno de los hosts.
Los dos primeros no son un objetivo de manipulación: son capturas efímeras de la salida de los contenedores, ni programas ni configuración. Por eso quedan excluidos, cada uno con su justificación en el código. En /boot/efi no basta con excluir: AIDE sigue consultando las propiedades del sistema de archivos con statfs(). Un pequeño módulo local de SELinux permite exactamente esa llamada: leer los metadatos del sistema de archivos, ni un solo archivo. Los archivos EFI vienen de paquetes, así que rpm -V los comprueba si hace falta.
Lo que queda: las exclusiones están bien mientras cada una tenga un motivo. «Molesta» no lo es; «es efímero y no es un objetivo de ataque», sí.
🔄 La base tiene que acompañar
Aun así, la mayoría de las falsas alarmas venían de mí. Cada cambio intencionado (una actualización de paquetes, una regla nueva del cortafuegos, un binario nuevo de K3s) es primero una desviación para AIDE. En mis hosts hay tres caminos por los que cambian archivos vigilados, y cada uno necesita su propia actualización de la base:
- Las ejecuciones de Ansible: el último rol de cada playbook se llama
common_aide_refresh. Ejecutaaide --update, que comprueba y escribe la base nueva en una sola pasada. Antes eran una comprobación y una actualización seguidas, es decir, dos análisis completos por ejecución. - Las actualizaciones de seguridad nocturnas: dnf-automatic instala actualizaciones de noche, sin Ansible. Tras un cambio de kernel, AIDE notificó a la mañana siguiente 3.273 archivos añadidos, 3.273 eliminados y 364 modificados: el kernel viejo fuera, el nuevo dentro. Desde entonces, a las 04:00 se ejecuta una actualización propia de la base, después de la ventana de actualizaciones y del posible reinicio de las 03:30.
- Los cambios a mano: quien cambia algo fuera de esos dos caminos tiene que lanzar la actualización él mismo. Mi regla: después de cada ejecución de un playbook con
--tags, ejecutar también--tags common_rkhunter,common_aide_refresh, porque una ejecución con etiquetas se salta el último rol.
El tercer camino es el más delicado. Cuando hace poco instalé un kernel a mano por la tarde, la comprobación de medianoche, anterior a la actualización de las 04:00, habría notificado justo ese kernel.
Lo que queda: cada camino por el que cambia un sistema necesita su actualización de la base. Si falta uno, la falsa alarma llega con total seguridad.
🦠 rkhunter: una segunda opinión con sus manías
rkhunter (Rootkit Hunter) funciona de forma parecida, pero con otro enfoque: compara programas importantes del sistema con su propia base de propiedades y busca rastros de rootkits conocidos. También necesita una base actualizada, que crea rkhunter --propupd.
Hicieron falta tres cosas para que se callara:
- Un plugin de dnf en lugar de un enganche al temporizador: tras cada transacción de dnf que realmente ha cambiado paquetes, un pequeño plugin ejecuta
rkhunter --propupd. Mi primera solución colgaba del servicio de dnf-automatic, así que solo actuaba en las actualizaciones nocturnas, no en undnf upgrademanual. - Un solo análisis por noche: el paquete de EPEL trae su propio script en
/etc/cron.daily. Junto con mi propia entrada de cron, cada host hacía dos análisis completos por noche, y un aviso suponía dos correos. El script del paquete ya no está, y un ajuste de rpm (%_netsharedpath) impide que la próxima actualización de rkhunter lo vuelva a poner. - Pruebas desactivadas, cada una con su motivo: algunas pruebas no encajan con AlmaLinux 9 o con K3s, por ejemplo la prueba de configuración de SSH, con un error conocido de rkhunter 1.4.6, o las pruebas de módulos, que buscan en la ruta equivocada tras un cambio de kernel. Cada prueba desactivada figura con su motivo en el rol.
Un detalle para el principio de deriva: --propupd renueva una base derivada, no una configuración. Por eso la tarea nunca notifica «changed»; si no, cada ejecución del playbook parecería una deriva.
Lo que queda: a veces un paquete trae su propia idea de cómo operar. Quien planifica por su cuenta tiene que desactivar a conciencia la planificación incluida, y asegurarse de que no vuelva.
💓 ¿Quién comprueba al comprobador?
Una comprobación de integridad silenciosa solo sirve si de verdad se ejecuta. «Ningún correo» puede significar dos cosas: todo va bien, o la tarea ya no se ejecuta. Por eso cada una de estas tareas escribe al final una marca de tiempo (un latido) para Prometheus, y una regla de alerta salta cuando alguna se queda demasiado antigua.
Al configurar los latidos salieron a la luz dos errores antiguos:
- En un host se ejecutaba a las 05:00 una segunda comprobación de AIDE creada a mano, que no estaba en ningún rol ni en ningún repositorio: de un solo hilo y duplicada.
- Los correos al destinatario a secas
rootnunca llegaban. El relé los aceptaba, pero msmtp no conoce alias locales y, sin dominio, el correo no iba a ninguna parte. Desde entonces, cada aviso va a una dirección completa.
Lo que queda: el silencio no es una prueba. Una herramienta de control necesita a su vez una comprobación de que se ejecuta y de que sus correos llegan.
🎯 Las lecciones
- Una alarma tiene que significar algo: si no, nadie la lee.
- Exclusiones solo con motivo: los registros efímeros de los contenedores sí, «molesta» no.
- Cada camino de cambio necesita su actualización de la base: Ansible, actualizaciones nocturnas y trabajo manual.
- Revisar las planificaciones incluidas: un paquete puede traer un segundo análisis.
- Vigilar al comprobador: un latido por tarea y correos a direcciones reales.
Más contexto sobre SELinux en estos hosts en el deep dive de SELinux, y la vigilancia de las tareas en la parte 6 del recorrido por Ansible.

