Mini-SOC homelab : Wazuh, Cowrie et alerting Telegram
Comment j'ai monté un Security Operations Center complet à la maison : Wazuh sur 15 agents, honeypot SSH Cowrie, custom rules MITRE ATT&CK, et alerts Telegram pour chaque attaque détectée. Architecture, code et leçons apprises.
Le contexte
Quand on bosse en sécurité applicative, on parle souvent de defense in depth. Mais dans un homelab personnel, on a tendance à s'arrêter au reverse proxy avec quelques security headers. J'ai voulu aller plus loin : monter un vrai mini-SOC, avec collecte de logs, détection, corrélation et alerting actif.
L'objectif n'est pas la paranoia, c'est la pratique. Ce homelab héberge mes services personnels mais aussi des projets clients via Coolify. Si quelque chose se fait compromettre, je veux le savoir rapidement, pas dans 6 mois quand un audit le révèle.
Code et configs complets sur le repo
didoulab-homelab(privé), avec CHANGELOG détaillé et runbooks pour chaque incident rencontré pendant le déploiement.
Architecture
Trois LXC dédiés à l'observabilité sécurité, plus le PVE host et tous les workloads applicatifs comme sources de données.
[15 agents Wazuh] [Vector x 15] [Cowrie honeypot]
│ │ │
v v v
[Wazuh manager :1514/1515] [Loki :3100] [Wazuh agent local]
│ │ │
└──────► alerts ◄─────┤ │
│ │ │
v v v
[Custom integration custom-telegram] [Manager parse]
│
v
[Telegram bot]
Wazuh manager (LXC 122)
Ubuntu 24.04 LTS, 4 vCPU, 6 GB RAM, 30 GB rootfs volontairement sur HDD Hitachi plutôt que sur le SSD local-lvm. Choix de design : Wazuh indexer (OpenSearch fork) peut grossir vite, et je préfère saturer le HDD que mon SSD critique qui héberge les rootfs des autres LXC.
Wazuh 4.14.5 all-in-one : manager + indexer + dashboard + filebeat. Dashboard accessible via wazuh.didoulab.com (Traefik wildcard cert + serversTransport insecure pour le self-signed du dashboard).
15 agents enrolled (100% coverage)
Tous les hosts actifs ont un agent Wazuh installé :
| ID | Type | Host |
|---|---|---|
| 001 | PVE host | hyperviseur lui-même |
| 002 à 013 | LXC | adguard, frigate, traefik, influxdb, grafana, glance, excalidraw, servarr, nas-files, immich, honeypot, nsm-logs |
| 014, 015 | VMs | authentik, coolify |
Excludes : HAOS VM (HassOS specialisé pas d'apt), NPM LXC supprimé après migration Traefik.
Les agents sont organisés en 5 groupes Wazuh pour pouvoir pousser des configs ciblées :
pve-host (1 agent)
lxc-services (10 agents)
lxc-honeypot (1 agent : cowrie LXC)
lxc-monitoring (1 agent : Loki/Vector LXC)
vm-services (2 agents : authentik + coolify VMs)
Le groupe lxc-honeypot reçoit par exemple un FIM realtime aggressif sur /home/cowrie/cowrie/etc pour détecter si un attaquant ayant pivoté tente de désactiver Cowrie. Le groupe vm-services reçoit le wodle docker-listener pour tracker les events containers (Coolify spawn beaucoup de workloads).
Les sources de logs
Cowrie SSH honeypot
LXC 120 dédié, Debian 13, 512 MB de RAM. Cowrie écoute sur :2222 en interne, et un iptables NAT redirect 22 vers 2222 fait que tout SSH externe sur le port standard hit le piège.
Le SSH du LXC lui-même est désactivé (administration uniquement via pct exec depuis le PVE host). Cowrie log en JSON dans /home/cowrie/cowrie/var/log/cowrie/cowrie.json. Wazuh agent local sur le LXC monitor ce fichier en log_format=json, ce qui décode automatiquement les fields cowrie (eventid, src_ip, username, password, input, dst_ip, etc.) et les rend disponibles aux rules custom.
Traefik access log
LXC 103 (reverse proxy) écrit en JSON dans /var/log/traefik/traefik-access.log. Le filter statusCodes est configuré pour logger ["200", "400-599"] : un audit complet du trafic légitime plus toutes les anomalies. Le User-Agent est gardé dans les fields pour la détection de bots.
Wazuh agent monitor ce fichier en JSON, ce qui donne accès à RequestPath, DownstreamStatus, ClientHost, RequestMethod, request_User-Agent dans les rules.
sshd journald sur PVE
L'agent PVE host monitor journald (default <localfile><log_format>journald</log_format></localfile>). sshd et pvedaemon écrivent leurs auth events dedans, et Wazuh a déjà des decoders pour ces deux services.
AdGuard query log
LXC 100, fichier /opt/AdGuardHome/data/querylog.json (mode 0600 root par défaut). J'ai utilisé setfacl -m u:wazuh:r pour donner accès au user wazuh sans relâcher les perms global, plus une ACL équivalente sur le dossier parent pour la traversée. Le file est append-only par AdGuard, donc l'ACL persiste.
Volume : ~120 MB par jour. Ingestion Wazuh ciblée uniquement sur les patterns suspects (TLDs free abusés, subdomains base64 longs).
Coolify docker logs
Vector sur la VM Coolify ship les container logs via la source docker_logs vers Loki. Wazuh agent sur la même VM utilise le wodle docker-listener pour tracker les events containers (start, stop, kill, image pull, network connect).
Custom rules avec MITRE ATT&CK
11 rules custom dans local_rules.xml, chacune mappée aux techniques MITRE pour que le dashboard "MITRE ATT&CK" de Wazuh montre des techniques réelles détectées (pas juste les rules built-in qui matchent rien sur du trafic homelab).
| Rule | Level | Trigger | MITRE |
|---|---|---|---|
| 100100 | 12 | Cowrie session.connect | T1110 (Brute Force), T1078 (Valid Accounts) |
| 100103 | 8 (silent) | Cowrie command.input | T1059 (Command Interpreter) |
| 100104 | 13 | Cowrie direct-tcpip pivot | T1572 (Protocol Tunneling), T1090 (Proxy) |
| 100105 | 9 | Cowrie file_download | T1105 (Ingress Tool Transfer) |
| 100200 | 11 | Traefik path scan (.env, wp-login, phpmyadmin, etc.) | T1595.003 (Wordlist Scanning) |
| 100203 | 11 | Traefik freq 5+ 401/403 / 60s = brute force web | T1110.001 (Password Guessing) |
| 100205 | 10 | Traefik freq 20+ 404 / 60s = path probing burst | T1595.001 (IP Block Scanning) |
| 100400 | 11 | PVE freq 5+ auth fail / 60s = brute force web UI | T1110.003 (Password Spraying) |
| 100500 | 9 (silent) | AdGuard query to suspicious TLD (.tk/.ml/.ga/.cf/.gq) | T1071.004 (DNS C2) |
| 100501 | 11 | AdGuard freq 5+ suspicious TLD / 60s same client = burst | T1071.004 + T1041 (Exfiltration over C2) |
| 100502 | 10 | AdGuard subdomain [A-Za-z0-9+/=_-]{40,} = DNS tunneling | T1572 + T1041 |
Stratégie sur les levels :
- Silent base rules (level 5 à 9) : matchent les events bruts sans trigger Telegram
- Aggregate rules (level 10+) :
<frequency>+<timeframe>+<same_field>pour catch les bursts. C'est ces rules qui fire sur Telegram.
Exemple concret pour le path probing :
<rule id="100204" level="3">
<decoded_as>json</decoded_as>
<field name="DownstreamStatus" type="pcre2">^404$</field>
<description>Traefik: 404 not found on $(RequestPath)</description>
<group>web,</group>
</rule>
<rule id="100205" level="10" frequency="20" timeframe="60">
<if_matched_sid>100204</if_matched_sid>
<same_field>ClientHost</same_field>
<description>Traefik: 404 burst (20+ in 60s) from $(ClientHost) = path probing</description>
<group>attack,web,scan,recon,</group>
<mitre>
<id>T1595.001</id>
</mitre>
</rule>
Un bot qui scanne /wp-admin, /admin, /.env, /phpinfo.php, etc. va générer 20+ alerts 100204 silent en quelques secondes, ce qui trigger 100205 aggregate level 10, qui trigger l'alert Telegram.
Alerting Telegram via integration custom
Wazuh ne ship pas avec une integration Telegram native. J'ai écrit une integration custom Python (stdlib only, pas de dépendance jq comme la version bash initiale qui avait des bugs de quoting).
#!/usr/bin/env python3
"""
Wazuh -> Telegram custom integration.
Wazuh manager calls: /var/ossec/integrations/custom-telegram <alert.json> <chat_id> <bot_token>
"""
import json, sys, urllib.parse, urllib.request
from pathlib import Path
def main():
alert = json.loads(Path(sys.argv[1]).read_text())
rule = alert.get("rule", {})
level = rule.get("level", 0)
icon = "🚨" if 12 <= level <= 15 else "⚠️" if 10 <= level <= 11 else "ℹ️"
msg = f"<b>{icon} Wazuh L{level}</b>\n<i>{rule.get('description')}</i>\n..."
urllib.request.urlopen(urllib.request.Request(
f"https://api.telegram.org/bot{sys.argv[3]}/sendMessage",
data=urllib.parse.urlencode({
"chat_id": sys.argv[2],
"text": msg,
"parse_mode": "HTML",
}).encode(),
method="POST",
), timeout=10)
Configuration côté manager dans ossec.conf :
<integration>
<name>custom-telegram</name>
<hook_url>${BOT_TOKEN}</hook_url>
<api_key>${CHAT_ID}</api_key>
<level>10</level>
<alert_format>json</alert_format>
</integration>
Le filter <level>10</level> fait que seules les alerts L10 ou plus déclenchent un message Telegram. Les events L1 à L9 restent dans le dashboard pour drill-down quand j'investigue, mais ne spamment pas.
Le bot Telegram est partagé avec PVE notifications et Grafana alerting, ce qui me donne un seul canal pour tous les events critiques de l'infra.
Dedup et tuning
Premier déploiement, avec les rules trop sensibles, j'ai reçu 4 messages Telegram pour un seul SSH attempt sur le honeypot (Wazuh L12 connect, L10 login success, L11 command, plus un alert Grafana redondant).
J'ai abaissé les rules cowrie.login.success et cowrie.command.input à L8 (sous le threshold Telegram), et paused la rule Grafana équivalente qui faisait double emploi. Résultat :
1 SSH attempt sur honeypot = 1 message Telegram (cowrie.session.connect L12)
+ pivot tentative = 1 message extra L13
+ download malware = 1 message extra L9 (silent), monté à L10 si je veux notif
Signal/noise correct, et les détails restent consultables sur le dashboard Wazuh quand je clique sur l'agent.
J'ai aussi rencontré un cas de faux positif intéressant : la règle 100502 (DNS tunneling, subdomain >= 40 chars) a fire sur k8s-opstest-apisixga-ffd5932408-71111629e4aa4dd8.elb.us-east-1.amazonaws.com. C'est un AWS ELB hostname légitime que Coolify checkait pour une dépendance. Fix : ajout d'un <field negate="yes"> qui exclut les FQDNs cloud connus (amazonaws, cloudfront, googleusercontent, akamai, vercel, netlify, etc.).
Même pattern pour la règle 100203 (brute force 401/403) qui firait sur le dashboard Wazuh lui-même quand je me logge (auth flow génère des 401 légitimes pendant la session establishment). Fix : exclude wazuh.didoulab.com|authentik.didoulab.com|grafana.didoulab.com du base rule.
Leçon : les rules de détection sécurité ne se déploient pas en mode "fire and forget", elles demandent du tuning itératif sur les premiers jours pour calibrer les exclusions et les seuils.
CanaryTokens DNS sur les subdomains attractifs
Pour la couche détection de recon externe, j'utilise CanaryTokens (service gratuit de Thinkst). Une dizaine de CNAMEs déployés sur ma zone DNS, en mode "DNS only" (pas de proxy CF), couvrant les sous-domaines classiques que les scanners testent en premier — pattern type :
<subdomain>.example.com CNAME <token>.canarytokens.com
Les sous-domaines réellement provisionnés ne sont volontairement pas listés ici (un canary documenté publiquement perd sa valeur défensive — l'attaquant qui a lu cet article les évite). La liste est tournée régulièrement.
Le mode "DNS only" est critique. Si on laisse CF en mode proxy orange, c'est CF qui résout le CNAME côté edge et le token fire à chaque renouvellement de cache CF. En mode gris, le resolver de l'attaquant suit lui-même le CNAME, ce qui donne sa vraie IP au token nameserver.
Quand un scanner externe fait du DNS recon sur la zone en testant des subdomains classiques, il hit ces tokens et je reçois un email avec son IP. Couvre la couche externe que Wazuh ne peut pas voir (recon DNS-only ne touche pas mes services).
Backups, retention et monitoring disk
Backup Wazuh manager
Le LXC 122 est inclus dans le job PVE backup quotidien (vzdump --all) qui écrit sur le HDD Hitachi, plus le job hebdo qui écrit sur le SSD backup dédié (off-Hitachi). Sans ce dernier, si Hitachi meurt physiquement, je perds à la fois le rootfs Wazuh et son backup.
Retention indexer (ISM policy)
L'indexer OpenSearch grossit à chaque alert et chaque event ingéré. Sans ISM (Index State Management) policy, c'est une fuite linéaire. J'ai appliqué une policy 30 jours :
{
"policy": {
"states": [
{"name": "hot", "transitions": [{
"state_name": "delete",
"conditions": {"min_index_age": "30d"}
}]},
{"name": "delete", "actions": [{"delete": {}}]}
],
"ism_template": [{
"index_patterns": ["wazuh-alerts-*", "wazuh-archives-*"],
"priority": 100
}]
}
}
Les indices wazuh-alerts-* et wazuh-archives-* se delete tout seuls après 30 jours. Footprint disk stable au lieu de croissance infinie.
Disk monitoring custom
Cron horaire sur le PVE host qui check à la fois les filesystems classiques (df) et les thinpools LVM (lvs) :
FS_CHECKS=(
"/:10" # rootfs PVE 10GB free min
"/mnt/pve/Hitachi:100" # bulk HDD
"/mnt/backup-ssd:30" # backup tier
"/mnt/usbssd:100" # workloads tier
)
LVM_THINPOOLS=(
"pve/data:85:85" # data% / metadata% threshold
)
Si un thinpool atteint 100% data, tous les LVs passent en read-only. Si un thinpool atteint 100% metadata, c'est de la corruption non récupérable sans backup. Le monitor distinct des deux est non-négociable.
Alert via Telegram (réutilise le bot homelab), token et chat_id extraits runtime depuis /etc/pve/priv/notifications.cfg, jamais hardcoded dans le script committé.
Bilan en métriques
Couverture multi-layer après ~25 commits sur le repo homelab dédiés à cette stack :
| Layer | Détection | Outil |
|---|---|---|
| DNS | Suspicious TLDs + tunneling burst | Wazuh + AdGuard logs |
| Web reverse proxy | Path scan, 401/403 brute force, 404 probing burst | Wazuh + Traefik logs |
| Honeypot SSH | session.connect, pivot, file_download, command.input | Cowrie + Wazuh |
| OS SSH (PVE host) | Brute force user invalides | Wazuh built-in 5712 |
| PVE web UI | Brute force pveproxy/pvedaemon | Wazuh built-in 87201 + custom 100400 |
| File integrity | FIM sur tous les agents | Wazuh syscheck default |
| Compliance | CIS Debian 13 scoring par agent | Wazuh SCA default |
| Recon externe DNS | Probing de subdomains attractifs | CanaryTokens DNS |
| Logs centralisés | Tous les hosts | Vector + Loki + Grafana |
Validation end-to-end : pour chaque catégorie, j'ai déclenché un test (faux SSH attempts, curl /.env, fake login PVE, dig vers evil.tk, etc.) et confirmé réception du message Telegram correspondant.
Ce que j'ai appris
-
wazuh-logtest est essentiel pour le tuning rules. Bien plus rapide que de commit + restart manager + déclencher event + check log. On feed une ligne de log et on voit immédiatement quelle rule match avec quel level. Un bug que je pensais venir du regex matchait en fait, c'était l'agent qui ne forwardait pas les events comme prévu.
-
Les false positives demandent du temps les premiers jours. Path scan rules qui firent sur les API internes Wazuh, DNS tunneling rule qui fire sur AWS ELB hostnames légitimes. La règle d'or : un alert qui fire 5 fois par jour sans être vrai = on perd confiance dans le système entier. Mieux vaut un peu moins sensible mais 100% trustable.
-
Les frequency rules ont besoin d'un base rule et
<if_matched_sid>. Une rule avec justefrequency=Nsans parent fait crasher wazuh-analysisd au boot (Invalid use of frequency/context options). Pattern correct : 1 base silent + 1 aggregate qui référence le base. -
Le wazuh-passwords.txt fichier après install est la source de vérité pour les credentials des composants internes (admin dashboard, indexer, kibanaserver, wazuh-wui API). À ranger dans Bitwarden et à ne pas garder en clair sur le LXC après le post-install.
-
MITRE ATT&CK mapping change la valeur du dashboard. Sans ça, le module MITRE de Wazuh affiche zéro technique. Avec, on voit "T1110 Brute Force détecté 47 fois cette semaine" et on peut prioriser les techniques selon leur criticité.
-
Defense-in-depth signifie aussi accepter de ne pas tout détecter. Coolify webhook spoofing par exemple : impossible à détecter sans patcher Coolify. La défense préventive (HMAC signature validation + CF Access bypass policy strict + tunnel restreint) suffit. Inutile de courir après une rule de détection coûteuse pour un cas que la prévention couvre déjà.
-
Un bug Coolify v4 : changer la FQDN d'une app dans l'UI ne régénère pas le
docker-compose.yamlcôté serveur cible si on save sans avoir clear le champ d'abord. Le redeploy utilise le compose existant tel quel. Workaround : clear complet du champ Domains, save, re-paste, save, redeploy. C'est ce genre de gotcha qui justifie que je documente tout sur Git en runbooks pour ne pas perdre 2 heures la prochaine fois.
Toute l'infrastructure est documentée publiquement sur le repo didoulab-homelab (privé), avec configs versionnées et CHANGELOG détaillé. Pour la vue d'ensemble du homelab non-sécurité, voir le post infrastructure 16 services. Pour la sécurité applicative côté portfolio Next.js, voir le post defense in depth.
Besoin d'un site vitrine professionnel ?
Je crée des sites sur mesure pour les entreprises à Caen et partout en France. Devis gratuit, réponse sous 24h.