Aller au contenu

Hardening as Code : CIS, ANSSI & Ansible

Objectifs pédagogiques

  • Choisir un référentiel de durcissement (CIS, ANSSI) et le relier à la PSSI
  • Écrire un durcissement idempotent et revu sous forme de rôles Ansible
  • Détecter la dérive de configuration sans modifier la machine
  • Mesurer le durcissement avant/après avec Lynis et OpenSCAP
  • Gérer les écarts au référentiel par des dérogations justifiées
  • Éviter les pièges réels : Docker qui contourne le pare-feu, forwarding IP

Un serveur Linux « par défaut » n'est pas prêt pour la production

Une image d'installation vise la compatibilité, pas la sécurité :

  • Authentification SSH par mot de passe souvent possible
  • Services démarrés dont personne n'a besoin
  • Paramètres noyau permissifs (redirections ICMP, routage source)
  • Aucune journalisation des actions sensibles (comptes, sudo, SSH)
  • Mises à jour de sécurité appliquées « quand on y pense »
  • Aucune trace de ce qui a été modifié à la main depuis l'installation

Durcir, c'est quoi exactement

Le durcissement (hardening) réduit la surface d'attaque d'un système :

  • Réduire : supprimer ou désactiver ce qui n'est pas nécessaire
  • Restreindre : moindre privilège pour les comptes, les services et le réseau
  • Tracer : journaliser les actions sensibles pour l'investigation
  • Maintenir : appliquer les correctifs et empêcher la dérive

Le durcissement complète la gestion des vulnérabilités, il ne la remplace pas : une machine durcie mais jamais mise à jour reste exposée


Les référentiels


CIS Benchmarks

Publiés par le Center for Internet Security, rédigés par consensus d'experts, un benchmark par OS ou technologie (Ubuntu 24.04, Docker, Kubernetes…)

  • Recommandations numérotées, chacune avec justification, audit et remédiation
  • Automated : vérifiable par un outil (ex : valeur d'un paramètre sysctl)
  • Manual : demande un jugement humain (ex : revue des comptes)
  • Profils : Level 1 et Level 2, déclinés en Server et Workstation

Niveau 1 ou niveau 2

Level 1 Level 2
Objectif Socle de sécurité largement applicable Défense en profondeur
Impact fonctionnel Faible Peut restreindre des usages ou la performance
Cible Toute machine de production Environnements sensibles (paiement, santé…)
Relation Base Étend le niveau 1, ne le remplace pas

Démarche recommandée : appliquer le niveau 1, puis sélectionner les mesures de niveau 2 justifiées par l'analyse de risques


Guide ANSSI BP-028 et autres sources

ANSSI — Recommandations de configuration d'un système GNU/Linux (BP-028)

  • Recommandations classées par niveau : Minimal, Intermédiaire, Renforcé, Élevé
  • Couvre le partitionnement, le noyau, les comptes, les services, la journalisation
  • Référence attendue dans un contexte français ou réglementé

Compléments selon la couche

  • CIS Docker Benchmark et CIS Kubernetes Benchmark pour la plateforme
  • OWASP (Docker Security Cheat Sheet, ASVS) pour les conteneurs et l'application
  • DISA STIG pour les environnements défense ou administration

Du référentiel à la PSSI

La PSSI fixe des exigences, le référentiel fournit les mesures, le code les applique, l'audit les prouve

Exigence PSSI Mesure Référence Contrôle Preuve
Accès d'administration nominatif et fort SSH par clé, root interdit CIS (SSH), ANSSI Rôle ssh sshd -T + Lynis
Traçabilité des actions sensibles Règles auditd CIS (auditd), ANSSI Rôle audit auditctl -l
Correctifs de sécurité sous 24 h Mises à jour automatiques CIS (paquets) Rôle updates Journal APT

Citez toujours le numéro exact de la recommandation et la version du benchmark utilisée : la numérotation change entre versions


Hardening as Code avec Ansible


Pourquoi « as code »

Durcissement manuel ou script shell Hardening as Code
Dépend de la mémoire de l'administrateur Décrit dans un dépôt versionné
Non relu Revu en Merge Request
Rejoué avec effets de bord Idempotent : rejouable sans risque
Dérive invisible Dérive détectée par simulation
Une machine à la fois N machines identiques

Le dépôt devient lui-même une preuve d'audit : qui a changé quelle mesure, quand et pourquoi


Structure du projet

ansible/
├── ansible.cfg
├── inventory.ini
├── requirements.yml        # collections (community.general, ansible.posix)
├── hardening.yml           # playbook principal
├── group_vars/
│   └── all.yml             # variables + dérogations documentées
└── roles/
    ├── ssh/                # tasks/, handlers/, templates/, defaults/
    ├── accounts/
    ├── firewall/
    ├── kernel/
    ├── audit/
    ├── updates/
    └── services/

Un rôle par domaine : chaque Merge Request reste lisible et chaque mesure retrouve son rôle


Le playbook principal

# ansible/hardening.yml
- name: Durcissement de la VM securepay-prod
  hosts: prod
  become: true
  roles:
    - role: ssh
      tags: [ssh]
    - role: accounts
      tags: [accounts]
    - role: firewall
      tags: [firewall]
    - role: kernel
      tags: [kernel]
    - role: audit
      tags: [audit]
    - role: updates
      tags: [updates]
    - role: services
      tags: [services]

Les tags permettent de rejouer un seul domaine : ansible-playbook hardening.yml --tags ssh


Un rôle : tâche, validation et handler

# roles/ssh/tasks/main.yml
- name: Déposer la configuration SSH durcie
  ansible.builtin.template:
    src: 10-hardening.conf.j2
    dest: /etc/ssh/sshd_config.d/10-hardening.conf
    owner: root
    group: root
    mode: "0600"
    validate: /usr/sbin/sshd -t -f %s
  notify: Recharger SSH

# roles/ssh/handlers/main.yml
- name: Recharger SSH
  ansible.builtin.systemd_service:
    name: ssh
    state: reloaded

validate refuse un fichier invalide avant qu'il ne remplace l'existant : on ne se coupe pas l'accès


Idempotence

Un playbook idempotent amène la machine dans l'état décrit, puis ne change plus rien

  • Privilégier les modules déclaratifs (template, sysctl, ufw, apt) à command et shell
  • Si command est inévitable : creates:, changed_when: et check_mode: false pour une lecture seule
  • Les handlers ne s'exécutent que si une tâche a réellement changé quelque chose

Test d'acceptation : la deuxième exécution consécutive doit afficher changed=0

PLAY RECAP *********************************************************
securepay-prod : ok=42  changed=0  unreachable=0  failed=0  skipped=0

Détecter la dérive sans rien modifier

ansible-playbook -i inventory.ini hardening.yml --check --diff
  • --check simule chaque tâche sans appliquer de changement
  • --diff affiche l'écart entre l'état réel et l'état déclaré
  • Toute tâche qui serait changed révèle une dérive (modification manuelle, paquet, mise à jour)

Limites à connaître :

  • Les tâches command et shell sont ignorées en mode check sauf check_mode: false
  • Une tâche qui dépend du résultat d'une précédente peut échouer en simulation

Planifié (CI nocturne ou cron), ce contrôle devient une surveillance continue de conformité


Qualité du code : ansible-lint en CI

# .gitlab-ci.yml de securepay-platform
ansible:lint:
  stage: validate
  image: python:3.12-slim          # épinglez une version vérifiée
  variables:
    ANSIBLE_LINT_VERSION: "<version>"
  script:
    - pip install --no-cache-dir "ansible-lint==${ANSIBLE_LINT_VERSION}"
    - cd ansible
    - ansible-galaxy collection install -r requirements.yml
    - ansible-lint --profile moderate hardening.yml
  rules:
    - changes: [ansible/**/*]

ansible-lint détecte les modules non qualifiés (FQCN), les shell évitables, les permissions implicites et les tâches non idempotentes ; montez ensuite vers le profil production


Rôle communautaire ou rôle maison

Rôle communautaire (ansible-lockdown UBUNTU24-CIS) Rôle maison
Couverture Quasi complète du benchmark Limitée aux mesures choisies
Maîtrise Des centaines de tâches à comprendre Totale
Risque Peut casser un service (Docker, Kubernetes) Plus faible, périmètre réduit
Cible Installation propre, à appliquer tôt Machine existante
Pilotage Une variable par recommandation Variables définies par l'équipe

Démarche réaliste : auditer d'abord (rôle d'audit UBUNTU24-CIS-Audit, Lynis), activer ensuite les règles par lots, désactiver explicitement et justifier celles qui cassent le service


Les domaines du durcissement


SSH

# /etc/ssh/sshd_config.d/10-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
AllowUsers opsadmin ubuntu        # ubuntu : compte utilisé par `multipass shell`
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
Banner /etc/issue.net

Ubuntu lit sshd_config.d/*.conf en premier et sshd retient la première valeur : 10-hardening.conf l'emporte sur 50-cloud-init.conf

Vérifier la configuration effective : sudo sshd -T | grep -Ei 'permitroot|password'


Comptes et privilèges

  • Un compte nominatif par administrateur, aucune connexion directe en root
  • sudo journalisé (Defaults logfile=/var/log/sudo.log), sans NOPASSWD généralisé
  • Comptes système sans shell (/usr/sbin/nologin), comptes inutilisés verrouillés
  • umask 027 par défaut, permissions strictes sur /etc/shadow, /etc/sudoers.d/
  • Appartenance au groupe docker = équivalent root : à réserver aux administrateurs
# Qui peut piloter le démon Docker (donc devenir root) ?
getent group docker

Pare-feu

# roles/firewall/tasks/main.yml (extrait)
- name: Autoriser SSH depuis le réseau d'administration
  community.general.ufw:
    rule: allow
    port: "22"
    proto: tcp
    src: "{{ admin_cidr }}"

- name: Refuser par défaut le trafic entrant
  community.general.ufw:
    direction: incoming
    default: deny

- name: Activer UFW
  community.general.ufw:
    state: enabled
    logging: "on"

Ordre impératif : ouvrir SSH avant d'activer le refus par défaut


Paramètres noyau

# group_vars/all.yml (extrait)
kernel_sysctls:
  kernel.randomize_va_space: 2
  kernel.kptr_restrict: 2
  kernel.dmesg_restrict: 1
  fs.suid_dumpable: 0
  fs.protected_symlinks: 1
  fs.protected_hardlinks: 1
  net.ipv4.conf.all.accept_redirects: 0
  net.ipv4.conf.all.send_redirects: 0
  net.ipv4.conf.all.accept_source_route: 0
  net.ipv4.conf.all.log_martians: 1
  net.ipv4.tcp_syncookies: 1
  net.ipv4.icmp_echo_ignore_broadcasts: 1
  # DER-01 : requis par Docker et Kubernetes (le CIS recommande 0)
  net.ipv4.ip_forward: 1

Appliqués par ansible.posix.sysctl dans un fichier dédié de /etc/sysctl.d/


Journalisation avec auditd

# /etc/audit/rules.d/50-securepay.rules
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/sudoers -p wa -k scope
-w /etc/sudoers.d/ -p wa -k scope
-w /etc/ssh/sshd_config.d/ -p wa -k sshd
-w /usr/bin/dockerd -p x -k docker
-w /etc/docker/ -p wa -k docker
  • Chargement : augenrules --load (handler), vérification : auditctl -l
  • Investigation : ausearch -k identity --start today
  • Option niveau 2 : -e 2 rend la configuration immuable jusqu'au prochain redémarrage

Mises à jour, AppArmor et services

  • unattended-upgrades : correctifs de sécurité appliqués chaque jour (/etc/apt/apt.conf.d/20auto-upgrades)
  • Docker installé depuis son propre dépôt n'est pas couvert par défaut : planifiez ses mises à jour, elles redémarrent le démon et le cluster
  • AppArmor : actif par défaut sur Ubuntu, Docker applique le profil docker-default aux conteneurs (sudo aa-status)
  • Services : systemctl list-unit-files --state=enabled, chaque service actif doit avoir une raison d'exister
  • Permissions : fichiers de configuration sensibles en 0600 ou 0640, propriétaire root

Les pièges du monde réel


Piège 1 : Docker contourne UFW

Après ufw default deny incoming, un conteneur lancé avec -p 8080:80 reste joignable depuis l'extérieur

  • Le trafic vers un port publié est traduit (DNAT) puis traité dans la chaîne FORWARD, pas INPUT
  • Docker insère ses propres règles dans FORWARD, les règles d'entrée d'UFW ne s'appliquent pas
  • Même chose pour les ports publiés par k3d : load balancer 80/443 et API Kubernetes

Correctifs possibles :

  • Publier sur une adresse précise (-p 127.0.0.1:8080:80) quand l'accès externe est inutile
  • Filtrer dans la chaîne DOCKER-USER, évaluée avant les règles de Docker et jamais modifiée par lui

Filtrer dans DOCKER-USER

# Ajouté à la fin de /etc/ufw/after.rules (géré par Ansible, blockinfile)
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -i enp0s1 -s 192.168.64.0/24 -j RETURN
-A DOCKER-USER -i enp0s1 -j DROP
-A DOCKER-USER -j RETURN
COMMIT
  • enp0s1 : interface externe de la VM, 192.168.64.0/24 : réseau du poste d'administration (à adapter, ip -br a)
  • Le trafic entre conteneurs et nœuds k3d passe par les bridges Docker : il n'est pas concerné
  • UFW charge ce bloc au démarrage, avant Docker, qui ne fait qu'ajouter son saut vers DOCKER-USER

Piège 2 : le benchmark contre le besoin

Le CIS recommande net.ipv4.ip_forward = 0 : une machine qui n'est pas un routeur ne doit pas router

  • Docker et Kubernetes ont besoin du forwarding pour faire transiter le trafic des conteneurs
  • Appliquer la règle casse le cluster, ignorer le benchmark entier supprime toute base de conformité

La bonne réponse est une dérogation :

  • Justifiée par une exigence technique
  • Validée par le responsable sécurité
  • Compensée par d'autres mesures (filtrage DOCKER-USER, NetworkPolicies)
  • Tracée dans le code et dans la matrice de conformité, revue périodiquement

Registre des dérogations

ID Recommandation Justification Mesure compensatoire Validée par Revue
DER-01 ip_forward = 0 Requis par Docker et k3d DOCKER-USER, NetworkPolicies RSSI Semestrielle
DER-02 AllowUsers limité au nominatif ubuntu requis par multipass shell Clé uniquement, accès local au poste RSSI Semestrielle
DER-03 Partition /tmp séparée Image cloud sans partitionnement dédié Options noexec étudiées, AppArmor Lead SRE Annuelle

Une recommandation non appliquée sans dérogation est un écart, pas un choix


Auditer et prouver


Lynis

sudo apt-get install -y lynis
sudo lynis audit system --no-colors | tee /tmp/lynis-$(date +%F_%H%M).txt

# Score et constats détaillés
sudo grep -E '^hardening_index=' /var/log/lynis-report.dat
sudo grep -E '^(warning|suggestion)\[\]=' /var/log/lynis-report.dat | head
  • Hardening index : indicateur de 0 à 100, utile pour comparer avant/après
  • Warnings : à traiter en priorité
  • Suggestions : à appliquer ou à justifier dans le registre des dérogations

OpenSCAP et ComplianceAsCode

sudo apt-get install -y openscap-scanner
# Contenu SCAP du projet ComplianceAsCode (release scap-security-guide)
oscap info ssg-ubuntu2404-ds.xml            # liste des profils disponibles
sudo oscap xccdf eval \
  --profile xccdf_org.ssgproject.content_profile_cis_level1_server \
  --report /tmp/openscap-cis-l1.html \
  ssg-ubuntu2404-ds.xml
  • Évalue la machine contre un profil (CIS niveau 1 serveur) règle par règle
  • Rapport HTML exploitable directement comme preuve d'audit
  • Vérifiez l'identifiant exact du profil avec oscap info : il dépend de la version du contenu

Prouver le durcissement

  • Avant : audit initial horodaté, conservé tel quel
  • Après : même outil, même profil, même machine
  • Delta : score, warnings résolus, recommandations restantes
  • Écarts : chaque recommandation restante est corrigée ou portée au registre des dérogations

Un score n'est pas une garantie : un indice Lynis élevé ne dit rien d'une CVE applicative ni d'un secret exposé


À retenir

  • Un référentiel (CIS, ANSSI) donne les mesures, la PSSI dit lesquelles sont exigées
  • Hardening as Code : versionné, revu, idempotent (changed=0), dérive visible avec --check --diff
  • Ouvrir SSH avant le refus par défaut, valider sshd avant de recharger
  • Docker contourne UFW : filtrez dans DOCKER-USER
  • Toute recommandation écartée devient une dérogation justifiée, compensée et revue
  • Mesurez avant/après, conservez les preuves horodatées

Pour aller plus loin

  • CIS Benchmarks : https://www.cisecurity.org/cis-benchmarks
  • Guide ANSSI BP-028 (v2.0) : https://cyber.gouv.fr/publications, « Recommandations de configuration d'un système GNU/Linux »
  • ansible-lockdown : https://github.com/ansible-lockdown
  • Lynis : https://cisofy.com/lynis/