Sécuriser un serveur dédié Linux Debian 12 — Guide complet post-incident

Un mardi matin ordinaire. Je jette un oeil aux logs Apache avant de commencer à travailler — réflexe conditionné, rarement utile. Sauf ce matin-là. Dans error.log, des centaines de lignes identiques, toutes depuis la même IP : 13.37.248.113. Des tentatives d'authentification HTTP Digest en boucle, combinant des noms d'utilisateurs courants avec des mots de passe génériques.

Le serveur a tenu. L'auth Digest avec un mot de passe correct est une barrière solide contre le brute force si le mot de passe tient la route. Mais l'incident a quand même déclenché un audit complet que j'avais repoussé depuis trop longtemps.

Ce serveur héberge une seedbox privée partagée avec une vingtaine d'utilisateurs, un site web PHP derrière Apache avec authentification HTTP Digest, des accès SFTP via ProFTPD, un wiki en Docker derrière un reverse proxy Apache, et Jellyfin pour le streaming. Un périmètre classique pour un serveur personnel semi-professionnel. Voici tout ce qui a été revu, corrigé, et automatisé.

1. Détection et réponse à une attaque brute force

Lire les logs Apache pour identifier les tentatives

L'authentification HTTP Digest d'Apache trace ses échecs dans error.log, pas dans access.log. Les codes à chercher sont AH01790 et AH01794.

# Compter les tentatives échouées par IP sur les dernières 24h
grep "AH0179" /var/log/apache2/error.log | grep -oP '\[client \K[0-9.]+' | sort | uniq -c | sort -rn | head -20

# Voir les lignes complètes pour une IP spécifique
grep "13.37.248.113" /var/log/apache2/error.log | tail -30

Les lignes de log ressemblent à ça :

[Tue Feb 18 07:23:41.412893 2026] [auth_digest:error] [pid 1234] [client 13.37.248.113:58432] AH01790: user admin: password mismatch: /protected/
[Tue Feb 18 07:23:41.718204 2026] [auth_digest:error] [pid 1234] [client 13.37.248.113:58433] AH01794: user root in realm "Private Area" not found: /protected/
[Tue Feb 18 07:23:42.091337 2026] [auth_digest:error] [pid 1234] [client 13.37.248.113:58434] AH01790: user administrator: password mismatch: /protected/

Trois patterns distincts dans ces logs : password mismatch (utilisateur connu, mauvais mot de passe), not found (utilisateur inexistant), et parfois nonce mismatch (rejeu de challenge expiré). L'attaquant testait clairement une liste de couples login/mdp génériques.

Identifier l'attaquant

# Whois rapide
whois 13.37.248.113

# Geolocalisation sans installer quoi que ce soit
curl -s https://ipinfo.io/13.37.248.113/json

Résultat : IP appartenant à Amazon AWS eu-west-3 (Paris). Classique. Les VPS AWS bon marché sont utilisés en masse pour ce genre d'opérations parce qu'ils sont faciles à créer, difficiles à tracer jusqu'à la personne réelle, et souvent peu surveillés. L'IP a depuis été rapportée sur AbuseIPDB avec une soixantaine de signalements.

Vérifier l'absence d'intrusion

Avant de faire quoi que ce soit d'autre, vérifier que rien n'a réussi à entrer. Le brute force avait peut-être trouvé quelque chose avant qu'on s'en aperçoive.

# Connexions SSH réussies (chercher des logins inattendus)
grep "Accepted" /var/log/auth.log | tail -50

# Tentatives SSH échouées depuis la même IP
grep "13.37.248.113" /var/log/auth.log

# Activité SFTP (ProFTPD log)
grep "13.37.248.113" /var/log/proftpd/proftpd.log 2>/dev/null

# Vérifier les timestamps des fichiers sensibles
stat /etc/passwd /etc/shadow /etc/sudoers
ls -la /root/.ssh/
ls -la /home/

# Chercher des fichiers modifiés récemment dans /etc (dernières 24h)
find /etc -newer /tmp/ref_file -ls 2>/dev/null
# Créer le fichier de référence d'abord : touch -d "24 hours ago" /tmp/ref_file

Dans ce cas précis, rien. L'attaque s'était limitée à HTTP Digest, sans toucher SSH. Mais ça ne change rien à la nécessité de fermer les points faibles qui auraient pu être exploités.

2. fail2ban — Protection contre le brute force

fail2ban analyse les logs et bannit les IPs qui dépassent un seuil de tentatives. La config par défaut est un bon point de départ, mais elle a des lacunes importantes sur ce setup précis.

apt install fail2ban
systemctl enable fail2ban

Toutes les personnalisations vont dans /etc/fail2ban/jail.local (ne jamais modifier jail.conf directement — il sera écrasé lors des mises à jour).

Jail SSH

# /etc/fail2ban/jail.local
[DEFAULT]
bantime  = 86400    ; 24h
findtime = 600      ; fenêtre de détection : 10 minutes
maxretry = 5
banaction = iptables-multiport

[sshd]
enabled  = true
port     = ssh
logpath  = %(sshd_log)s
backend  = %(sshd_backend)s
maxretry = 5

Jail apache-auth — le piège du filtre par défaut

fail2ban inclut un filtre apache-auth par défaut. Il ne détecte pas les erreurs d'authentification HTTP Digest d'Apache 2.4. Le filtre par défaut cherche des patterns comme Authorization Required ou des erreurs Basic Auth — pas les AH01790 / AH01794 du module auth_digest.

Il faut créer un filtre custom :

# /etc/fail2ban/filter.d/apache-auth.local
[Definition]
failregex = \[client <HOST>:.*\] AH01790: user .+: password mismatch
            \[client <HOST>:.*\] AH01794: user .+ in realm .+ not found
            \[client <HOST>:.*\] AH01788: .+nonce from .+ received on .+ - not found

Puis la jail correspondante dans jail.local :

[apache-auth]
enabled  = true
filter   = apache-auth
port     = http,https
logpath  = /var/log/apache2/error.log
maxretry = 8
bantime  = 86400
findtime = 300

Pour tester le filtre avant de l'activer en production :

# Tester le filtre contre un extrait de log réel
fail2ban-regex /var/log/apache2/error.log /etc/fail2ban/filter.d/apache-auth.local --print-all-matched

Jail ProFTPD

[proftpd]
enabled  = true
port     = ftp,ftp-data,ftps,ftps-data
logpath  = /var/log/proftpd/proftpd.log
maxretry = 6
bantime  = 86400

Vérifier l'état des jails

# Vue d'ensemble
fail2ban-client status

# Détail d'une jail spécifique
fail2ban-client status apache-auth
fail2ban-client status sshd

# Débannir une IP manuellement (si tu te bans toi-même)
fail2ban-client set sshd unbanip 1.2.3.4

# Logs fail2ban
tail -f /var/log/fail2ban.log

3. SSH/SFTP — Restreindre les accès

Une vingtaine d'utilisateurs ont accès SFTP pour déposer et récupérer des fichiers. Aucun d'entre eux n'a besoin d'un shell SSH complet. C'est une surface d'attaque inutile.

Groupe sftponly et ChrootDirectory

# Créer le groupe
groupadd sftponly

# Ajouter les utilisateurs existants
usermod -aG sftponly alice
usermod -aG sftponly bob
# etc.

Dans /etc/ssh/sshd_config, ajouter ce bloc à la fin (il doit être après toute directive Match existante) :

Match Group sftponly
    ForceCommand internal-sftp -l INFO -f AUTH
    ChrootDirectory %h
    AllowTcpForwarding no
    X11Forwarding no
    PermitTunnel no
    AllowAgentForwarding no

Le piège du chroot. La directive ChrootDirectory impose une contrainte sévère et contre-intuitive : le répertoire racine du chroot doit appartenir à root:root avec des permissions 755. Si c'est la home directory de l'utilisateur et qu'elle lui appartient, SSH refuse la connexion silencieusement — l'utilisateur voit juste une erreur de connexion sans aucune explication côté client.

# Structure correcte pour un chroot SFTP
# Le home doit être root:root 755
ls -la /home/alice/
# drwxr-xr-x  3 root  root  4096 ...

# Les sous-dossiers appartiennent à l'utilisateur
ls -la /home/alice/downloads/
# drwxr-xr-x  2 alice alice 4096 ...

# Corriger les permissions si nécessaire
chown root:root /home/alice
chmod 755 /home/alice

# L'utilisateur doit quand même pouvoir écrire quelque part
mkdir -p /home/alice/uploads
chown alice:alice /home/alice/uploads
chmod 755 /home/alice/uploads

Logging SFTP détaillé

Par défaut, les transferts SFTP ne sont pas loggués de façon exploitable. Activer le logging dans la directive Subsystem :

# Dans /etc/ssh/sshd_config
# Remplacer la ligne Subsystem existante par :
Subsystem sftp internal-sftp -l INFO -f AUTH

Les transferts apparaissent ensuite dans /var/log/auth.log avec le format sftp-server[PID]: open "/path/file.txt" flags READ mode 0666.

Désactiver le login root SSH

# Dans /etc/ssh/sshd_config
PermitRootLogin prohibit-password

# Vérifier qu'aucune clé inconnue n'existe
cat /root/.ssh/authorized_keys
# Si le fichier contient des clés que tu ne reconnais pas : incident de sécurité

prohibit-password (anciennement without-password) interdit le login par mot de passe mais autorise les clés SSH. C'est plus sûr que no si tu as besoin d'un accès d'urgence via clé.

# Recharger SSH après modification
sshd -t && systemctl reload sshd

4. Permissions et fichiers sensibles

Après avoir vérifié que rien n'a été modifié, c'est l'occasion de mettre les permissions dans un état correct une fois pour toutes.

Fichiers de credentials

# Fichiers htdigest — lisibles par root et www-data uniquement
chown root:www-data /etc/apache2/.htdigest
chmod 640 /etc/apache2/.htdigest

# Fichiers de configuration avec mots de passe
chown root:www-data /var/www/html/config.php
chmod 640 /var/www/html/config.php

# Home directories des utilisateurs normaux
chmod 700 /home/alice
chown alice:alice /home/alice

# Exception : home dirs des utilisateurs chrootés SFTP → root:root 755 (voir section 3)

Bloquer l'accès web aux répertoires sensibles

La tentation naturelle est d'utiliser <Location> avec Require all denied. Problème : en Apache 2.4 avec l'authentification HTTP Digest configurée au niveau du parent, les directives Require peuvent interagir de façon inattendue avec l'auth héritée. Dans certaines configurations, l'accès est simplement demandé avec un challenge Digest au lieu d'être refusé.

La RewriteRule est plus fiable parce qu'elle s'exécute dans le pipeline de traitement Apache avant l'authentification :

# Dans .htaccess ou le vhost
RewriteEngine On

# Bloquer l'accès direct aux données internes
RewriteRule ^/data/internal - [F,L]
RewriteRule ^/uploads/private - [F,L]

# Bloquer les fichiers de config exposés par erreur
RewriteRule \.(env|log|sql|bak)$ - [F,L]

Le flag [F] retourne un 403 immédiatement. [L] arrête le traitement des règles suivantes.

5. Sudoers — Principe du moindre privilège

Par défaut sur Debian, l'installation crée souvent un utilisateur dans le groupe sudo. Dans un contexte où le serveur est partagé et expose des services, garder des comptes avec sudo complet est un risque inutile.

# Lister qui a sudo
getent group sudo

# Retirer le sudo d'un compte non-root
deluser adminuser sudo

# Vérifier
sudo -l -U adminuser
# "User adminuser is not allowed to run sudo"

Si l'admin a besoin d'élévation, su suffit — il connaît le mot de passe root. Pas besoin de sudo sur un serveur perso.

www-data et les scripts avec privileges

Si des scripts PHP ont besoin d'exécuter des commandes système avec des droits élevés (recharger Apache, lancer un script de maintenance), ne jamais mettre www-data dans sudo de façon générale. Créer un fichier dans /etc/sudoers.d/ avec uniquement ce qui est strictement nécessaire :

# /etc/sudoers.d/www-data
# Toujours utiliser des chemins absolus
www-data ALL=(ALL) NOPASSWD: /usr/sbin/apachectl graceful
www-data ALL=(ALL) NOPASSWD: /usr/local/bin/maintenance-script.sh
# Permissions correctes sur ce fichier
chmod 440 /etc/sudoers.d/www-data

# Vérifier la syntaxe avant de sauvegarder
visudo -c -f /etc/sudoers.d/www-data

Les scripts appelés par www-data doivent valider leurs entrées. Si un paramètre est passé depuis PHP, utiliser escapeshellarg() et valider le format avant d'appeler shell_exec() :

<?php
// Valider le paramètre avant de l'utiliser
$user = $_POST['username'] ?? '';
if (!preg_match('/^[a-z][a-z0-9_]{2,31}$/', $user)) {
    die('Invalid username format');
}

$escaped = escapeshellarg($user);
$output = shell_exec("sudo /usr/local/bin/create-user-dir.sh $escaped");
?>

6. Audit et détection d'intrusion (auditd)

fail2ban réagit. auditd observe et enregistre. Les deux sont complémentaires.

apt install auditd audispd-plugins
systemctl enable auditd

Créer le fichier de règles /etc/audit/rules.d/security.rules :

# /etc/audit/rules.d/security.rules

# Modifications sudoers
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers

# Configuration SSH
-w /etc/ssh/sshd_config -p wa -k sshd_config

# Clés SSH root
-w /root/.ssh/authorized_keys -p wa -k root_keys

# Comptes système
-w /etc/passwd -p wa -k accounts
-w /etc/shadow -p wa -k accounts
-w /etc/group -p wa -k accounts

# Exécution su et sudo
-w /usr/bin/su -p x -k su_exec
-w /usr/bin/sudo -p x -k sudo_exec

# Crontab
-w /etc/crontab -p wa -k crontab
-w /etc/cron.d/ -p wa -k crontab
-w /var/spool/cron/ -p wa -k crontab

# Configuration fail2ban
-w /etc/fail2ban/ -p wa -k fail2ban

# Fichiers de configuration applicatifs sensibles
-w /var/www/html/config.php -p rwa -k app_config
-w /etc/apache2/ -p wa -k apache_config
# Charger les règles sans redémarrer
augenrules --load

# Vérifier les règles actives
auditctl -l

# Consulter les événements (dernières 24h)
ausearch -ts yesterday -te now | aureport -f -i | head -50

Process accounting avec acct

acct enregistre chaque commande exécutée par chaque utilisateur, avec timestamp et durée. Moins verbeux qu'auditd, mais excellent pour un audit post-incident : "qu'est-ce que l'utilisateur X a fait hier entre 14h et 15h ?"

apt install acct
accton on  # Activer l'enregistrement

# Voir les dernières commandes par utilisateur
lastcomm --user alice | head -30

# Dernières commandes de root
lastcomm --user root | head -50

# Filtrer par commande spécifique
lastcomm --command bash

rkhunter — Rootkit scanner

apt install rkhunter

# Initialiser la baseline (à faire immédiatement après installation propre)
rkhunter --update
rkhunter --propupd

# Scan complet
rkhunter --check --skip-keypress

# Après une mise à jour système, régénérer la baseline
apt upgrade && rkhunter --propupd

rkhunter va générer des faux positifs au début — des fichiers légitimes qu'il ne reconnaît pas. Parcourir les warnings une première fois pour les qualifier. Les vrais rootkits ne se cachent pas à la vue (si le système est déjà compromis, rkhunter sera probablement contourné), mais l'outil est utile pour détecter des modifications inattendues de binaires système.

7. Audit de sécurité automatisé avec analyse IA

Le problème avec la surveillance des logs sur un serveur exposé : le bruit de fond est énorme. Des centaines de tentatives SSH par jour depuis des IPs chinoises et russes, c'est la norme. Les alerter toutes par mail reviendrait à ne plus lire ses mails.

La solution mise en place : collecter le rapport brut chaque nuit, passer ce rapport à Claude via CLI pour distinguer le bruit de fond des vrais incidents, et n'envoyer un mail que si quelque chose mérite attention.

Script de collecte

#!/bin/bash
# /root/scripts/security-audit.sh
# Exécuté chaque nuit via cron : 3 0 * * * /root/scripts/security-audit.sh

set -euo pipefail

REPORT_DIR="/var/log/security-audit"
TODAY=$(date +%Y-%m-%d)
REPORT="$REPORT_DIR/$TODAY.txt"

mkdir -p "$REPORT_DIR"
chmod 700 "$REPORT_DIR"

{
    echo "=== RAPPORT DE SÉCURITÉ - $TODAY ==="
    echo "Généré le : $(date)"
    echo ""

    echo "=== ÉVÉNEMENTS AUDITD (24h) ==="
    ausearch -ts yesterday -te now 2>/dev/null | aureport -f -i 2>/dev/null | tail -100 || echo "auditd: aucun événement"
    echo ""

    echo "=== FAIL2BAN - BANS ACTIFS ==="
    for jail in sshd apache-auth proftpd; do
        echo "--- $jail ---"
        fail2ban-client status "$jail" 2>/dev/null || echo "jail $jail non active"
    done
    echo ""

    echo "=== SSH - TENTATIVES ÉCHOUÉES (24h) ==="
    grep "Failed password" /var/log/auth.log | grep "$(date +%b)" | tail -50 || echo "aucune"
    echo ""

    echo "=== SSH - CONNEXIONS RÉUSSIES (24h) ==="
    grep "Accepted" /var/log/auth.log | grep "$(date +%b)" || echo "aucune"
    echo ""

    echo "=== PORTS EN ÉCOUTE (vérification) ==="
    ss -tlnp
    echo ""

    echo "=== CONNEXIONS ÉTABLIES VERS L'EXTÉRIEUR ==="
    ss -tnp state established | grep -v "127.0.0.1" | grep -v "::1" | head -30
    echo ""

    # Scan rkhunter uniquement le dimanche (jour 0)
    if [ "$(date +%u)" -eq 7 ]; then
        echo "=== RKHUNTER SCAN (hebdomadaire) ==="
        rkhunter --check --skip-keypress --quiet 2>&1 | tail -30 || echo "rkhunter: erreur"
        echo ""
    fi

    echo "=== FIN DU RAPPORT ==="
} > "$REPORT" 2>&1

chmod 600 "$REPORT"

# Lancer l'analyse IA séparément
/root/scripts/security-analyze.sh "$REPORT"

Script d'analyse IA

#!/bin/bash
# /root/scripts/security-analyze.sh
# Reçoit le chemin du rapport en argument

set -euo pipefail

REPORT="${1:-}"
ADMIN_EMAIL="admin@example.com"
TODAY=$(date +%Y-%m-%d)
ANALYSIS_TIMEOUT=60

if [ -z "$REPORT" ] || [ ! -f "$REPORT" ]; then
    echo "Usage: $0 /path/to/report.txt" >&2
    exit 1
fi

# Prompt pour Claude — demander uniquement du JSON pour parser facilement
PROMPT="Analyse ce rapport de sécurité serveur Linux. Distingue les incidents réels des événements normaux (les tentatives SSH depuis des IPs aléatoires sont du bruit de fond habituel). Un incident réel est par exemple : une connexion SSH réussie depuis une IP inconnue, un changement de fichier sensible dans auditd, un port inattendu ouvert, ou un volume de tentatives anormalement élevé sur un service spécifique. Réponds UNIQUEMENT par JSON valide, sans markdown, sans explication : {\"alert\": true/false, \"summary\": \"résumé en 2-3 phrases\", \"details\": [\"point1\", \"point2\"]}"

# Appel Claude avec timeout
AI_RESPONSE=""
AI_ERROR=0

if command -v claude >/dev/null 2>&1; then
    AI_RESPONSE=$(timeout "$ANALYSIS_TIMEOUT" bash -c "cat '$REPORT' | claude --print --model claude-haiku-4-5 '$PROMPT'" 2>/dev/null) || AI_ERROR=1
else
    AI_ERROR=1
fi

# Fallback sécuritaire : si l'IA est indisponible, envoyer le rapport brut
# On ne rate pas un incident parce que l'API est down
if [ "$AI_ERROR" -eq 1 ] || [ -z "$AI_RESPONSE" ]; then
    mail -s "[SECURITE] Rapport $TODAY - analyse IA indisponible" "$ADMIN_EMAIL" < "$REPORT"
    exit 0
fi

# Parser le JSON de réponse
ALERT=$(echo "$AI_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('alert', False))" 2>/dev/null || echo "parse_error")

if [ "$ALERT" = "parse_error" ]; then
    # JSON invalide → fallback rapport brut
    mail -s "[SECURITE] Rapport $TODAY - réponse IA invalide" "$ADMIN_EMAIL" < "$REPORT"
    exit 0
fi

if [ "$ALERT" = "True" ]; then
    SUMMARY=$(echo "$AI_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('summary', 'N/A'))" 2>/dev/null || echo "N/A")

    {
        echo "Analyse IA : $SUMMARY"
        echo ""
        echo "--- Rapport complet ---"
        cat "$REPORT"
    } | mail -s "[ALERTE SECURITE] $TODAY - Incident détecté" "$ADMIN_EMAIL"
fi

# Si alert=False : rien. Le rapport est archivé dans $REPORT_DIR pour consultation manuelle.
# Rendre exécutables et planifier
chmod 700 /root/scripts/security-audit.sh
chmod 700 /root/scripts/security-analyze.sh

# Crontab root
crontab -e
# Ajouter :
# 0 3 * * * /root/scripts/security-audit.sh

L'avantage du fallback systématique : si Claude API est down, timeout ou retourne du JSON invalide, le rapport brut est envoyé quand même. On ne risque pas de rater un incident réel parce que le service d'analyse tiers était indisponible cette nuit-là.

8. Sécurité PHP

Sessions sécurisées

Par défaut, les sessions PHP ne sont pas configurées pour être résistantes au vol de cookie. À ajouter dans php.ini ou au début de chaque script qui utilise des sessions :

<?php
// Configurer avant session_start()
ini_set('session.cookie_httponly', 1);   // Inaccessible depuis JavaScript
ini_set('session.cookie_secure', 1);     // HTTPS uniquement
ini_set('session.cookie_samesite', 'Lax');  // Protection CSRF partielle
ini_set('session.use_strict_mode', 1);   // Rejeter les IDs de session non générés par le serveur

session_start();
?>

Ou dans /etc/php/8.x/apache2/php.ini pour l'appliquer globalement :

session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1

Protection CSRF

Chaque formulaire qui effectue une action (modification, suppression, envoi) doit être protégé par un token CSRF. L'implémentation minimaliste mais correcte :

<?php
session_start();

// Génération du token (une fois par session ou par formulaire)
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

// Dans le formulaire HTML
// <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">

// Vérification sur les requêtes POST
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $submitted_token = $_POST['csrf_token'] ?? '';
    if (!hash_equals($_SESSION['csrf_token'], $submitted_token)) {
        http_response_code(403);
        die('Token CSRF invalide');
    }
    // Régénérer après utilisation pour les formulaires à usage unique
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>

Rate limiting sur les actions sensibles

Sans base de données ni Redis, un rate limiting basique en session suffit pour les petits sites :

<?php
function check_rate_limit(string $action, int $max_attempts, int $window_seconds): bool
{
    $key = 'rate_limit_' . $action;
    $now = time();

    if (!isset($_SESSION[$key])) {
        $_SESSION[$key] = ['count' => 0, 'reset_at' => $now + $window_seconds];
    }

    if ($now > $_SESSION[$key]['reset_at']) {
        $_SESSION[$key] = ['count' => 0, 'reset_at' => $now + $window_seconds];
    }

    $_SESSION[$key]['count']++;

    return $_SESSION[$key]['count'] <= $max_attempts;
}

// Usage
if (!check_rate_limit('login', 5, 300)) {  // 5 tentatives par 5 minutes
    http_response_code(429);
    die('Trop de tentatives. Réessayez dans quelques minutes.');
}
?>

9. Headers HTTP Apache

Ces headers renforcent la sécurité côté navigateur. Ils n'empêchent pas une intrusion serveur, mais ils réduisent la surface d'attaque pour les vulnérabilités XSS et clickjacking.

# Dans .htaccess ou la config du vhost
# Nécessite mod_headers : a2enmod headers

<IfModule mod_headers.c>
    # Empêche le navigateur de deviner le MIME type
    Header always set X-Content-Type-Options "nosniff"

    # Empêche l'inclusion dans des iframes (protection clickjacking)
    Header always set X-Frame-Options "SAMEORIGIN"

    # Contrôle les informations envoyées dans le Referer
    Header always set Referrer-Policy "strict-origin-when-cross-origin"

    # Désactive les fonctionnalités sensibles non utilisées
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"

    # Supprimer la version Apache des réponses
    Header always unset X-Powered-By
    ServerTokens Prod
    ServerSignature Off
</IfModule>

Le HSTS (Strict-Transport-Security) est géré automatiquement par Certbot/Let's Encrypt si le site est en HTTPS. Ne pas le configurer manuellement sauf si tu sais exactement ce que tu fais — une durée trop longue avec une erreur de config HTTPS peut rendre le site inaccessible pendant des mois depuis les navigateurs qui ont mis le header en cache.

10. Docker — Isoler les containers

Quand Apache fait office de reverse proxy vers des containers Docker, un raccourci classique consiste à exposer les ports du container sur toutes les interfaces réseau. Le résultat : le service est accessible directement depuis Internet, contournant complètement le reverse proxy et toute l'authentification qui va avec.

# /srv/wiki/docker-compose.yml

services:
  wiki:
    image: requarks/wiki:2
    # MAUVAIS — accessible depuis n'importe quelle IP sur le port 3000
    ports:
      - "3000:3000"

    # BON — uniquement depuis localhost, le reverse proxy y accède, Internet non
    ports:
      - "127.0.0.1:3000:3000"

La config Apache côté reverse proxy :

<VirtualHost *:443>
    ServerName wiki.example.com

    ProxyPreserveHost On
    ProxyPass / http://127.0.0.1:3000/
    ProxyPassReverse / http://127.0.0.1:3000/

    # L'authentification est gérée ici, pas dans le container
    <Location /admin>
        AuthType Digest
        AuthName "Admin Area"
        AuthDigestProvider file
        AuthUserFile /etc/apache2/.htdigest
        Require valid-user
    </Location>
</VirtualHost>

Vérifier les containers existants qui auraient ce problème :

# Lister les ports Docker exposés
docker ps --format "table {{.Names}}\t{{.Ports}}"

# Chercher les bindings sur 0.0.0.0 (problématiques)
docker ps --format "{{.Ports}}" | grep "0.0.0.0"

11. Mises à jour automatiques

apt install unattended-upgrades apt-listchanges

# Activer
dpkg-reconfigure -plow unattended-upgrades

Vérifier la configuration générée dans /etc/apt/apt.conf.d/20auto-upgrades :

APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";

Sur Debian 12, les security updates sont incluses par défaut dans la configuration d'unattended-upgrades. Vérifier /etc/apt/apt.conf.d/50unattended-upgrades pour s'assurer que les origines Debian-Security sont bien décommentées.

# Simuler ce qui serait mis à jour
unattended-upgrade --dry-run -d

# Forcer une mise à jour maintenant
unattended-upgrade -d

# Voir les logs de mises à jour passées
cat /var/log/unattended-upgrades/unattended-upgrades.log | tail -50

12. Rétention des logs

Debian garde les logs système 4 semaines par défaut. Si tu détectes un incident et que tu veux savoir ce qui s'est passé il y a 6 semaines, tu n'as rien. Étendre la rétention maintenant, avant d'en avoir besoin.

# /etc/logrotate.d/rsyslog — modifier rotate 4 en rotate 13 pour ~3 mois
# (vérifie d'abord le contenu existant)
cat /etc/logrotate.d/rsyslog
# Version modifiée
/var/log/syslog
/var/log/auth.log
/var/log/kern.log
/var/log/mail.log
/var/log/daemon.log
{
    rotate 13
    weekly
    missingok
    notifempty
    compress
    delaycompress
    sharedscripts
    postrotate
        /usr/lib/rsyslog/rsyslog-rotate
    endscript
}
# /etc/logrotate.d/apache2 — passer à 365 jours
# Chercher "rotate" dans le fichier existant et adapter
# Format recommandé pour Apache : rotation quotidienne, 365 fichiers
/var/log/apache2/*.log {
    daily
    rotate 365
    missingok
    notifempty
    compress
    delaycompress
    sharedscripts
    postrotate
        if invoke-rc.d apache2 status > /dev/null 2>&1; then
            invoke-rc.d apache2 reload > /dev/null 2>&1
        fi
    endscript
}
# fail2ban : 54 semaines (~1 an)
# /etc/logrotate.d/fail2ban
/var/log/fail2ban.log {
    weekly
    rotate 54
    compress
    delaycompress
    missingok
    postrotate
        fail2ban-client flushlogs 1>/dev/null || true
    endscript
}

# Tester la config logrotate
logrotate --debug /etc/logrotate.conf

13. Changement de mots de passe post-incident

La partie la moins glamour mais la plus critique. Après toute suspicion d'intrusion, même si l'analyse conclut à une tentative échouée, changer tous les mots de passe qui auraient pu être exposés. Dans le doute, on change.

Mot de passe htdigest

# Changer le mot de passe d'un utilisateur dans un fichier htdigest
htdigest /etc/apache2/.htdigest "Private Area" alice

# Vérifier le fichier résultant (format : user:realm:hash_md5)
cat /etc/apache2/.htdigest

Mots de passe système

# Changer le mot de passe d'un utilisateur
passwd alice

# Changer le mot de passe root
passwd root

# Forcer le changement au prochain login
chage -d 0 alice

Vérifier la cohérence

Le piège classique : un mot de passe est référencé à plusieurs endroits. Avant de valider un changement, chercher toutes les occurrences :

# Chercher les références à un nom d'utilisateur dans les configs
grep -r "alice" /etc/apache2/ 2>/dev/null
grep -r "alice" /etc/proftpd/ 2>/dev/null
grep -r "alice" /var/www/html/ 2>/dev/null

# Chercher les fichiers htpasswd et htdigest
find /etc/apache2 /var/www -name ".htpasswd" -o -name ".htdigest" 2>/dev/null

Un mot de passe peut se trouver dans le fichier htdigest, dans la configuration d'une application (wiki, seedbox), dans des scripts de maintenance, et dans une documentation interne. Mettre à jour un seul des quatre et tout réexpliquer à tous les utilisateurs trois semaines plus tard n'est pas une expérience recommandée.

Conclusion — Checklist de sécurisation

Ce que cet audit a produit concrètement, résumé pour un passage en revue rapide :

  • fail2ban installé et configuré avec filtre custom pour auth_digest Apache
  • Jails actives : sshd, apache-auth, proftpd
  • Groupe sftponly avec chroot et ForceCommand internal-sftp
  • Home dirs des utilisateurs chrootés en root:root 755 (contrainte incontournable)
  • Logging SFTP détaillé activé dans sshd_config
  • Login root SSH en prohibit-password, authorized_keys vérifié
  • Permissions des fichiers de credentials revues (640, root:www-data)
  • Répertoires sensibles bloqués par RewriteRule (pas Location)
  • Comptes sans besoin de sudo retirés du groupe sudo
  • Sudoers www-data limité aux commandes strictement nécessaires
  • auditd installé avec règles sur les fichiers critiques
  • acct installé pour l'historique des commandes par utilisateur
  • rkhunter installé, baseline initialisée
  • Script d'audit quotidien avec analyse IA et fallback rapport brut
  • Sessions PHP sécurisées (httponly, secure, samesite)
  • Protection CSRF sur les formulaires
  • Headers HTTP de sécurité configurés dans Apache
  • Ports Docker bindés sur 127.0.0.1 uniquement
  • unattended-upgrades actif pour les security updates
  • Rétention des logs étendue (3 mois pour auth.log, 1 an pour Apache)
  • Tous les mots de passe potentiellement exposés changés

Un serveur exposé sur Internet sera toujours attaqué. C'est donné avec le territoire. La question n'est pas d'empêcher les tentatives — c'est impossible — mais de s'assurer que les tentatives échouent, que les réussites éventuelles sont détectées rapidement, et qu'on a les logs nécessaires pour comprendre ce qui s'est passé.

Cet audit a pris environ deux jours de travail étalés sur une semaine. La plupart des points auraient dû être faits à l'installation initiale. C'est rarement le cas. L'important est de le faire avant que quelque chose de sérieux arrive — et d'automatiser la surveillance pour ne pas avoir à y revenir manuellement chaque semaine.

Commentaires (0)