Scripts Infrastructure

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 :

CommandeCe qu’elle fait
cf-syncDaemon 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-syncSynchronise l’allowlist CrowdSec avec les IPs BetterStack + plages Cloudflare (toutes les 15 min)
cf-cleanupNettoyage 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.


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 doctor

5. 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.