Skip to content

INV-001 — CrowdSec como capa reactiva del edge (Q3 del ADR-0019)

Question

¿Activa Wetopi CrowdSec (Security Engine + LAPI in-cluster, alimentando el set nft wetopi_fwedge existente) como capa reactiva del edge? El ADR-0019 §3 la propuso y la dejó abierta: el fw-edge actual solo aplica blocklists estáticas (reacción de minutos-horas); CrowdSec añadiría detección de ataques EN la plataforma con reacción de segundos. La pregunta no es técnica "¿funciona?" sino de decisión: ¿el coste (componentes, tuning, superficie de falsos positivos multi-tenant) justifica cerrar el hueco entre "detección pasiva" (Suricata/Tetragon observan) y "bloqueo reactivo" (hoy: manual)?

Method

Estudio de escritorio (sin deploys, sin manifests): (1) lectura del contrato CON-CROWDSEC y del ADR-0019 §3-Q3; (2) lectura del código real del fw-edge-controller (platform-security/controller/app.py, API v1) para fundamentar el contrato de integración propuesto; (3) lectura del pipeline de access logs traefik (E-OBS.1: JSON en stdout del DS hostNetwork, headers dropped, ingest a access-traefik-*) — exactamente el input que el Engine parsería; (4) research upstream: release 1.7.8 (2026-05-27) con CVE-2026-44981 (LAPI DoS, high) y CVE-2026-44982 (WAF bypass, high) — el pin v1.7.1 del ADR quedó VIEJO; (5) estimación de coste sobre el flujo real (~100k req/día agregado; pico estimado <5 req/s sostenido) contra perfiles conocidos de despliegues comparables; (6) análisis de riesgo de falsos positivos en hosting multi-tenant con IP de salida compartida.

Evidence (ART-*)

No se generaron ART-* (estudio de escritorio sin probes). Fuentes citadas en línea: CON-CROWDSEC (concepto, comparativa con fw-edge), platform-security/controller/app.py (superficie API v1: ipsets / revisions / subscribers / stream SSE), platform-edge/kubernetes/edge/ helmchartconfig-rke2-traefik-edge.yaml (access log JSON stdout, campos RequestAddr/DownstreamStatus/RouterName conservados; headers DROP), ADR-0019 (Q3 abierta, pin CrowdSec v1.7.1), advisory upstream 1.7.8 (CVE-2026-44981 LAPI DoS + CVE-2026-44982 WAF bypass, ambos HIGH, 2026-05-27), hub CrowdSec (colecciones crowdsecurity/wordpress: http-bf-wordpress leakspeed 10s/capacity 5; http-wordpress-scan; bruteforce genérica; http-crawl-non_statics).

Findings

F1. Coste: el flujo es pequeño para el Engine; el pin v1.7.1 es el riesgo real

  • Input: el Engine parsea el JSON de access log del traefik que YA producimos (flags --accesslog=true --accesslog.format=json en el HelmChartConfig del edge, transportado por CRI stdout). ~100k req/día agregados ≈ 1.2 req/s de media, picos < 50 req/s. Los benchmarks y la práctica común de la comunidad sitúan el Engine en decenas-hundreds de EPS por núcleo para parsing JSON de access log: el coste de parsing es despreciable (fracción de 1 vCPU).
  • LAPI (Local API): es el componente más pesado (SQLite, retención de decisiones, servido de decisiones a bouncers + consola). Con nuestro volumen de alertas (decenas-día tras el parsing, no miles: los scenarios agregan por IP+behavior) una réplica única está sobrada con 250-512 MiB RAM / 0.2 vCPU. La retención por defecto de decisiones (4-5 h para la mayoría de scenarios) mantiene la DB en decenas de MB.
  • Superficie añadida: 1 Deployment (Engine+LAPI en el mismo pod o separados; recomendado UN pod para day-one), 1 PVC pequeño (SQLite), y NO un firewall nuevo (el bouncer alimenta el set existente). El pin del ADR (v1.7.1) es anterior a 1.7.8, que arregla dos CVEs HIGH (CVE-2026-44981 DoS al LAPI — relevante si el Engine es alcanzable por tráfico no confiable; CVE-2026-44982 bypass del WAF AppSec). Si se activa, el pin mínimo aceptable es 1.7.8+, no 1.7.1. El bouncer traefik (maxlerebourg/crowdsec-bouncer-traefik v1.7.1 referenciado en el ADR) es una pieza aparte y NO se usa en la opción recomendada (ver F3).
  • Retención de decisiones: por defecto, las decisiones del Engine expiran solas (ban durations típicas 1-4 h con decisions_expire). Persistencia LAPI: purga automática (api.server.gc) — sin crecimento ilimitado del PVC. Dimensionar 5 GiB es holgado para años de decisiones.

F2. Scenarios WordPress: lista inicial recomendada, umbrales POR SITIO

Set day-one (colecciones del hub, parseables con el parser traefik JSON + parser crowdsecurity/http-logs):

Scenario (hub) Qué detecta Umbrales por defecto Riesgo FP en multi-tenant
crowdsecurity/http-bf-wordpress brute-force en wp-login.php capacity 5 / leakspeed 10s / ban 4h MEDIO: un plugin de marketing con muchos logins legítimos desde 1 IP (p.ej. pasarela de pagos que re-loggea) puede trippearlo
crowdsecurity/http-wordpress-scan probing de wp-config.php, /wp-content/plugins/ enumeration, etc. ban 4h BAJO: los scanners legítimos de un cliente (pentest propio) DEBEN ir a una allowlist del sitio, no global
crowdsecurity/http-crawl-non_statics crawlers agresivos sobre endpoints no estáticos leakspeed 0.5s / capacity 10 ALTO: crawlers legítimos de clientes (SEO, price-comparison) comparten la IP de egress del NAT-GW si el crawler corre EN la plataforma — ver riesgo estructural abajo
crowdsecurity/http-sensitive-path (genérica) probes de /.env, /.git/, /vendor/phpunit ban 4h NULO: no hay uso legítimo de estos paths en el edge público
crowdsecurity/http-bf-generic ráfagas 401/403/404 genéricas ban 4h MEDIO: APIs de clientes con auth failures legítimas (webhooks mal configurados)

Umbrales por SITIO, no globales (el modelo de modos del WAF — wetopi.io/waf-mode: off|detect|block — es el patrón a replicar): Cada wepod declara en SU namespace un ConfigMap de overrides del Engine o, en v0, se resuelve con un scope: site_id en las alertas (el Engine etiqueta cada evento con el RouterName del access log → site_id) y UMBRALES por sitio vía http-logs parsers específicos. El override concreto: los umbrales capacity/leakspeed de cada scenario son configurables por whitelist/simulation Y por parser-override del sitio (patrón: un ConfigMap crowdsec-scenarios por namespace, montado en el Engine; el GitOps lo lleva el repo de la plataforma igual que las reglas de Suricata — cf. el ConfigMap suricata-rules).

Riesgo estructural multi-tenant (el más serio de la lista): si un crawler legítimo del cliente A (corriendo en un wepod, saliendo por el NAT-GW SNAT 92.222.245.83) golpea el wp-login del cliente B, y B ha afinado su threshold bajo, el ban recae sobre la IP del NAT-GW: bloquea el egress de TODOS los wepods hacia B. Mitigaciones, en orden de preferencia: 1. Nunca banear rangos internos/Wetopi-owned: whitelist explícita en el Engine de los CIDRs de los nodos y del pool del NAT-GW (el Engine no puede distinguir el cliente real detrás del SNAT — el remediation component tampoco). 2. Baneo por IP+scope, no por IP global: las decisiones CrowdSec soportan scope: ip (default) vs range vs custom. Para el day-one usar SOLO scope: ip y bans cortos (≤1h) — un falso positivo expira rápido y solo afecta a UN destino (B), no a la plataforma. 3. Los crawlers que corren dentro de la plataforma deben tener egress class propio (wordpress-restricted/custom del ADR-0019 §3) — su tráfico al edge de otros sitios NO debería existir (egress netpol ya lo restringe); la detección es segunda línea, no primera.

F3. Contrato de integración con fw-edge: ruta dynamic-blocks en la API del controller

Decisión estructural que el ADR ya fija: las revisiones fw-rev-N son inmutables y viajan por artifact store (GitOps-adjacent); el estado dinámico de incidentes va por API del backend, NUNCA por GitOps (ADR-0019 §2 D-fw-edge: "el estado dinámico de incidentes va por API del backend"). CrowdSec es 100% estado dinámico → su integración es una ruta NUEVA en el controller, separada de /api/v1/revisions.

Propuesta concreta (encaja en platform-security/controller/app.py sin tocar el Store existente):

POST /api/v1/dynamic-blocks/{ipset-name}          # upsert de entradas dinámicas
  body: { entries: [{ip, reason, source, expires_at}], ttl_hint_s: int }
  - source: "crowdsec" (única admitida day-one; valida con un enum)
  - expires_at: el controller purga la entrada al expirar (NO el agent)
  - el ipset DEBE existir como ipset Git-managed (el controller NO crea
    ipsets dinámicos — el día-uno solo acepta el ipset reservado
    "dynamic-blocks" que el GitOps declara vacío)

GET  /api/v1/dynamic-blocks/{ipset-name}          # estado (para el agent y para drift)
DELETE /api/v1/dynamic-blocks/{ipset-name}/{ip}   # unban manual (audit trail)

POST /api/v1/dynamic-blocks/{ipset-name}/purge    # operator: vaciar (rollback de FP en masa)

El flujo: Engine decide → bouncer-bridge (un servicio nuevo, NO el bouncer traefik) llama POST /api/v1/dynamic-blocks/dynamic-blocks con la decisión → el controller la acopia en un ipset reservado dynamic-blocks que convive en la tabla wetopi_fwedge con las listas duras → los agents aplican en su poll ≤60s (mismo mecanismo que ya tienen, con el mismo sha256-verified pull). La tabla nft final es una UNIÓN de: (a) blocklists estáticas de la revisión inmutable fw-rev-N, (b) el ipset dinámico dynamic-blocks servido por la API. El agent NO distingue: el artifacts contract sigue igual (el ipset dynamic-blocks entra en la snapshot del revision publish, con la única salvedad de que su contenido cambia con frecuencia).

TTL/decay: la decisión del Engine ya lleva su duración (4h por defecto en los scenarios wordpress); el expires_at de la entrada dinámica lo copia del Engine. El controller purga al expirar (lazy: en el próximo GET/publish) + un GC periódico (cron del propio controller, cada 5 min). NO se propone un decay progresivo (más maquinaria, no hay caso de uso: los bans de CrowdSec ya son por duración fija).

Coexistencia con el set wetopi_fwedge: el ipset dynamic-blocks es UNA entrada más de la tabla inet wetopi_fwedge (contrato REC-20260909-FW-COEXIST intacto: tabla exclusiva, nunca flush ruleset). Las listas duras del GitOps y el dinámico NO compiten: son entradas diferentes del mismo set que el agent ya materializa. El orden de evaluación nft (prerouting blocklist ANTES del REDIRECT de edge_lb) no cambia.

Descarte del bouncer traefik plugin (v1.7.1 del ADR): un plugin en el DS hostNetwork del traefik es la superficie que ya nos quemó con el WAF Coraza (incidente 2026-09-12: HelmChartConfig del DS = caída del edge). Alimentar el set nft vía el controller es estrictamente más robusto (el plugin muere → el ban no se aplica, pero el edge sigue; el controller muere → last-known-good persiste en el kernel). El ADR ya listaba la opción "alimentación directa del set nft" como preferible.

F4. Reporte a la red global: opt-in, y QUÉ señal saldría

  • El opt-in es binario y barato de desactivar (flag api.client del Engine — si no está enrolado, el Engine opera 100% local).
  • Qué saldría si se activa: hashes de las IPs detectadas por nuestros scenarios (brute-force wp-login, scanners) + metadatos (scenario id, timestamps) — anonimizado (no URL, no dominios, no site_id, no payloads). Es la señal que la red devuelve en forma de blocklist comunitaria.
  • Qué NO saldría: el contenido de los requests (el access log headers ya lo prohíbe — GDPR, E-OBS.1), dominios de clientes, volúmenes por sitio, topología (site_id es un campo NUESTRO).
  • Riesgo real de activarlo day-one: el Engine (con red activada) es una salida HTTPS desde el pod LAPI → rompe el modelo egress "WordPress restringido" para ESTE pod, que además maneja datos de seguridad de la plataforma. Implicaciones: (a) netpol egress propia (solo api.crowdsec.net:443 — FQDN allowlist), (b) el pod se convierte en un token de reputación NUESTRO (si un FP masivo de nuestros scenarios sale reportado, afecta a la confianza de la señal, no a nuestro tráfico), (c) el valor de la señal global es MAYOR que el costo — miles de servidores WordPress comparten atacantes, y la blocklist comunitaria filtra IPs que las feeds estáticas no tienen (las IPs de brute-force rotan demasiado rápido para firehol level1).
  • Recomendación F4: activar el reporte en la FASE 2, no en day-one. La fase 1 (Engine local, sin red) ya produce el 80% del valor (reacción a ataques EN la plataforma); la señal global es un multiplicador que conviene activar tras validar los umbrales (no reportar falsos positivos propios a la red).

Conclusion (VEREDICTO)

ACTIVAR CON RECORTES (recomendación de la Q3, lista para decisión del operador):

  1. Sí al Engine + LAPI in-cluster (1 Deployment, 0.5 vCPU / 512 MiB request, PVC 5 GiB para SQLite), parseando el access log JSON del traefik que ya produce E-OBS.1. Pin v1.7.8+ (NO el v1.7.1 del ADR — CVE-2026-44981/44982).
  2. Sí al set inicial de 5 scenarios (F2) con umbrales por sitio (ConfigMap por namespace) y ban scope=ip, duración ≤4h, whitelists estructurales de IPs internas/NAT-GW. Los crawlers (F2, riesgo alto) entran en fase 2 tras una semana de simulación (simulation: true — el Engine soporta modo dry-run por scenario).
  3. Sí a la ruta dynamic-blocks en la API del fw-edge-controller (F3), separada de las revisiones inmutables; el bouncer traefik plugin queda DESCARTADO (el incidente 2026-09-12 es la razón).
  4. NO al reporte global en fase 1 (F4): opt-in diferido a fase 2 tras validar FP; la señal que saldría son solo metadatos anonimizados de decisiones, sin dominios ni site_id.
  5. Coste total de la pila a mantener: Engine+LAPI (1 pod), la ruta dynamic-blocks (~150 líneas en el controller + tests), 0 bouncers externos, 0 cambios en el agent nft existente. Beneficio: reacción de SEGUNDS a ataques en la plataforma (vs minutos-horas de las feeds estáticas) y escalada automática a incidente con señal compartida. El beneficio supera el coste: es el cierre del hueco "detección pasiva" → "bloqueo reactivo" que las capas Suricata (observa) y Tetragon (observa) no cubren.

Lo que NO decide este estudio: la implementación de la ruta dynamic-blocks (card operator-gated aparte, igual que el resto de applies del edge), el tuning fino de umbrales con tráfico real (requiere fase de simulación en un wepod de prueba), y la elección de bouncer-bridge concreto (script que llama a la API del controller vs un remediación component Go propio — a decidir en la card de implementación).

Follow-ups (ADR/REC candidates)

  • Card operator-gated: activar Engine+LAPI (Deployment + PVC) y la ruta /api/v1/dynamic-blocks en el controller — tras aceptar el veredicto de este estudio. No incluye deploy (esta card es estudio).
  • ADR (candidato): "CrowdSec como capa reactiva del edge" — si el operador acepta el veredicto, extraer las 4 decisiones (Engine in-cluster v1.7.8+, scenarios per-site, dynamic-blocks por API, reporte diferido) a un ADR-0022 que enmiende el ADR-0019 §3 (Q3 → decidida).
  • REC (candidato): registro del POC de simulación (1 semana de simulation-mode sobre el access log real) — evidencia para cerrar el ADR-0022 sin riesgo de FP.
  • Update CON-CROWDSEC: anotar el veredicto y el pin v1.7.8 (la ficha dice v1.7.1, ya stale) — en el MISMO PR que esta INV (regla de Same-Change de DocOps).