Cette page recense les scripts et outils qui font tourner l’infrastructure d’arleo.eu — ce qui est réellement utilisé aujourd’hui en premier, l’historique Python ensuite. Le détail architecture/service systemd de la partie CrowdSec ↔ Cloudflare est dans Documentation Infrastructure ; cette page-ci se concentre sur des exemples concrets, téléchargeables. Aucun token réel n’apparaît sur cette page — placeholders uniquement, secrets toujours hors dépôt.
1. Actuellement utilisés
security-automation-go — orchestration CrowdSec ↔ Cloudflare
Dépôt : github.com/jmrGrav/security-automation-go (Go, Apache-2.0, actif)
Remplace l’ensemble des scripts Python de la section 2 ci-dessous. Détail complet (architecture, services systemd, commandes de vérification) dans Documentation Infrastructure §2 — en bref :
| Commande | Ce qu’elle fait |
|---|---|
cf-sync | Daemon principal : synchronise les bans CrowdSec → Cloudflare, reporte à AbuseIPDB (ban gated par corroboration locale, pas juste le score de réputation), gère le cycle de vie complet des bans (expiration auto), et sert la console opérateur web |
cf-allowlist-sync | Synchronise l’allowlist CrowdSec avec les IPs BetterStack + plages Cloudflare (toutes les 15 min) |
cf-cleanup | Nettoyage des règles d’accès IP Cloudflare obsolètes |
Installation, releases .deb, et assistant de configuration (credentials chiffrés, aucun token en clair sur disque) : voir le README du projet.
csp-monitor.sh — surveillance et auto-réparation CSP
Cron : */15 * * * * /home/jm/scripts/csp-monitor.sh · Sortie : 0 OK / 1 WARN / 2 ALERT
Détecte un header CSP manquant ou incomplet, un pic de violations sur 15 min, un nouveau pattern de violation jamais vu, une régression (script inline produit par Hugo — devrait toujours être 0), et vérifie une fois par jour la note Mozilla Observatory. Se répare tout seul dans deux cas : rollback automatique si openresty -t échoue (vers le dernier .bak-*), et purge du cache Cloudflare si une directive CSP attendue manque de la réponse observée (généralement un cache CF périmé, pas un vrai problème de config). Alertes et heartbeat via BetterStack, tokens Cloudflare déchiffrés à la volée via age — jamais en clair sur disque.
#!/bin/bash
# csp-monitor.sh — Détection ET réparation automatique CSP sur www.arleo.eu
# Cron : */15 * * * * /home/jm/scripts/csp-monitor.sh >> /var/log/csp-monitor.log 2>&1
#
# Détecte :
# 1. Header CSP manquant ou directives critiques manquantes (default-src 'none' / frame-ancestors)
# 2. Burst de violations sur les 15 dernières minutes
# 3. Apparition d'un nouveau type de violation (jamais vu avant)
# 4. Régression : inline scripts depuis l'origine (Hugo doit produire 0)
# 5. Score Mozilla Observatory < A+ (1x/jour)
#
# Répare automatiquement :
# A. Si openresty -t échoue → rollback vers le dernier .bak-*
# B. Si directive CSP manquante → purge CF cache
# C. (Info uniquement) Si CF injecte script — exige action manuelle dashboard
#
# Sortie : 0 = OK, 1 = WARN, 2 = ALERT
# Alertes via BETTERSTACK_WEBHOOK env var si défini.
# Heartbeat via BETTERSTACK_HEARTBEAT env var si défini.
set -euo pipefail
LOG_FILE="/var/log/nginx/csp-violations.log"
STATE_DIR="/var/lib/csp-monitor"
KNOWN_PATTERNS_FILE="${STATE_DIR}/known-patterns.txt"
AGE_KEY_FILE="${STATE_DIR}/age-key.txt"
CF_TOKEN_AGE="${STATE_DIR}/cf-token.age"
CF_ZONE_AGE="${STATE_DIR}/cf-zone.age"
WEBHOOK_URL="${BETTERSTACK_WEBHOOK:-}"
HEARTBEAT_URL="${BETTERSTACK_HEARTBEAT:-}"
SITE="https://www.arleo.eu"
LOCK_FILE="/run/lock/csp-monitor.lock"
REQUIRED_DIRECTIVES=(
"default-src 'none'"
"frame-ancestors 'self'"
"object-src 'none'"
"base-uri 'self'"
"form-action 'self'"
"upgrade-insecure-requests"
"worker-src 'self' https://cdn.jsdelivr.net"
)
BURST_THRESHOLD=20
WARN_THRESHOLD=5
ALERT_LEVEL=0
ALERTS=()
log() { printf '[%s] %s\n' "$(date -Iseconds)" "$*"; }
alert() { ALERT_LEVEL=2; ALERTS+=("ALERT: $*"); log "ALERT: $*"; }
warn() { (( ALERT_LEVEL < 1 )) && ALERT_LEVEL=1; ALERTS+=("WARN: $*"); log "WARN: $*"; }
info() { log "INFO: $*"; }
exec 9>"$LOCK_FILE"
flock -n 9 || { log "Another instance is running — skipping"; exit 0; }
mkdir -p "$STATE_DIR"
chmod 700 "$STATE_DIR"
touch "$KNOWN_PATTERNS_FILE"
chmod 600 "$KNOWN_PATTERNS_FILE"
OPENRESTY_BIN="/usr/bin/openresty"
if sudo "$OPENRESTY_BIN" -t >/dev/null 2>&1; then
sudo /usr/local/bin/csp-backup.sh 2>/dev/null || true
fi
if ! sudo "$OPENRESTY_BIN" -t >/dev/null 2>&1; then
alert "openresty -t a échoué — config CSP invalide, rollback automatique en cours"
restored=$(sudo /usr/local/bin/csp-restore.sh 2>/dev/null || true)
if [[ -n "$restored" ]]; then
if sudo "$OPENRESTY_BIN" -t >/dev/null 2>&1; then
sudo /bin/systemctl reload openresty
info "Rollback réussi vers $restored"
else
alert "Rollback FAILED — vhost reste invalide, intervention manuelle requise"
fi
else
alert "Aucun backup disponible — intervention manuelle requise"
fi
fi
csp_header=$(curl -sk -I "$SITE/" 2>/dev/null | grep -i '^content-security-policy:' | tr -d '\r' || true)
if [[ -z "$csp_header" ]]; then
alert "Aucun header Content-Security-Policy servi par $SITE"
else
csp_ok=1
for directive in "${REQUIRED_DIRECTIVES[@]}"; do
if ! grep -qF "$directive" <<<"$csp_header"; then
warn "Directive CSP manquante : $directive"
csp_ok=0
fi
done
if (( csp_ok == 0 )); then
warn "CSP diverge de la config attendue — tentative de purge CF cache"
if [[ -r "$CF_TOKEN_AGE" && -r "$CF_ZONE_AGE" && -r "$AGE_KEY_FILE" ]]; then
cf_token=$(age -d -i "$AGE_KEY_FILE" "$CF_TOKEN_AGE" 2>/dev/null || true)
cf_zone=$(age -d -i "$AGE_KEY_FILE" "$CF_ZONE_AGE" 2>/dev/null || true)
if [[ -n "$cf_token" && -n "$cf_zone" ]]; then
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${cf_zone}/purge_cache" \
-H "Authorization: Bearer ${cf_token}" -H "Content-Type: application/json" \
--data '{"purge_everything":true}' >/dev/null
info "Purge CF déclenchée — re-vérification dans le prochain run"
fi
else
warn "Credentials CF manquants — purge skipped"
fi
fi
fi
since=$(date -d '15 minutes ago' -Iseconds)
recent_count=$(awk -v since="$since" '
match($0, /"timestamp":"[^"]+"/) {
ts = substr($0, RSTART+13, RLENGTH-14)
if (ts >= since) print
}
' "$LOG_FILE" 2>/dev/null | wc -l || echo 0)
if (( recent_count > BURST_THRESHOLD )); then
alert "Burst CSP violations : $recent_count en 15 min (seuil $BURST_THRESHOLD)"
elif (( recent_count > WARN_THRESHOLD )); then
warn "Violations CSP élevées : $recent_count en 15 min (seuil $WARN_THRESHOLD)"
fi
recent_patterns=$(awk -v since="$since" '
match($0, /"timestamp":"[^"]+"/) {
ts = substr($0, RSTART+13, RLENGTH-14)
if (ts < since) next
d = ""; b = ""
if (match($0, /"directive":"[^"]*"/)) d = substr($0, RSTART+13, RLENGTH-14)
if (match($0, /"blocked_uri":"[^"]*"/)) b = substr($0, RSTART+15, RLENGTH-16)
if (d != "" || b != "") print d "|" b
}
' "$LOG_FILE" 2>/dev/null | sort -u || true)
new_patterns=""
while IFS= read -r pattern; do
[[ -z "$pattern" ]] && continue
if ! grep -qxF "$pattern" "$KNOWN_PATTERNS_FILE"; then
new_patterns+="$pattern"$'\n'
echo "$pattern" >> "$KNOWN_PATTERNS_FILE"
fi
done <<<"$recent_patterns"
if [[ -n "$new_patterns" ]]; then
warn "Nouveau(x) pattern(s) CSP :"$'\n'"$new_patterns"
fi
pattern_count=$(wc -l < "$KNOWN_PATTERNS_FILE" || echo 0)
if (( pattern_count > 1000 )); then
tail -1000 "$KNOWN_PATTERNS_FILE" > "${KNOWN_PATTERNS_FILE}.tmp"
mv "${KNOWN_PATTERNS_FILE}.tmp" "$KNOWN_PATTERNS_FILE"
fi
inline_count=$(curl -sk --resolve www.arleo.eu:443:127.0.0.1 "$SITE/" 2>/dev/null \
| python3 -c "
import sys, re
html = sys.stdin.read()
pattern = re.compile(
r'<script'
r'(?![^>]*\bsrc\s*=)'
r'(?![^>]*\btype\s*=\s*[\"\'']?(?:application/ld\+json|application/json|module)[\"\'']?)'
r'[^>]*>',
re.IGNORECASE
)
print(len(pattern.findall(html)))
" 2>/dev/null || echo "0")
if (( inline_count > 0 )); then
warn "Hugo origine produit $inline_count inline script(s) — devrait être 0"
fi
cf_inline=$(curl -sk "$SITE/" 2>/dev/null | grep -c '__CF\$cv\|challenge-platform' || true)
if (( cf_inline > 0 )); then
info "CF injecte $cf_inline script(s) au edge — désactiver : Dashboard CF → Security → Bots → JS Detections → Off"
fi
MDN_CACHE="${STATE_DIR}/mdn-score.txt"
if [[ ! -f "$MDN_CACHE" ]] || find "$MDN_CACHE" -mtime +1 -print 2>/dev/null | grep -q .; then
grade=$(curl -s -X POST "https://observatory-api.mdn.mozilla.net/api/v2/scan?host=www.arleo.eu" 2>/dev/null \
| python3 -c "import sys,json
try: print(json.load(sys.stdin).get('grade','?'))
except: print('?')" 2>/dev/null || echo "?")
echo "$grade" > "$MDN_CACHE"
if [[ "$grade" != "A+" && "$grade" != "?" ]]; then
warn "MDN Observatory dégradé : $grade (attendu A+)"
else
info "MDN Observatory grade = $grade"
fi
fi
if (( ALERT_LEVEL > 0 )) && [[ -n "$WEBHOOK_URL" ]]; then
payload=$(printf '%s\n' "${ALERTS[@]}" | python3 -c "import sys,json; print(json.dumps({'text': sys.stdin.read()}))")
curl -s -X POST "$WEBHOOK_URL" -H 'Content-Type: application/json' --data "$payload" >/dev/null || true
fi
if [[ -n "$HEARTBEAT_URL" ]]; then
curl -s "$HEARTBEAT_URL" >/dev/null || true
fi
exit "$ALERT_LEVEL"2. Legacy — anciens scripts Python (retirés)
Tous remplacés en production par security-automation-go (section 1). Service Python arrêté depuis le 9 juin 2026 — conservés ici uniquement en référence historique, à ne pas réinstaller.
crowdsec-cf-sync.py
Synchronisait les bans CrowdSec actifs vers Cloudflare, reportait à AbuseIPDB, gérait l’escalade des récidivistes et un ban immédiat 2h sur score ModSecurity ≥ 5 (le WAF local a lui-même depuis migré vers CrowdSec AppSec — ModSecurity n’est plus le WAF en place).
⚠️ Les valeurs
VOTRE_*ci-dessous sont des placeholders, jamais des tokens réels. Version condensée pour lisibilité — le script complet faisait ~700 lignes.
#!/usr/bin/env python3
"""
CrowdSec → Cloudflare IP Sync + AbuseIPDB Reporter + Recidivist Escalation
+ ModSecurity → CF Ban immédiat (2h) + Ban /24 automatique
"""
import ipaddress
import json
import logging
import subprocess
import time
import urllib.request
import urllib.error
from pathlib import Path
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"}
CF_LOG_FILE = Path("/var/log/crowdsec/cf-sync.log")
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"
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 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:
raise RuntimeError(f"HTTP {e.code} on {method} {path}: {e.read().decode()}") 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()cloudflare-allowlist-update.py
Cron horaire — synchronisait allowed_ip Cloudflare avec BetterStack + plages Cloudflare IPv4/IPv6, puis la allowlist CrowdSec. Remplacé par cf-allowlist-sync (section 1), désormais toutes les 15 min au lieu d’une fois par heure.
SOURCES = {
"betterstack": "https://uptime.betterstack.com/ips.txt",
"cloudflare_v4": "https://www.cloudflare.com/ips-v4/",
"cloudflare_v6": "https://www.cloudflare.com/ips-v6/",
}
def main():
existing_cf = get_existing_cf_ips(account_id, list_id)
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)
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")cloudflare-cleanup-ip-rules.py
Usage unique — a supprimé les ~49 974 règles d’accès IP Cloudflare obsolètes (Fail2Ban/UFW/ModSecurity) accumulées avant mars 2026, en conservant tout ce qui portait easycron en note. Équivalent actuel : cf-cleanup (section 1), disponible en continu plutôt qu’à usage unique.
3. cookie-banner.js — Grav, retiré
Ce script venait du thème Grav (quark) d’avant la migration vers Hugo. Le chemin /var/www/grav/user/themes/quark/js/cookie-banner.js n’existe plus depuis la bascule Hugo — le thème actuel (LoveIt) ne le reprend pas, et le site ne sert aujourd’hui aucune bannière de consentement cookies (pas de cookie non essentiel posé). Conservé ci-dessous uniquement comme exemple de code réutilisable — pas comme description de ce qui tourne sur arleo.eu aujourd’hui.
(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>';
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();
})();4. Commandes de maintenance (actuelles)
# Statut et logs
systemctl status cf-sync
systemctl status cf-allowlist-sync.timer
journalctl -fu cf-sync
# Console opérateur (loopback uniquement)
curl -s http://127.0.0.1:9091/ # depuis le NUC lui-même
# Vérifier les bans actifs CrowdSec
cscli decisions list --origin cscli
cscli decisions list --origin crowdsec
# Diagnostic intégré
cf-sync -mode doctor5. Sécurité — stockage des secrets
security-automation-go chiffre les credentials en base (SQLite, AES-GCM) via son assistant de configuration au premier lancement — plus de fichier .env en clair à gérer pour ce composant. csp-monitor.sh (section 1) déchiffre ses tokens Cloudflare à la volée via age, jamais en clair sur disque.
⚠️ Aucun token réel n’apparaît sur cette page. Toute valeur
VOTRE_*dans les scripts legacy ci-dessus est un placeholder.