Contenu

Pipeline de logs CrowdSec avec Vector : filtrer le bruit et capturer les vrais bans

⚡ En bref

Le pipeline Vector initial inondait BetterStack de ~500 events/24h, dont 434 pulls CAPI sans valeur de supervision locale. Ce travail reconfigure le filtre Vector pour ne garder que les bans à haute valeur (cscli) et corrige un angle mort majeur : les bans effectifs du bouncer nginx-lua n’apparaissaient nulle part dans BetterStack.

🧠 Pourquoi

La stack de sécurité repose sur trois composants qui travaillent ensemble :

  • nginx avec le bouncer lua CrowdSec (lua-resty-crowdsec) qui bloque les requêtes en temps réel
  • CrowdSec pour la détection et la gestion des décisions de ban
  • Vector qui centralise les logs vers BetterStack pour la supervision

Après avoir mis en place le pipeline initial, deux problèmes sont apparus rapidement. D’abord, le signal était noyé dans le bruit : sur 500 events/24h, 434 venaient du pull CAPI communautaire horaire et 66 des listes tierces — ni les uns ni les autres ne représentent une menace détectée sur cette infrastructure. Ensuite, les bans effectifs du bouncer lua (blocages en temps réel dans nginx) n’apparaissaient nulle part dans BetterStack, ce qui créait un angle mort sur l’activité de sécurité réelle.

🔧 Ce qui a été fait

Problème 1 : le bruit CAPI et les listes tierces

Sur une période de 24h, la répartition des events CrowdSec dans BetterStack était :

OriginNombreNature
CAPI434Pull communautaire toutes les heures
lists66Listes tierces (firehol_greensnow, otx-webscanners…)
cscli0Bans locaux manuels — jamais vus

Les events CAPI et lists arrivent en rafales toutes les heures pile (à :09 de chaque heure) correspondant au cycle de synchronisation des listes communautaires. Solution : modifier le filtre Vector pour ne garder que origin == "cscli" :

# Dans vector.yaml
crowdsec_decisions_filter:
  type: "filter"
  inputs:
    - "crowdsec_decisions_flatten"
  condition: |
    exists(.cs) && .cs.origin == "cscli"

Problème 2 : les bans effectifs du bouncer lua invisibles

Le bouncer nginx-lua bloque les IPs en temps réel, mais ces blocages effectifs n’apparaissaient nulle part dans BetterStack. Pourtant, ils sont loggés par nginx dans /var/log/nginx/error.log :

2026/04/15 03:36:37 [alert] 67913#67913: *3949 [lua] crowdsec.lua:783: Allow(): \
  [Crowdsec] denied '43.130.106.18' with 'ban' (by bouncer), \
  client: 43.130.106.18, server: www.arleo.eu, \
  request: "GET / HTTP/2.0", host: "www.arleo.eu"

Le problème venait du filtre nginx existant dans Vector, qui droppait silencieusement tout message contenant le mot crowdsec. Puisque error.log est déjà inclus dans la source nginx, il ne faut pas créer une nouvelle source — il faut intercaler un transform avant le filtre pour tagger et rediriger ces events.

Architecture du nouveau pipeline

     ┌────────────────────────┐
     │    nginx error.log      │
     └──────────┬──────────────┘
                │
                ▼
     ┌─────────────────────────────────┐
     │   better_stack_nginx_parser      │
     │   (parse tous les logs nginx)    │
     └──────────┬──────────────────────┘
                │
                ▼
     ┌─────────────────────────────────┐
     │  crowdsec_nginx_ban_extractor    │
     │  (détecte [Crowdsec] denied)     │
     │  tague cs_nginx_ban = true/false │
     └────┬─────────────────┬───────────┘
          │                 │
   cs_nginx_ban==true  cs_nginx_ban==false
          │                 │
          ▼                 ▼
  crowdsec_nginx_   better_stack_
  ban_filter        nginx_filter
          │                 │
          ▼                 ▼
  Sink CrowdSec     Sink nginx
  BetterStack       BetterStack

Le transform extractor

crowdsec_nginx_ban_extractor:
  type: "remap"
  inputs:
    - "better_stack_nginx_parser_XXXXX"
  source: |
    msg = string(.message) ?? ""
    if contains(msg, "[Crowdsec] denied") && contains(msg, "with 'ban'") {
      m = parse_regex(msg, r'\[Crowdsec\] denied \'(?P<banned_ip>[^\']+)\' with \'ban\'') ?? {}
      ip   = string(m.banned_ip) ?? "?"
      req  = string(.nginx.request) ?? "-"
      host = string(.nginx.host) ?? string(.nginx.server) ?? "-"
      .cs_nginx_ban = true
      .cs_banned_ip = ip
      .cs_origin    = "nginx-bouncer"
      .platform     = "CrowdSec"
      .message      = "Ban " + ip + " | " + req + " | " + host
      del(.file)
      del(.level)
      del(.nginx.cid)
      del(.nginx.pid)
      del(.nginx.tid)
    } else {
      .cs_nginx_ban = false
    }

Le champ .message est construit pour être immédiatement lisible dans le tail BetterStack :

Ban 43.130.106.18 | GET / HTTP/2.0 | www.arleo.eu

Modification du filtre nginx existant

Ajout de la condition d’exclusion des bans déjà reroutés :

better_stack_nginx_filter_XXXXX:
  type: "filter"
  inputs:
    - "crowdsec_nginx_ban_extractor"   # ← pointe sur le nouveau transform
  condition: |
    !contains(string(.message) ?? "", "crowdsec") &&
    !contains(string(.message) ?? "", "Initialisation done") &&
    !contains(string(.message) ?? "", "APPSEC is enabled") &&
    !((.nginx.status == 499) && contains(string(.nginx.path) ?? "", "empty.php")) &&
    !contains(string(.message) ?? "", "lua tcp socket read timed out") &&
    !(.cs_nginx_ban == true)           # ← exclusion des bans reroutés

Sink CrowdSec unifié

Les deux flux (bans cscli et bans lua) convergent vers le même sink :

crowdsec_betterstack_sink:
  type: "http"
  inputs:
    - "crowdsec_decisions_filter"   # bans cscli
    - "crowdsec_nginx_ban_filter"   # bans lua nginx

Bonus : token Cloudflare bloqué par son propre NUC

En marge du travail sur Vector, le script crowdsec-cf-sync.py était en échec silencieux depuis plusieurs jours avec des erreurs HTTP 401 Authentication error. La cause : le token Cloudflare avait une restriction IP not_in qui incluait explicitement l’IP WAN du serveur lui-même. Toutes les requêtes API émises depuis le NUC étaient rejetées par Cloudflare.

Correction : retirer l’IP du serveur de la liste not_in du token via l’API Cloudflare. Le script a immédiatement repris sa synchronisation normale (13 bans actifs re-synchronisés).

🏁 Conclusion

Le rapport signal/bruit est passé de ~500 events/24h majoritairement inutiles à uniquement les événements qui méritent attention : bans cscli (décisions manuelles ou scénarios locaux) et bans nginx-bouncer (blocages effectifs en temps réel avec IP, requête et vhost). Le pipeline donne enfin une vision fidèle de l’activité de sécurité réelle du serveur.

Pour aller plus loin :

  • 💡 Ajouter un compteur de bans nginx-bouncer dans un dashboard BetterStack pour visualiser les pics de blocage en temps réel
  • 💡 Étendre le filtre cscli pour inclure aussi les bans issus de scénarios personnalisés locaux (origin == "crowdsec" avec scope IP)