diff --git a/crowdsec/values.yaml b/crowdsec/values.yaml index 65fe23c..10c5d36 100644 --- a/crowdsec/values.yaml +++ b/crowdsec/values.yaml @@ -22,6 +22,34 @@ crowdsec: &crowdsec_config value: "crowdsecurity/traefik crowdsecurity/http-cve" - name: TZ value: Europe/Paris + # ⚠ L'AGENT NE LISAIT AUCUNE LIGNE DE TRAEFIK — mesuré le 2026-10-08. + # + # Sur pi1, Docker (le runtime de k3s ici) a sa racine de données sous + # /mnt/arcodange/docker. Le journal d'un conteneur se lit par une chaîne de + # liens : + # /var/log/containers/traefik-…log + # → /var/log/pods/kube-system_traefik-…/traefik/0.log + # → /mnt/arcodange/docker/containers//-json.log + # Le chart ne monte que /var/log et /var/lib/docker/containers (l'ancienne + # racine, plus écrite depuis le 2026-04-07). Le dernier lien pendait donc + # dans le pod : `head` sur le lien → « No such file or directory », les 54 + # journaux de /var/log/pods pointent tous sous /mnt/arcodange/docker, et la + # métrique `cs_filesource_hits_total` n'existait même pas (zéro ligne lue). + # Aucun scénario HTTP ne pouvait donc mordre, quel que soit le trafic. + # + # On monte la vraie racine, en lecture seule, AU MÊME CHEMIN : c'est le + # noyau qui résout le lien dans l'espace de montage du pod, le chemin doit + # donc exister tel quel. Si la racine Docker de pi1 bouge encore, ce + # montage doit suivre (relever `readlink` d'un 0.log de /var/log/pods). + extraVolumes: + - name: racine-docker-pi1 + hostPath: + path: /mnt/arcodange/docker/containers + type: Directory + extraVolumeMounts: + - name: racine-docker-pi1 + mountPath: /mnt/arcodange/docker/containers + readOnly: true lapi: # Source stable pour le CUSTOM_HOSTNAME défini plus bas : un fieldRef ne sait # lire qu'un champ du pod, et tous ceux que le chart expose varient (le nom) @@ -153,6 +181,129 @@ crowdsec: &crowdsec_config cpu: "100m" memory: "200Mi" config: + # ── Aspiration de la forge publique (gitea.arcodange.fr) ────────────────── + # Le 2026-10-08, deux plages (16.216.88.0/24, 74 adresses qui tournent, et + # 74.7.227.0/24) ont fait tourner Gitea (pi2, 4 cœurs) à 100 % pendant plus + # d'une heure sur blame/commit/commits/raw, à ~1 req/s, 3,8 s de Gitea par + # requête. `crowdsecurity/http-crawl-non_statics` ne pouvait pas les voir : + # il compte PAR ADRESSE et tolère 2 req/s soutenues (fuite 0,5 s), alors que + # la plage la plus lourde ne dépassait jamais 9 req/min par adresse ; et il + # range les `.ts` (TypeScript, ici) parmi les fichiers statiques. + # + # D'où deux seaux qui fuient, réservés au routeur de la forge : + # - ils ne comptent que les pages qui font travailler git ; + # - ils regroupent par /24 (IPv4) ou /64 (IPv6), pour voir le robot qui + # répartit sa charge sur une plage ; + # - mais leur PORTÉE reste l'adresse (scope Ip, la valeur par défaut) : + # au débordement, CrowdSec émet une alerte — donc un ban — par adresse + # présente dans le seau. Ne PAS passer en `scope: Range` : le videur de + # Traefik (plugin crowdsec-bouncer-traefik, mode stream) ne range ses + # décisions que par valeur exacte, un ban de plage n'y bloque personne + # (mesuré le 2026-10-08 sur les bans manuels des deux plages). + # - les réponses 403 (déjà bannies par le videur) ne sont pas recomptées, + # pour laisser la place, au débordement suivant, aux adresses de la même + # plage qui passent encore. + # Seuils : une personne qui lit l'historique fait quelques pages par minute + # et n'approche pas 60 d'affilée au-dessus de 15/min ; le robot le plus lent + # du 2026-10-08 (24 req/min) déborde en ~7 min, la plage (51 req/min) en + # moins de 2 min. Les rejeux qui le prouvent (et le sabotage du seuil) sont + # dans la PR qui a introduit ces lignes : `git log -S forge-aspiration`. + scenarios: + forge-aspiration.yaml: | + type: leaky + name: arcodange/forge-aspiration + description: "Aspiration des pages git couteuses de la forge (src/commit, commits, blame, raw...), comptee par /24 ou /64" + filter: | + evt.Meta.log_type == 'http_access-log' + && evt.Meta.traefik_router_name contains 'gitea' + && evt.Parsed.verb in ['GET', 'HEAD'] + && evt.Meta.http_status != '403' + && evt.Parsed.request matches '^/[^/]+/[^/]+/(src/(commit|branch|tag)|commits|commit|blame|compare|raw|archive|media|graph|activity|lastcommit)(/|$)' + groupby: "IpToRange(evt.Meta.source_ip, IsIPV4(evt.Meta.source_ip) ? '/24' : '/64')" + capacity: 60 + leakspeed: 4s + blackhole: 1m + labels: + service: http + behavior: "http:crawl" + label: "Aspiration de la forge" + confidence: 2 + spoofable: 0 + classification: + - attack.T1595 + remediation: true + # Une comparaison (`/compare/a...b`) ou une archive coûte à elle seule ~10 s + # de Gitea : le 2026-10-08, un troisième robot en enchaînait une toutes les + # 15 s (dont 3 sur 4 abandonnées avant la réponse, statut 499) — trop lent + # pour le seau du dessus, assez pour occuper un cœur en permanence. + forge-comparaisons.yaml: | + type: leaky + name: arcodange/forge-comparaisons + description: "Rafale de comparaisons ou d'archives sur la forge, comptee par /24 ou /64" + filter: | + evt.Meta.log_type == 'http_access-log' + && evt.Meta.traefik_router_name contains 'gitea' + && evt.Parsed.verb in ['GET', 'HEAD'] + && evt.Meta.http_status != '403' + && evt.Parsed.request matches '^/[^/]+/[^/]+/(compare|archive)(/|$)' + groupby: "IpToRange(evt.Meta.source_ip, IsIPV4(evt.Meta.source_ip) ? '/24' : '/64')" + capacity: 15 + leakspeed: 60s + blackhole: 1m + labels: + service: http + behavior: "http:crawl" + label: "Comparaisons en rafale sur la forge" + confidence: 2 + spoofable: 0 + classification: + - attack.T1595 + remediation: true + parsers: + s02-enrich: + # `crowdsecurity/whitelists` couvre déjà 10/8, 172.16/12, 192.168/16 et + # 127/8 (les runners de CI et ArgoCD passent par gitea.arcodange.lab, + # donc par une adresse privée). On y ajoute les adresses PUBLIQUES du + # foyer, par lesquelles on revient quand on passe par Cloudflare. + # ⚠ Toutes deux sont dynamiques (relevées le 2026-07-28, voir + # doc/ce-que-traefik-voit.md) : si elles changent, c'est ici. + foyer-whitelist.yaml: | + name: arcodange/foyer-whitelist + description: "Adresses publiques du foyer : jamais bannies" + whitelist: + reason: "adresse publique du foyer (homelab)" + ip: + - "86.238.234.54" + cidr: + - "2a01:cb04:dff:cf00::/64" + # Les deux premiers profils de l'image, recopiés tels quels, précédés d'un + # profil qui bannit 24 h (au lieu de 4 h) ce que la forge a vu aspirer : un + # robot d'aspiration revient, et chaque retour coûte un débordement complet. + # ⚠ Ce fichier REMPLACE celui de l'image : un profil retiré d'ici n'existe plus. + profiles.yaml: | + name: forge_aspiration_24h + filters: + - Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() startsWith "arcodange/forge-" + decisions: + - type: ban + duration: 24h + on_success: break + --- + name: default_ip_remediation + filters: + - Alert.Remediation == true && Alert.GetScope() == "Ip" + decisions: + - type: ban + duration: 4h + on_success: break + --- + name: default_range_remediation + filters: + - Alert.Remediation == true && Alert.GetScope() == "Range" + decisions: + - type: ban + duration: 4h + on_success: break config.yaml.local: | db_config: type: postgresql diff --git a/doc/ce-que-traefik-voit.md b/doc/ce-que-traefik-voit.md index 4f3d474..dc18ea9 100644 --- a/doc/ce-que-traefik-voit.md +++ b/doc/ce-que-traefik-voit.md @@ -211,6 +211,26 @@ mais à l'IP résolue. La convention `.fr` (« crowdsec devant tout ») tient do ce qu'elle promet. Écrit ici pour que personne ne « corrige » cette ligne en croyant reproduire le piège A. +> ⚠ **Mise à jour du 2026-10-08 — juger la bonne IP ne suffisait pas.** Deux +> limites que ce constat ne pouvait pas voir, mesurées pendant l'aspiration de +> la forge : +> +> 1. **L'agent ne lisait rien.** Les journaux de conteneurs de pi1 pointent sous +> `/mnt/arcodange/docker` (racine Docker déplacée), que l'agent ne montait +> pas : `Error setting up tail for /var/log/containers/traefik-…` à chaque +> démarrage, aucune ligne lue, aucun scénario HTTP n'a jamais débordé. +> Corrigé dans `crowdsec/values.yaml` (`agent.extraVolumes`). +> 2. **Un ban de plage n'est pas appliqué.** En mode `stream`, le plugin +> (v1.3.3 ici, et toujours sur sa branche principale) range chaque décision +> par sa valeur exacte et cherche l'adresse du client telle quelle : +> `cscli decisions add --range 16.216.88.0/24` laissait passer +> `16.216.88.122` (`isBanned:false cache:miss`). Les scénarios maison +> gardent donc une portée **Ip**, et un ban manuel de plage se fait adresse +> par adresse (`cscli decisions import`). Le mode `live` interrogerait la +> LAPI (`/v1/decisions?ip=…`), qui, elle, rend la décision de plage pour une +> adresse qu'elle contient (vérifié sur une LAPI v1.7.0 locale) — au prix +> d'un appel par adresse nouvelle. + --- **Où vit quoi** (aucun de ces réglages n'est dans ce dépôt — vérifié) :