Le 2026-10-08, 16.216.88.0/24 (74 adresses qui tournent) et 74.7.227.0/24 ont fait tourner Gitea à 100 % plus d'une heure sur blame/commit/commits/raw. crowdsecurity/http-crawl-non_statics ne pouvait pas les voir : il compte par adresse et tolère 2 req/s soutenues ; la plage ne dépassait pas 9 req/min par adresse, et `.ts` (TypeScript ici) est rangé parmi les fichiers statiques. - arcodange/forge-aspiration : pages git coûteuses sur le routeur gitea, regroupées par /24 ou /64, capacité 60, fuite 4 s ; - arcodange/forge-comparaisons : compare/archive, capacité 15, fuite 60 s (un troisième robot enchaînait une comparaison de ~10 s toutes les 15 s) ; - portée Ip conservée (une alerte par adresse présente dans le seau) : le videur Traefik en mode stream n'applique pas les décisions de plage ; - profil LAPI : 24 h pour ces deux scénarios, les deux profils par défaut recopiés tels quels derrière ; - liste blanche des adresses publiques du foyer (IPv4 et /64 IPv6). Rejoué hors trafic dans un CrowdSec v1.7.0 local, avec le hub copié de l'agent du cluster : voir la PR (débordements, sabotage du seuil, lecteur humain, adresses privées et du foyer). Co-Authored-By: Claude Opus 5.5 <[email protected]>
333 lines
16 KiB
YAML
333 lines
16 KiB
YAML
crowdsec: &crowdsec_config
|
|
# for raw logs format: json or cri (docker|containerd)
|
|
container_runtime: docker
|
|
agent:
|
|
affinity:
|
|
nodeAffinity:
|
|
requiredDuringSchedulingIgnoredDuringExecution:
|
|
nodeSelectorTerms:
|
|
- matchExpressions:
|
|
- key: node-role.kubernetes.io/control-plane
|
|
operator: Exists
|
|
# Specify each pod whose logs you want to process
|
|
acquisition:
|
|
# The namespace where the pod is located
|
|
- namespace: kube-system
|
|
# The pod name
|
|
podName: traefik-*
|
|
# as in crowdsec configuration, we need to specify the program name to find a matching parser
|
|
program: traefik
|
|
env:
|
|
- name: COLLECTIONS
|
|
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/<id>/<id>-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)
|
|
# ou disent autre chose (`k8s-app`, `type`). Ce label n'existe que pour ça.
|
|
podLabels:
|
|
machine-name: crowdsec-lapi
|
|
strategy:
|
|
type: Recreate
|
|
env:
|
|
- name: TZ
|
|
value: Europe/Paris
|
|
# ⚠ NOM DE MACHINE STABLE — NE PAS REVENIR AU NOM DU POD.
|
|
#
|
|
# Le chart amont code en dur `CUSTOM_HOSTNAME` sur `metadata.name`
|
|
# (templates/lapi-deployment.yaml). L'entrypoint s'en sert pour
|
|
# enregistrer la LAPI en base :
|
|
# si .login du fichier d'identifiants == CUSTOM_HOSTNAME et que la
|
|
# machine existe déjà → « Local agent already registered », rien à faire ;
|
|
# sinon → cscli machines delete <CUSTOM_HOSTNAME> puis add.
|
|
# Le `delete` ne porte QUE sur le nom courant : l'enregistrement de
|
|
# l'ancien pod, lui, n'est jamais supprimé.
|
|
#
|
|
# Or le pod change de nom bien plus souvent qu'on ne le croit. Ce n'est pas
|
|
# une histoire de redémarrages — un redémarrage garde le nom. C'est le
|
|
# `rolloutRestartTargets` du VaultDynamicSecret (templates/
|
|
# vaultdynamicsecret.yaml) : à chaque rotation de l'identifiant Postgres,
|
|
# VSO force un rollout, ce qui change le pod template, donc le hash du
|
|
# ReplicaSet, donc le nom du pod.
|
|
#
|
|
# Mesuré le 2026-09-20 : 594 enregistrements `crowdsec-lapi-*` en base
|
|
# depuis le 2025-12-05, pour 571 hashes de ReplicaSet distincts — soit
|
|
# 2,05 par jour, sur 290 jours, et une seule LAPI vivante à la fois.
|
|
# Ils s'entassent dans le PostgreSQL externe de pi2, celui-là même qui a
|
|
# refusé des connexions le 2026-09-20 (SQLSTATE 53300) et fait crasher la
|
|
# LAPI 3 fois.
|
|
#
|
|
# Avec un nom fixe, le premier démarrage fait delete+add sur CE nom, et
|
|
# tous les suivants tombent sur « already registered » — plus aucune
|
|
# écriture. Le fichier d'identifiants survit d'ailleurs aux redémarrages
|
|
# (/etc/crowdsec est un lien vers le PVC crowdsec-config), donc la
|
|
# comparaison sur `.login` tient dans le temps.
|
|
#
|
|
# ⚠ POURQUOI UN fieldRef VERS UN LABEL, ET PAS UN SIMPLE `value:`.
|
|
#
|
|
# Le chart émet sa propre définition de CUSTOM_HOSTNAME AVANT d'injecter
|
|
# `.Values.lapi.env`. On se retrouve donc avec deux entrées de même nom, et
|
|
# au RUNTIME Kubernetes retient bien la dernière — un pod de test le
|
|
# confirme. Mais ce n'est pas le runtime qui décide ici : c'est l'APPLY.
|
|
#
|
|
# `env` est une liste à clé de fusion (`name`). Un strategic merge patch
|
|
# FUSIONNE donc les deux entrées en une seule. Avec un `value:` en face du
|
|
# `valueFrom:` du chart, l'objet fusionné porte les deux, et l'API refuse :
|
|
# Deployment.apps "crowdsec-lapi" is invalid:
|
|
# spec.template.spec.containers[0].env[5].valueFrom: Invalid value: "":
|
|
# may not be specified when `value` is not empty
|
|
# Mesuré le 2026-09-20 : ArgoCD a bouclé cinq fois là-dessus, sync en échec,
|
|
# Deployment inchangé. `helm template` ne voit rien de tout ça — le rendu
|
|
# est parfaitement valide, c'est la fusion avec l'objet vivant qui casse.
|
|
#
|
|
# Avec un `valueFrom` des deux côtés, la fusion écrase proprement le
|
|
# fieldPath et laisse UNE entrée valide. Vérifié par
|
|
# `kubectl apply --dry-run=server -o json` : une seule occurrence, pointant
|
|
# sur metadata.labels['machine-name'].
|
|
#
|
|
# D'où le label `machine-name` posé via lapi.podLabels juste au-dessus : le
|
|
# fieldRef a besoin d'une source stable, et un label dédié se lit mieux que
|
|
# de détourner `k8s-app` ou `type`.
|
|
#
|
|
# ⚠ Ne pas « simplifier » en `value: crowdsec-lapi`. C'est exactement ce qui
|
|
# a échoué, et ça échoue à l'apply, pas au rendu.
|
|
#
|
|
# Ne pas « corriger » non plus en supprimant le rolloutRestartTargets : les
|
|
# identifiants arrivent par secretKeyRef, qui ne se recharge pas à chaud.
|
|
# Sans rollout, la LAPI garderait des identifiants révoqués par Vault.
|
|
- name: CUSTOM_HOSTNAME
|
|
valueFrom:
|
|
fieldRef:
|
|
fieldPath: metadata.labels['machine-name']
|
|
# ⚠ MESURE D'URGENCE (2026-10-08, ~06:40Z) : l'API centrale de CrowdSec
|
|
# (CAPI) REFUSE notre instance — « authenticate watcher (…) : API error:
|
|
# Forbidden », FATAL au démarrage — et le CDN du hub répond 403 lui aussi.
|
|
# Sans LAPI, le videur de Traefik refuse tout *.arcodange.fr. On démarre
|
|
# donc la LAPI SANS l'API centrale : les décisions locales (agent, appsec)
|
|
# tiennent, la liste de blocage communautaire ne descend plus. À rouvrir
|
|
# quand la cause du refus est connue (instance révoquée ? les 594 LAPI
|
|
# fantômes de #56 ? version 1.7.0 retirée ?) : retirer cette variable.
|
|
- name: DISABLE_ONLINE_API
|
|
value: "true"
|
|
# ⚠ PAS d'ENROLL_KEY ici (retirée le 2026-10-08). Le script de démarrage
|
|
# appelle `cscli console enroll` à CHAQUE démarrage quand la clé est posée,
|
|
# et un refus de la console (api.crowdsec.net → 403 Forbidden) TUE le
|
|
# démarrage : la LAPI boucle en CrashLoopBackOff, le videur de Traefik
|
|
# refuse alors TOUT le monde, et tout *.arcodange.fr répond 403 sans corps.
|
|
# C'est arrivé le 2026-10-08 à 06:07Z, sur un simple redémarrage déclenché
|
|
# par la rotation Vault (rolloutRestartTargets ci-dessus). L'instance est
|
|
# déjà enrôlée (l'état vit dans le volume de config) : la ré-enrôler ne
|
|
# sert à rien. Pour un enrôlement neuf, le faire UNE fois à la main
|
|
# (`cscli console enroll --name homelab <clé>` dans le pod), jamais au
|
|
# démarrage — un service tiers ne doit pas pouvoir éteindre la porte.
|
|
- name: DB_USER
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: crowdsec-db-credentials
|
|
key: username
|
|
- name: DB_PASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: crowdsec-db-credentials
|
|
key: password
|
|
appsec:
|
|
enabled: true
|
|
acquisitions:
|
|
- appsec_config: crowdsecurity/appsec-default
|
|
labels:
|
|
type: appsec
|
|
listen_addr: 0.0.0.0:7422
|
|
path: /
|
|
source: appsec
|
|
env:
|
|
- name: TZ
|
|
value: Europe/Paris
|
|
- name: COLLECTIONS
|
|
value: "crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules"
|
|
resources:
|
|
limits:
|
|
cpu: "500m"
|
|
memory: "300Mi"
|
|
requests:
|
|
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
|
|
user: ${DB_USER}
|
|
password: ${DB_PASSWORD}
|
|
db_name: crowdsec
|
|
host: pgbouncer.tools
|
|
port: 5432
|
|
api:
|
|
server:
|
|
auto_registration: # Activate if not using TLS for authentication
|
|
enabled: true
|
|
token: "${REGISTRATION_TOKEN}" # /!\ do not change
|
|
allowed_ranges: # /!\ adapt to the pod IP ranges used by your cluster
|
|
- "127.0.0.1/32"
|
|
- "192.168.0.0/16"
|
|
- "10.42.0.0/16"
|
|
- "172.16.0.0/12"
|
|
|
|
tool:
|
|
# kind: 'SubChart' or 'HelmChart', if subchart then uncomment Chart.yaml dependency, else comment and use tool library with helm chart template
|
|
kind: 'SubChart'
|
|
repo: https://crowdsecurity.github.io/helm-charts
|
|
chart: crowdsec
|
|
version: 0.20.1
|
|
values: *crowdsec_config
|