Aller au contenu
Infrastructure as Code medium

Sécuriser ses playbooks Ansible : 6 axes critiques (secrets, supply chain, audit)

21 min de lecture

Logo Ansible

Ansible amplifie tout : les bonnes pratiques comme les mauvaises. Un playbook mal sécurisé peut exposer des secrets à toute votre équipe, propager une mauvaise configuration à 200 serveurs en 30 secondes, ou ouvrir une porte dérobée via une collection Galaxy compromise. Cette page consolide les 6 axes de sécurité à appliquer transversalement à tout projet Ansible, en complément des sections spécialisées (secrets-vault, auditer un rôle, collections requirements).

  • Identifier les 6 axes de sécurité d'un projet Ansible et leurs anti-patterns courants.
  • Choisir la bonne stratégie de gestion de secrets (Ansible Vault, HashiCorp Vault, Passbolt) selon le contexte.
  • Auditer la supply chain Galaxy (collections, rôles tiers, signatures GPG).
  • Appliquer le moindre privilège côté contrôle (compte, clés SSH) et côté managé (sudoers ciblés).
  • Durcir les managed nodes via les modules Ansible (SELinux enforcing, sysctl, firewalld, OpenSCAP).
  • Tracer et auditer l'exécution (logs, callback plugins, AAP/AWX RBAC).

Tous les secrets (mots de passe, tokens API, clés privées TLS, certificats) doivent être chiffrés avec Ansible Vault avant d'entrer dans Git, et masqués au runtime avec no_log: true. Ces deux protections répondent à deux fuites différentes : le chiffrement couvre le secret au repos dans le dépôt Git, no_log le couvre en transit dans les sorties d'exécution. Le choix de l'outil de stockage, lui, dépend de la durée de vie du secret et du nombre de personnes qui doivent y accéder :

Type de secretOutil recommandéRaison
Token API stable, mot de passe d'appAnsible Vault encrypt_stringVersionné Git, inline, diffs lisibles
Secrets multi-environnements (dev/prod)vault-id par environnementMots de passe distincts, rotation isolée
Credentials BDD dynamiques (TTL court)HashiCorp Vault / OpenBaoGénération on-demand, lease automatique
Mots de passe partagés entre humainsPassboltUI navigateur, OpenPGP, audit par utilisateur
Secrets cloud (AWS, Azure, GCP)IAM / Workload IdentityPas de credential statique

Ces cinq pratiques reviennent dans presque tous les projets Ansible repris en cours de route. Elles ont un point commun : le secret finit lisible par quelqu'un qui n'aurait pas dû y accéder, sans que personne s'en rende compte. La colonne « Risque » indique où la fuite se produit concrètement, la colonne « Solution » la contre-mesure à appliquer.

Anti-patternRisqueSolution
Mot de passe en clair dans group_vars/all.ymlExposition Git, logs, historique navigateurAnsible Vault
vault_password_file commitéTout le monde peut déchiffrer.gitignore + variable d'environnement
Secret affiché dans la sortie verboseVisibilité dans CI/CD, AWX, ELKno_log: true sur les tâches sensibles
Un seul mot de passe Vault pour dev + prodCompromission totale en cas de fuiteVault IDs par environnement
Credentials dans --extra-vars CLIVisible dans ps aux, history shellFichier chiffré ou variable d'env

no_log est un mot-clé de tâche qui se pose au même niveau que name:, pas dans les paramètres du module. Il vaut false par défaut : sans lui, un mot de passe passé en argument est réaffiché tel quel dans la sortie de la tâche.

- name: Créer un user avec mot de passe (à masquer)
ansible.builtin.user:
name: alice
password: "{{ vault_alice_password_hash }}"
no_log: true # OBLIGATOIRE : sinon le hash apparaît en clair dans les logs
- name: Appel API authentifié (à masquer)
ansible.builtin.uri:
url: "{{ api_url }}/v1/users"
headers:
Authorization: "Bearer {{ vault_api_token }}"
method: POST
body: "{{ payload }}"
body_format: json
no_log: true
register: api_response

no_log: true masque toute la sortie de la tâche (args, stdout, stderr), y compris en -v ou en cas d'échec.

Quand Ansible Vault ne suffit plus (équipe > 5, audit fin requis, secrets dynamiques), le secret n'est plus stocké dans le dépôt Git mais récupéré à l'exécution auprès d'un serveur externe. Le lookup ci-dessous s'authentifie en AppRole (un couple identifiant/secret propre à la machine, pas à un humain) et lit la clé password d'un secret KV version 2 :

- name: Récupérer un secret KV depuis HashiCorp Vault
ansible.builtin.set_fact:
db_password: "{{ lookup('community.hashi_vault.vault_kv2_get',
'kv/myapp/db',
url='https://vault.example.com:8200',
auth_method='approle',
role_id=lookup('env', 'VAULT_ROLE_ID'),
secret_id=lookup('env', 'VAULT_SECRET_ID')).data.password }}"
no_log: true

Détails dans le guide intégration HashiCorp Vault.

Les collections Galaxy ne sont pas exemptes des attaques supply chain (typosquatting, takeover de package, dépendances malicieuses). Une collection est du code exécuté avec les privilèges du playbook, souvent en root sur des dizaines de machines : elle mérite le même niveau de contrôle qu'une dépendance applicative. Trois mesures s'imposent : figer les versions, vérifier ce qui est réellement téléchargé, et maîtriser la source de téléchargement.

Le fichier requirements.yml déclare les collections dont le projet dépend, avec leur version. C'est ce fichier que ansible-galaxy collection install -r requirements.yml lit, et c'est le seul endroit où le numéro de version est décidé : sans contrainte explicite, Galaxy installe la dernière publiée, qui change sans prévenir.

# requirements.yml, versionné dans Git
---
collections:
- name: ansible.posix
version: "2.0.0" # exact, pas >=2.0.0
- name: community.general
version: ">=8.0.0,<10.0.0" # range majeur locké
- name: stephrobert.webapp
source: https://github.com/stephrobert/ansible-collection-webapp.git
type: git
version: v1.2.3 # tag, JAMAIS "main" ou branche

Jamais latest ni de branche Git, le drift est garanti et silencieux. Toujours un tag ou un SHA Git pour la reproductibilité.

Figer une version ne garantit pas que le contenu téléchargé soit celui d'origine. La sous-commande verify relit les collections déjà installées et les confronte au serveur, sans rien réinstaller ; l'option -p désigne le répertoire où elles ont été déposées.

Fenêtre de terminal
# Dans la CI, après install :
ansible-galaxy collection verify -r requirements.yml -p collections/

verify compare les checksums + signatures GPG du tarball installé avec ce que le mirror déclare. Un mirror compromis qui substitue un tarball malveillant est détecté.

Pour les fleets sensibles, déployer un Galaxy NG ou Automation Hub privé qui mirror Galaxy public. Avantages : audit centralisé, scan Trivy automatique sur les nouveaux uploads, possibilité de bloquer une collection compromise avant qu'elle ne touche la prod.

Cf. la checklist d'audit en 7 axes : auteur, activité, tests, README, code, permissions, dépendances. Un rôle malveillant qui tourne en root sur 50 serveurs = compromission totale. Le coût d'audit (10 min) est négligeable face au coût d'incident.

Ansible se connecte en SSH depuis une machine, le control node, vers des machines cibles, les managed nodes. Ces deux extrémités se durcissent séparément : d'un côté l'identité qui se connecte, de l'autre ce que cette identité a le droit de faire une fois arrivée. Le principe est le même des deux côtés : un compte compromis ne doit ouvrir que le strict nécessaire, jamais un accès root généralisé sur la flotte.

Le control node concentre les clés d'accès à toute l'infrastructure : c'est la machine la plus sensible du dispositif. Quatre réflexes limitent les dégâts si elle est compromise.

  • Compte dédié Ansible (jamais root SSH direct sur les cibles).
  • Clé SSH Ed25519 générée spécifiquement pour le contrôle, distincte de votre clé personnelle.
  • ssh-agent + ControlPersist pour éviter de retaper la passphrase à chaque tâche.
  • Pas de --ask-pass, c'est un signal qu'une clé manque.

Sur la machine cible, l'élévation de privilèges passe par become, qui appelle sudo par défaut. Plutôt que d'autoriser ALL, on déclare la liste exacte des commandes que le compte Ansible peut lancer en root.

  • Compte non-root avec sudo ciblé via community.general.sudoers :

    - name: Déclarer une règle sudo
    community.general.sudoers:
    name: ansible-deploy
    user: ansible
    nopassword: true
    commands:
    - /usr/bin/systemctl restart nginx
    - /usr/bin/systemctl reload nginx
    - /usr/local/bin/deploy-app.sh
    state: present
    become: true

    La règle ci-dessus permet à l'utilisateur ansible de redémarrer nginx et lancer un script précis sans mot de passe, mais pas d'avoir un shell root complet. Bien plus restrictif que commands: ALL.

  • Pas de become: true au niveau du play par défaut, uniquement sur les tâches qui en ont besoin. Réduit la surface d'attaque en cas de bug.

Le cloisonnement se joue dans l'arborescence de l'inventaire. Les secrets ne vivent pas dans les mêmes fichiers que les variables publiques : chaque groupe reçoit un vault.yml distinct, chiffré avec son propre mot de passe.

inventory/
├── hosts.yml
├── group_vars/
│ ├── all.yml # variables publiques globales
│ ├── all/vault.yml # secrets globaux (chiffrés Vault)
│ ├── prod/main.yml # variables publiques prod
│ ├── prod/vault.yml # secrets prod (vault-id "prod")
│ ├── staging/main.yml
│ └── staging/vault.yml # vault-id "staging" (mot de passe différent)

Avec vault-id distincts pour staging et prod, un développeur qui a accès à staging ne peut pas déchiffrer les secrets prod. La rotation est aussi locale (changer le mot de passe prod sans toucher à staging).

Ces 3 modules exécutent des commandes arbitraires sur la cible. shell passe la commande à un interpréteur (/bin/sh par défaut), command l'exécute directement sans interpréteur, raw l'envoie en SSH brut sans même passer par Python. Dès qu'une variable est interpolée dans la chaîne exécutée par shell ou raw, elle devient un vecteur d'injection de commande : ce que contient la variable est exécuté au même titre que ce que vous avez écrit.

# ❌ DANGEREUX : injection si user_input contient "; rm -rf /"
- ansible.builtin.shell: "rm /tmp/{{ user_input }}"
# ✅ SÉCURISÉ : module dédié, validation côté Ansible
- name: Supprimer le fichier ciblé
ansible.builtin.file:
path: "/tmp/{{ user_input | regex_replace('[^a-zA-Z0-9._-]', '') }}"
state: absent

Règle : avant d'écrire command: ou shell:, chercher le module dédié avec ansible-doc -l | grep <thème>. Si c'est vraiment nécessaire, échapper les variables avec | quote :

# Si shell est vraiment inévitable, ici parce qu'il y a un tube
- name: Compter les occurrences dans le journal d'authentification
ansible.builtin.shell: |
set -o pipefail
grep {{ pattern | quote }} /var/log/auth.log | wc -l
args:
executable: /bin/bash
register: hits
changed_when: false
failed_when: hits.rc not in [0, 1]

Notez que sans le tube, shell: ne serait pas nécessaire : un simple grep passe très bien par command:, qui n'ouvre aucun interpréteur et supprime donc le risque d'injection à la racine.

Le recours à ces modules n'est pas une faute en soi, il devient un problème quand il remplace un module qui existe. Trois situations le justifient réellement.

  • Aucun module dédié n'existe (cas rare en 2026, vérifier deux fois).
  • Bootstrap d'un host sans Python : raw est obligatoire.
  • Lecture-seule pour récupérer une valeur : avec register: + changed_when: false.

Détails dans raw / command / shell.

Un playbook non testé est une modification qui part en production sans relecture. La validation Ansible s'organise en couches : la syntaxe d'abord, les règles de qualité ensuite, le comportement réel en dernier. Chacune se branche à un moment différent du cycle, du pre-commit local jusqu'au contrôle de dérive planifié.

Le tableau se lit de haut en bas comme un ordre de passage : plus on descend, plus le contrôle est coûteux et tardif. La colonne « Fréquence » indique où brancher chaque outil pour qu'il échoue le plus tôt possible.

OutilRôleFréquence
yamllintSyntaxe YAML stricte (interdire yes/no ambigus)Pre-commit
ansible-lint --profile production80+ règles : FQCN, idempotence, anti-patternsPre-commit + CI bloquante
ansible-test sanity --dockerValidation collection (FQCN, types, doc)CI sur push
Molecule + testinfraTests fonctionnels rôle multi-distrosCI sur PR
ansible-playbook --check --diffDétection de drift sur fleetCron quotidien

Le workflow qui lint vos playbooks manipule le code de toute votre infrastructure : il se durcit comme n'importe quel pipeline sensible. Les deux points critiques sont le jeton GITHUB_TOKEN, dont les droits doivent être remis à zéro puis rouverts job par job, et l'empreinte des actions utilisées, pinnées par SHA de commit pour qu'une republication de tag ne change pas le code exécuté.

# .github/workflows/ansible-ci.yml, extrait
permissions: {} # GLOBAL : aucune permission par défaut
jobs:
lint:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@<SHA40>
with:
persist-credentials: false
- run: pipx install ansible-lint
- run: ansible-lint --profile=production . # bloquant
- run: ansible-galaxy collection verify -r requirements.yml

Règles non négociables 2026 :

  • permissions: {} global + permissions minimales par job.
  • Actions pinnées par SHA, jamais par tag.
  • persist-credentials: false sur actions/checkout.
  • Lint bloquant, pas seulement informatif, sinon les warnings s'accumulent.

Les cinq axes précédents protègent le playbook ; celui-ci concerne ce que le playbook applique sur les machines. Ansible est bien placé pour porter un référentiel de durcissement, parce qu'il le rend rejouable et vérifiable : le même code applique la configuration et, relancé en --check, détecte la dérive. La traçabilité complète le dispositif en gardant la trace de qui a exécuté quoi.

Trois modules de la collection ansible.posix couvrent l'essentiel d'un durcissement Linux : selinux pour le contrôle d'accès obligatoire, sysctl pour les paramètres du noyau, firewalld pour le filtrage réseau. Ils sont idempotents : relancés sur une machine déjà conforme, ils ne changent rien et ne remontent aucun changed.

# SELinux enforcing (mandatory en prod RHEL, objectif RHCE)
- name: Régler le mode SELinux
ansible.posix.selinux:
state: enforcing
policy: targeted
become: true
# Durcissement réseau (anti-spoofing, syncookies)
- name: Régler un paramètre noyau
ansible.posix.sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
sysctl_file: /etc/sysctl.d/99-hardening.conf
reload: true
loop:
- { name: net.ipv4.tcp_syncookies, value: '1' }
- { name: net.ipv4.conf.all.rp_filter, value: '1' }
- { name: kernel.kptr_restrict, value: '2' }
- { name: kernel.dmesg_restrict, value: '1' }
become: true
# Firewalld avec règle d'or permanent + immediate
- name: Ouvrir le service dans le pare-feu
ansible.posix.firewalld:
service: ssh
permanent: true
immediate: true
state: enabled
zone: public
become: true

OpenSCAP évalue une machine face à un référentiel de conformité publié (CIS, ANSSI, STIG) et produit un rapport règle par règle. Pour les environnements soumis à ces référentiels, le déclencher via Ansible permet d'auditer toute la flotte d'un seul run et de récupérer les rapports au même endroit :

- name: Audit CIS via OpenSCAP
ansible.builtin.command: >
oscap xccdf eval --profile xccdf_org.ssgproject.content_profile_cis
--results /var/log/oscap-cis-{{ inventory_hostname }}.xml
/usr/share/xml/scap/ssg/content/ssg-rhel10-ds.xml
register: scap_result
changed_when: false
failed_when: scap_result.rc not in [0, 2] # rc=2 = "des règles ont échoué", pas une erreur fatale
become: true

Par défaut, Ansible n'écrit aucun fichier de log : la sortie défile dans le terminal et disparaît avec lui. En cas d'incident, il ne reste plus rien pour établir quelle tâche a changé quoi et quand. Activer une journalisation persistante est le minimum sur un control node partagé.

  • ANSIBLE_LOG_PATH=/var/log/ansible.log dans ansible.cfg pour persister chaque run.
  • Callback plugins (profile_tasks, timer, json) pour des sorties structurées.
  • AAP / AWX en prod : audit trail centralisé, RBAC fin, secrets stockés isolés, intégration LDAP/SAML.
# ansible.cfg, config sécurité
[defaults]
log_path = /var/log/ansible.log
no_log = False # explicite, contrôlé par tâche
host_key_checking = True # ne JAMAIS désactiver en prod
display_skipped_hosts = False
callbacks_enabled = ansible.posix.profile_tasks, ansible.posix.timer
[privilege_escalation]
become = True
become_method = sudo
become_ask_pass = False
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new
pipelining = True

host_key_checking = True est non négociable en production, sans lui, vous acceptez aveuglément n'importe quelle clé hôte (MITM possible). Pour le bootstrap initial, préférer accept-new (accepte le premier hôte mais refuse un changement ultérieur) plutôt que False.

Les six axes se recoupent, et il est facile d'en appliquer cinq en laissant le sixième ouvert. Cette liste condense les points sur lesquels aucun arbitrage n'est acceptable en production ; elle sert de contrôle avant de brancher un projet Ansible sur une flotte réelle.

  • Tous les secrets dans Vault, jamais en clair. no_log: true sur les tâches sensibles.
  • Pinning strict + ansible-galaxy collection verify dans la CI.
  • become: true ciblé, sudoers précis (commandes, pas ALL).
  • Modes quoté + restrictifs : "0600" secrets, "0640" configs sensibles, "0644" public.
  • ansible-lint --profile production en CI bloquante.
  • SELinux enforcing, firewalld permanent + immediate, host_key_checking = True.
  • Audit trail : ANSIBLE_LOG_PATH ou AAP/AWX en prod.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn