1. crowdsec-cf-sync.py
Emplacement : /usr/local/bin/crowdsec-cf-sync.py
Service systemd : crowdsec-cf-sync.service
Logs : /var/log/crowdsec/cf-sync.log
Installation
cp crowdsec-cf-sync.py /usr/local/bin/
chmod +x /usr/local/bin/crowdsec-cf-sync.py
systemctl enable crowdsec-cf-sync
systemctl start crowdsec-cf-syncFonctionnalités
- Synchronise les bans CrowdSec actifs → Cloudflare IP Access Rules (tag
crowdsec-local-ban) - Reporte les IPs bannies → AbuseIPDB (fenêtre 48h, dédupliqué)
- Escalade récidivistes : 1er ban CrowdSec gère | 2ème → 24h | 3ème+ → 7j (fenêtre 7j)
- ModSecurity score ≥ 5 → ban CF immédiat 2h (tag
modsec-ban) + report AbuseIPDB - Ban /24 automatique : 2+ IPs du même bloc en 7j → ban CF + CrowdSec 24h (tag
crowdsec-cidr-ban)
Bugs corrigés (avril 2026)
cs_originvsorigindansget_recent_local_bans()— le champ JSON s’appellecs_origin- Pagination API REST
/v1/decisions?limit=1000rate les banscscli→ remplacé parcscli decisions list --origin Items→items(minuscule) dans le parsing JSON de la allowlist CrowdSec
⚠️ Important : les vrais tokens (
CF_API_TOKEN,CF_ZONE_ID,CS_API_KEY,ABUSEIPDB_KEY) doivent être stockés dans/etc/secrets/avecchmod 600et chargés via variables d’environnement, pas hardcodés dans le script. Les valeursVOTRE_*ci-dessous sont des placeholders.
Code source
#!/usr/bin/env python3
"""
CrowdSec → Cloudflare IP Sync + AbuseIPDB Reporter + Recidivist Escalation
+ ModSecurity → CF Ban immédiat (2h) + Ban /24 automatique
1. Synchronise les bans actifs CrowdSec → Cloudflare IP Access Rules
2. Reporte les nouvelles IPs bannies (48h) → AbuseIPDB
3. Escalade les bans des récidivistes : 1er → CrowdSec gère | 2ème → 24h | 3ème+ → 7j
4. ModSecurity score ≥ 5 → ban CF 2h immédiat + report AbuseIPDB
5. Ban /24 automatique si 2+ IPs distinctes du même /24 en 7j → 24h
- Tourne toutes les 60 secondes
"""
import ipaddress
import json
import logging
import re
import subprocess
import time
import urllib.request
import urllib.error
import urllib.parse
from pathlib import Path
from datetime import datetime, timezone, timedelta
# ── Configuration ─────────────────────────────────────────────────
CF_API_TOKEN = "VOTRE_CF_API_TOKEN"
CF_ZONE_ID = "VOTRE_CF_ZONE_ID"
CS_API_KEY = "VOTRE_CS_API_KEY"
ABUSEIPDB_KEY = "VOTRE_ABUSEIPDB_KEY"
ABUSEIPDB_URL = "https://api.abuseipdb.com/api/v2/report"
INTERVAL = 60
NOTE_TAG = "crowdsec-local-ban"
NOTE_TAG_MODSEC = "modsec-ban"
NOTE_TAG_CIDR = "crowdsec-cidr-ban"
LOCAL_ORIGINS = {"crowdsec", "cscli"}
DECISIONS_LOG = Path("/var/log/crowdsec/decisions.log")
NGINX_ERROR_LOG = Path("/var/log/nginx/error.log")
CF_LOG_FILE = Path("/var/log/crowdsec/cf-sync.log")
ABUSE_STATE = Path("/var/log/crowdsec/abuseipdb-reported.json")
RECIDIV_STATE = Path("/var/log/crowdsec/recidivists.json")
MODSEC_STATE = Path("/var/log/crowdsec/modsec-banned.json")
CIDR_STATE = Path("/var/log/crowdsec/cidr-banned.json")
LOOKBACK_HOURS = 48
RECIDIV_WINDOW = 7
CIDR_WINDOW = 7
MODSEC_SCORE_MIN = 5
MODSEC_BAN_SECS = 7200
CIDR_BAN_DURATION = "24h"
CIDR_THRESHOLD = 2
RECIDIV_ESCALATION = {
0: None,
1: "24h",
}
RECIDIV_DEFAULT = "168h"
SCENARIO_CATEGORIES = {
"http-sensitive-files": "21",
"http-probing": "21",
"http-scan": "21,19",
"http-bad-user-agent": "21",
"http-wordpress-scan": "21",
"http-crawl-non_statics": "21",
"http-exploit": "21",
"vpatch-env-access": "21",
"vpatch-git-config": "21",
"ssh-bf": "22",
"ssh-slow-bf": "22",
"ssh-time-based-bf": "22",
"ssh-refused-conn": "22",
"ssh-cve": "22",
"default": "21",
}
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.FileHandler(CF_LOG_FILE), logging.StreamHandler()]
)
log = logging.getLogger(__name__)
def get_crowdsec_allowlist() -> set:
try:
result = subprocess.run(
["cscli", "allowlists", "inspect", "my_allowlist", "-o", "json"],
capture_output=True, text=True, timeout=15
)
if result.returncode != 0:
return set()
data = json.loads(result.stdout)
items = data.get("items", []) or []
return {item.get("value", "") for item in items if item.get("value")}
except Exception as e:
log.warning("Erreur lecture allowlist CrowdSec: %s", e)
return set()
def is_allowlisted(ip_str: str, cs_allowlist: set) -> bool:
if ip_str in cs_allowlist:
return True
try:
ip_obj = ipaddress.ip_address(ip_str)
for entry in cs_allowlist:
try:
net = ipaddress.ip_network(entry, strict=False)
if ip_obj in net:
return True
except ValueError:
pass
except ValueError:
pass
return False
def cf_request(method: str, path: str, data=None) -> dict:
url = f"https://api.cloudflare.com/client/v4{path}"
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(
url, data=body, method=method,
headers={
"Authorization": f"Bearer {CF_API_TOKEN}",
"Content-Type": "application/json",
}
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode())
if not result.get("success"):
raise RuntimeError(f"CF API error: {result.get('errors')}")
return result
except urllib.error.HTTPError as e:
body = e.read().decode()
raise RuntimeError(f"HTTP {e.code} on {method} {path}: {body}") from e
def get_active_bans() -> set:
"""Bans actifs locaux (crowdsec + cscli) via cscli decisions list."""
bans = set()
for origin in LOCAL_ORIGINS:
try:
result = subprocess.run(
["cscli", "decisions", "list", "--origin", origin, "-o", "json"],
capture_output=True, text=True, timeout=15
)
if result.returncode != 0 or not result.stdout.strip():
continue
alerts = json.loads(result.stdout)
if not isinstance(alerts, list):
continue
for alert in alerts:
for dec in alert.get("decisions") or []:
if (dec.get("type") == "ban"
and dec.get("scope", "").lower() == "ip"
and dec.get("value")):
bans.add(dec["value"])
except Exception as e:
log.warning("Erreur cscli decisions list --origin %s: %s", origin, e)
return bans
def main():
log.info("=== CrowdSec CF Sync démarré (interval=%ds) ===", INTERVAL)
cs_allowlist = get_crowdsec_allowlist()
log.info("Allowlist CrowdSec: %d entrées", len(cs_allowlist))
while True:
try:
active_bans = get_active_bans()
log.info("CrowdSec: %d bans actifs", len(active_bans))
# ... (logique complète : sync CF, AbuseIPDB, récidivistes, ModSec, CIDR)
except Exception as e:
log.error("Erreur sync: %s", e, exc_info=True)
time.sleep(INTERVAL)
if __name__ == "__main__":
main()💡 Note : ci-dessus, version condensée pour la lisibilité de l’article. Le script complet (~700 lignes) inclut la gestion fine des récidivistes, ModSecurity, et bans /24. Disponible sur demande ou dans le repo privé.
2. cookie-banner.js
Emplacement : /var/www/grav/user/themes/quark/js/cookie-banner.js
Caractéristiques
- Cookie :
arleo_cookie_consent— flags Secure + SameSite=Strict — durée 365 jours - Bandeau fixe en bas de page, fond sombre, bouton OK
- Lien vers
/privacy-policies - Aucune dépendance externe (~4ko)
- Accessible (ARIA role=dialog)
- Disparaît avec animation opacity au clic OK
Code source
(function () {
'use strict';
var COOKIE_NAME = 'arleo_cookie_consent';
var COOKIE_DURATION = 365; // jours
function getCookie(name) {
var match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? match[2] : null;
}
function setCookie(name, value, days) {
var expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = name + '=' + value
+ '; expires=' + expires.toUTCString()
+ '; path=/'
+ '; SameSite=Strict'
+ '; Secure';
}
function createBanner() {
var banner = document.createElement('div');
banner.id = 'arleo-cookie-banner';
banner.setAttribute('role', 'dialog');
banner.setAttribute('aria-live', 'polite');
banner.setAttribute('aria-label', 'Consentement cookies');
banner.innerHTML =
'<div class="arleo-cookie-inner">' +
'<p class="arleo-cookie-text">' +
'Ce site utilise des cookies de session nécessaires à son fonctionnement. ' +
'<a href="https://www.arleo.eu/privacy-policies" rel="noopener noreferrer">En savoir plus</a>' +
'</p>' +
'<button id="arleo-cookie-accept" aria-label="Accepter les cookies">OK</button>' +
'</div>';
// ... (CSS inline pour éviter une 2e requête HTTP)
document.head.appendChild(style);
document.body.appendChild(banner);
document.getElementById('arleo-cookie-accept').addEventListener('click', function () {
setCookie(COOKIE_NAME, 'accepted', COOKIE_DURATION);
banner.style.transition = 'opacity 0.3s';
banner.style.opacity = '0';
setTimeout(function () {
if (banner.parentNode) {
banner.parentNode.removeChild(banner);
}
}, 300);
});
}
function init() {
if (getCookie(COOKIE_NAME) === 'accepted') {
return;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createBanner);
} else {
createBanner();
}
}
init();
})();3. cloudflare-allowlist-update.py
Cron horaire — Synchronise la liste allowed_ip Cloudflare avec BetterStack + Cloudflare IPs, puis synchronise la allowlist CrowdSec.
Installation cron
0 * * * * /usr/bin/python3 /usr/local/bin/cloudflare-allowlist-update.py >> /var/log/cloudflare-allowlist.log 2>&1Sources synchronisées
| Source | URL |
|---|---|
| BetterStack | https://uptime.betterstack.com/ips.txt |
| Cloudflare IPv4 | https://www.cloudflare.com/ips-v4/ |
| Cloudflare IPv6 | https://www.cloudflare.com/ips-v6/ |
Logique principale
def main():
# 1. Récupérer les IPs déjà dans la liste Cloudflare
existing_cf = get_existing_cf_ips(account_id, list_id)
# 2. Pour chaque source, fetch + diff + ajout
for source_name, url in SOURCES.items():
fetched = fetch_ips(url, source_name)
new_ips = fetched - existing_cf
if new_ips:
add_cf_ips(account_id, list_id, new_ips, source_name)
# 3. Sync vers la allowlist CrowdSec
existing_cs = get_crowdsec_allowlist()
new_cs_ips = all_cf_ips - existing_cs
if new_cs_ips:
add_crowdsec_ips(new_cs_ips, "sync-cloudflare-allowlist")4. cloudflare-cleanup-ip-rules.py
Usage unique — Supprime toutes les règles IP Cloudflare sauf celles taguées easycron.
Utilisé pour nettoyer les 49 974 règles obsolètes (Fail2Ban/UFW/ModSec) en mars 2026.
python3 cloudflare-cleanup-ip-rules.pyLogique :
- Récupération paginée de toutes les règles via API Cloudflare
- Filtrage : garde tout ce qui contient
easycrondans la note - Suppression du reste (rate limit ~1200 req/min, sleep 0.05s entre chaque)
5. Commandes de maintenance
# Statut et logs du service de sync
systemctl status crowdsec-cf-sync
journalctl -fu crowdsec-cf-sync | grep -E "bans|Ajouté|CIDR|ModSec|RÉCIDIVE"
# Voir les récidivistes
cat /var/log/crowdsec/recidivists.json | python3 -c "
import json,sys
d=json.load(sys.stdin)
for ip,v in sorted(d.items(), key=lambda x: x[1]['count'], reverse=True):
print(f'{ip:20} count={v[\"count\"]} last={v[\"last_seen\"]}')
"
# Vérifier les bans actifs CrowdSec
cscli decisions list --origin cscli
cscli decisions list --origin crowdsec
# Vérifier les IPs dans Cloudflare par tag
# Via dashboard CF → Sécurité → Règles d'accès IP6. Sécurité — Stockage des secrets
⚠️ Important : tous les vrais tokens ont été retirés de cet article. Les valeurs
VOTRE_CF_API_TOKEN,VOTRE_CF_ZONE_ID,VOTRE_CS_API_KEY,VOTRE_ABUSEIPDB_KEYsont des placeholders. En production, ces tokens sont stockés dans/etc/secrets/avecchmod 600et chargés viaos.getenv()au démarrage de chaque script.
# Création du répertoire des secrets
sudo mkdir -p /etc/secrets
sudo chown root:root /etc/secrets
sudo chmod 700 /etc/secrets
# Fichier des tokens
sudo tee /etc/secrets/crowdsec-cf-sync.env <<'EOF'
CF_API_TOKEN=...
CF_ZONE_ID=...
CS_API_KEY=...
ABUSEIPDB_KEY=...
EOF
sudo chmod 600 /etc/secrets/crowdsec-cf-sync.envPuis dans le service systemd :
[Service]
EnvironmentFile=/etc/secrets/crowdsec-cf-sync.env
ExecStart=/usr/bin/python3 /usr/local/bin/crowdsec-cf-sync.py