Aller au contenu

Écrire des scripts Bash système fiables et sûrs en production

Écrire des scripts Bash système fiables et sûrs en production

Section intitulée « Écrire des scripts Bash système fiables et sûrs en production »

Bash est le langage que personne n’a vraiment choisi et que tout le monde exploite : il tourne en root, la nuit, sans témoin. Cette page ne refait pas la syntaxe ; elle traite ce qui fait qu’un script d’administration tient en production et ne devient pas lui-même un chemin d’escalade. La position défendue : Bash pour coller des commandes, en version courte, stricte et analysée, et un autre langage dès que la logique dépasse la colle.

→ Lire l’explication complète de la marmotte

Tout ce qui suit suppose Bash 4.4 au minimum. C’est la version qui apporte inherit_errexit, la forme ${var@Q}, mapfile -d et un comportement sain des tableaux vides sous set -u. Les distributions Linux courantes livrent Bash 5.x. macOS livre encore Bash 3.2 sous /bin/bash, où rien de cela n’existe. Un script qui dépend d’une version le vérifie au démarrage via BASH_VERSINFO et s’arrête avec un message, plutôt que de se comporter différemment en silence.

Côté outillage, il faut flock (paquet util-linux), mktemp (coreutils) et ShellCheck, sur le poste de développement comme dans la chaîne d’intégration.

L’ordre des expansions, les redirections, la résolution des commandes et les permissions sont traités dans la page Linux shell, permissions et processus. Cette page-ci part de là et ne la refait pas.

Ce que Bash fait bien et quand passer à un autre langage

Section intitulée « Ce que Bash fait bien et quand passer à un autre langage »

Bash excelle à une chose : lancer des processus et brancher leurs flux de texte. Tubes, redirections, codes de retour, tests sur les fichiers : aucun langage généraliste ne fait cela avec aussi peu de cérémonie. Il est aussi déjà installé sur à peu près tout serveur Linux (les conteneurs minimalistes mis à part), donc sans dépendance à déployer ni à maintenir.

Le prix se paie ailleurs, et c’est le compromis central du sujet :

  • un seul type de donnée, la chaîne ; des tableaux qui ne s’imbriquent pas et ne s’exportent pas ;
  • une arithmétique limitée aux entiers ;
  • une gestion d’erreur par convention, sans exception ni type de retour ;
  • une réinterprétation de chaque valeur à chaque expansion, ce qui fait de toute variable une entrée potentiellement hostile.

Le langage n’a pas d’exceptions, il a des habitudes.

La règle qui tient : Bash tant que le script enchaîne des commandes existantes de façon linéaire. Le guide de style shell de Google fixe le seuil à une centaine de lignes ou au premier flux de contrôle non trivial, et ce seuil se vérifie en pratique. Analyse de JSON (JavaScript Object Notation), appels à une API (Application Programming Interface) avec reprise sur erreur, structures imbriquées, besoin de tests unitaires : chacun de ces signaux suffit à changer de langage. La frontière et les garde-fous côté Python sont détaillés dans Python pour l’automatisation défensive. Le script écrit « pour une fois » obéit aux mêmes règles, et à quelques autres : voir écrire un outil jetable proprement.

Le schéma ci-dessous se lit de haut en bas : une seule réponse défavorable suffit à sortir de Bash.

Arbre de décision lu de haut en bas, partant du besoin. Question 1 : le script enchaîne-t-il surtout des commandes existantes ? Si non (calcul, logique métier, structures de données), on passe à un autre langage ; si oui, on va à la question 2. À la question 2, des données structurées à analyser ou des appels réseau avec reprise mènent à un autre langage ; un flux de texte ligne à ligne mène à la question 3. À la question 3, plus d'une centaine de lignes ou des branches nombreuses mènent à un autre langage ; un script court et linéaire reste en Bash, avec mode strict, ShellCheck et revue.

Mode strict Bash : set -e, set -u, pipefail et leurs limites

Section intitulée « Mode strict Bash : set -e, set -u, pipefail et leurs limites »

Par défaut, Bash continue après une commande en échec. C’est un comportement raisonnable pour un terminal interactif et désastreux pour un script qui supprime des fichiers à l’étape suivante. L’en-tête ci-dessous est le minimum défendable pour un script d’administration.

#!/bin/bash
set -Eeuo pipefail # arrêt sur erreur, variable non définie, tube en échec
shopt -s inherit_errexit # Bash 4.4 minimum
PATH=/usr/sbin:/usr/bin:/sbin:/bin # chemin fixé par le script, pas par l'appelant
export PATH LC_ALL=C # tri et messages d'erreur stables
umask 077 # fichiers créés lisibles par leur seul propriétaire
trap 'echo "echec ligne $LINENO: $BASH_COMMAND (code $?)" >&2' ERR

Ce mode strict est un filet de sécurité, pas une gestion d’erreur. Ses trous sont documentés dans le manuel de Bash :

  • Contextes où -e est ignoré. Une commande testée par if, while, until, précédée de ! ou placée avant && ou || ne provoque pas d’arrêt. Plus vicieux : une fonction appelée dans un de ces contextes (if ma_fonction; then, ma_fonction || true) voit -e désactivé pour tout son corps. Les échecs internes passent alors sans bruit.
  • Affectation masquée. local v=$(commande) renvoie le code de local, donc zéro, même quand commande échoue. Même effet avec export et readonly. Il faut déclarer puis affecter sur deux lignes.
  • Substitutions de commande. Sans inherit_errexit, le sous-shell d’une substitution $(...) n’hérite pas de -e.
  • Arithmétique. ((i++)) renvoie un code de retour 1 quand l’expression vaut zéro. Un compteur initialisé à 0 tue donc le script à sa première incrémentation.
  • Échecs légitimes. grep renvoie 1 quand il ne trouve rien, ce qui n’est pas une panne. Sous pipefail, commande | head -n 1 peut échouer avec le code 141, parce que le producteur reçoit SIGPIPE quand head ferme le tube.
  • set -u et le vide. Une variable vide n’est pas une variable non définie. rm -rf "$dir/"* avec dir vide vise la racine. La forme "${dir:?}" arrête le script dans les deux cas.

L’option -E propage le piège ERR aux fonctions et aux sous-shells ; sans elle, le message de diagnostic disparaît précisément là où il servirait. La conduite à tenir : activer le mode strict partout, puis tester explicitement le résultat des commandes dont dépend la suite (montage présent, archive non vide, espace disque). Croire que trois options remplacent ces vérifications revient à confondre ceinture de sécurité et permis de conduire.

Guillemets, découpage en mots et développement des noms de fichiers

Section intitulée « Guillemets, découpage en mots et développement des noms de fichiers »

Une expansion sans guillemets subit deux traitements. Elle est d’abord découpée en mots selon la variable IFS (Internal Field Separator : espace, tabulation et saut de ligne par défaut). Chaque mot est ensuite développé comme motif de noms de fichiers. Les guillemets doubles suppriment les deux.

Fenêtre de terminal
f='rapport final *.log'
rm $f # trois mots : "rapport", "final" et le motif *.log, développé en tous les .log du répertoire
rm "$f" # un argument : le fichier nommé « rapport final *.log »
rm -- "$f" # idem, et un nom commençant par un tiret reste un nom de fichier

Les règles qui en découlent :

  • Guillemets doubles autour de toute expansion, sans exception « parce que cette variable ne contient jamais d’espace ». Elle en contiendra.
  • Liste d’arguments dans un tableau, développé par "${args[@]}". Une commande construite dans une chaîne puis relancée finit toujours en découpage raté ou en eval.
  • Pas d’analyse de la sortie de ls. Un nom de fichier peut contenir un saut de ligne. Parcourir par motif (for f in ./*.log) ou par find -print0 relu avec mapfile -d '' ou xargs -0.
  • Motif sans correspondance. Par défaut il reste littéral : la boucle tourne une fois sur la chaîne ./*.log. shopt -s nullglob donne une liste vide, failglob une erreur ; l’un des deux doit être choisi explicitement.

[ est une commande ordinaire dont les arguments subissent toutes les expansions. [[ est un mot-clé de Bash, analysé avant expansion. Dans un script Bash, [[ ]] s’impose ; [ ] ne se justifie que sous sh.

Point[ ][[ ]]
Naturecommande POSIX (Portable Operating System Interface)mot-clé propre à Bash
Variable sans guillemetsdécoupée puis développéeni découpage ni développement de noms
== avec partie droite sans guillemetscomparaison littéralecomparaison à un motif
< et >redirections, sauf échappementcomparaison de chaînes
Expressions rationnellesabsentesopérateur =~

Un piège survit au passage à [[ ]]. Les opérateurs numériques (-eq, -lt) et (( )) évaluent leurs opérandes comme des expressions arithmétiques, indices de tableau compris. Avec x='a[$(id)]', le test [[ $x -eq 0 ]] exécute id. Toute valeur externe destinée à un contexte numérique se valide donc d’abord par motif ([[ $x =~ ^[0-9]+$ ]]).

Injection de commande : eval, données non fiables et noms commençant par un tiret

Section intitulée « Injection de commande : eval, données non fiables et noms commençant par un tiret »

Une injection survient quand une donnée est relue comme du code ou comme une option. En Bash, les occasions de relecture sont nombreuses et rarement signalées. Le schéma liste les sources à gauche, les points de relecture à droite ; chaque flèche est une attaque documentée.

Schéma en deux colonnes : trois sources à gauche, sept points de relecture à droite, reliés par des flèches. Un nom de fichier est interprété comme option par une commande externe s'il commence par un tiret et que -- est absent, ou devient une option via le motif * s'il est nommé comme une option. Un argument ou une saisie est relu comme du code par eval, bash -c ou sh -c, relu une seconde fois côté serveur par le shell distant via ssh, exécuté par substitution de commande dans un indice de tableau en contexte arithmétique, ou pris comme chaîne de format par printf s'il est en premier argument. L'environnement transmet PATH, BASH_ENV et IFS hérités de l'appelant à Bash au démarrage.

Les contre-mesures, dans l’ordre d’efficacité :

  1. Supprimer la relecture. Pas de eval. Pas de bash -c "... $var" : la donnée se passe en argument positionnel (bash -c 'traiter "$1"' _ "$var"). Même règle pour find -exec sh -c, où {} ne s’insère jamais dans la chaîne de commande.
  2. Terminer les options. -- avant tout argument issu d’une variable, et ./* plutôt que *, pour qu’aucun nom ne commence par un tiret. La commande tar des distributions Linux accepte par exemple les options --checkpoint=1 et --checkpoint-action=exec=... ; deux fichiers portant ces noms, ramassés par un *, deviennent une exécution de commande. echo n’honore pas -- : printf '%s\n' "$var" le remplace.
  3. Échapper quand la relecture est imposée. SSH (Secure Shell) concatène ses arguments et les confie au shell distant. printf '%q' ou ${var@Q} produisent une forme relisible par Bash, pas forcément par un sh distant.
  4. Traiter l’environnement comme une entrée. Shellshock (CVE-2014-6271, CVE signifiant Common Vulnerabilities and Exposures) l’a démontré à grande échelle : Bash exécutait, dès son démarrage, du code placé après une définition de fonction dans une variable d’environnement. Le correctif date de 2014, la leçon reste. Un script lancé par un tiers fixe son PATH, ignore ce dont il n’a pas besoin et se lance, si nécessaire, sous env -i.

read -r s’ajoute à la liste pour toute lecture de ligne : sans -r, les barres obliques inverses sont interprétées. La donnée « interne et de confiance » est en général celle dont personne n’a regardé la provenance.

Un nom prévisible dans /tmp (/tmp/export.$$, où $$ est l’identifiant du processus) permet à un compte local de créer à l’avance un lien symbolique à cet endroit. Le script, lancé en root, écrit alors dans la cible du lien. Le réglage noyau fs.protected_symlinks atténue l’attaque sur Linux ; il ne dispense pas d’écrire correctement.

mktemp crée le fichier de façon atomique, avec un nom imprévisible et des droits réservés au propriétaire ; mktemp -d fait de même pour un répertoire. Le nettoyage se confie à trap ... EXIT, posé juste après la création. Deux limites sont à connaître. Aucun piège ne s’exécute sur SIGKILL ni sur une coupure d’alimentation, donc le script doit tolérer des restes d’une exécution précédente. Et sous systemd, PrivateTmp=yes donne au service son propre /tmp, ce qui règle la question à la racine.

Même logique pour les fichiers produits. Écrire dans un fichier temporaire du même système de fichiers que la destination, puis renommer avec mv : le renommage y est atomique, et un lecteur ne voit jamais un fichier à moitié écrit.

Un script qui fonctionne dans un terminal et échoue une fois planifié n’a pas un problème de planificateur, il a un problème d’hypothèses. Les trois lanceurs usuels fournissent trois environnements différents, dont aucun ne ressemble à une session interactive.

LanceurPATH et environnementInterprétationSortiePiège propre
cronminimal, souvent /usr/bin:/bin ; aucun fichier de profil chargé/bin/sh par défautcourriel au propriétaire de la crontab ou à l’adresse MAILTO, perdue si la machine n’a pas de messagerie locale% vaut saut de ligne dans une crontab, sauf échappement
unité systemdvaleur par défaut fixée à la compilation ; Environment= et EnvironmentFile=aucun shell : ExecStart= n’interprète ni tube ni redirectionjournal, rattachée à l’unitérépertoire courant / sans WorkingDirectory=
sudopurgé par env_reset, sauf liste env_keep ; PATH remplacé si secure_path est définicelle du script lancéterminal de l’appelantrépertoire courant choisi par l’appelant

Trois règles en sortent. Le script fixe lui-même un PATH absolu, sans répertoire relatif ni inscriptible par un autre compte. Il ne suppose rien du répertoire courant : cd explicite, dont l’échec arrête tout. Et une règle sudo qui autorise un script modifiable par l’appelant, ou placé dans un répertoire qu’il contrôle, équivaut à lui donner root, avec une étape de plus pour la forme.

Secrets hors de la ligne de commande et hors du script

Section intitulée « Secrets hors de la ligne de commande et hors du script »

Les arguments d’un processus sont lisibles par tout compte local, via ps ou /proc/PID/cmdline (PID pour Process IDentifier), sauf montage de /proc avec l’option hidepid. Un mot de passe passé en option est donc publié sur la machine pendant toute la durée de la commande, et parfois archivé par l’audit des exécutions.

L’environnement fait à peine mieux : /proc/PID/environ est réservé au propriétaire et à root, mais la variable est héritée par tous les processus enfants et ressort dans les traces de débogage. set -x affiche chaque commande après expansion, secrets compris ; l’activer dans une chaîne d’intégration revient à les écrire dans les journaux de build.

Ce qui tient : un fichier aux droits 0600, lu par la commande cible elle-même (option de type fichier de mot de passe) ou transmis sur son entrée standard. Les commandes internes de Bash comme printf ne créent pas de processus, donc printf '%s' "$secret" | commande n’expose rien dans la table des processus. Sous systemd 247 ou plus récent, LoadCredential= dépose le secret dans un répertoire privé, désigné par $CREDENTIALS_DIRECTORY ; la version 250 ajoute la variante chiffrée.

Un secret écrit dans le script est un secret écrit dans Git, donc dans chaque clone et chaque sauvegarde ; la détection en amont est traitée dans Prévenir les secrets dans le code source. Reste l’exploitation : une rotation n’a de sens que si le script relit le fichier à chaque exécution au lieu de garder la valeur dans une copie.

Deux exécutions simultanées du même script de purge ou de synchronisation produisent un état que ni l’une ni l’autre n’a prévu. Le fichier contenant un numéro de processus, vérifié puis écrit, souffre d’une course entre le test et l’écriture, et survit aux arrêts brutaux. flock pose un verrou sur un descripteur ouvert : le noyau le libère à la mort du processus, quelle qu’en soit la cause.

Fenêtre de terminal
exec 9>/run/purge-archives/lock # le descripteur 9 porte le verrou
flock -n 9 || { echo "execution deja en cours, abandon" >&2; exit 75; }
# suite du script : le noyau relâche le verrou quand le processus se termine

Trois pièges. Le descripteur est hérité par les processus enfants : un service lancé en arrière-plan depuis le script garde le verrou après sa fin. Le fichier doit vivre dans un répertoire réservé au compte du script (RuntimeDirectory= sous systemd le crée), sinon n’importe quel compte peut tenir le verrou et bloquer le traitement. Et ce fichier ne se supprime pas en fin d’exécution, sous peine de recréer la course. À noter : un minuteur systemd ne relance pas une unité encore active, ce qui couvre le cas courant sans une ligne de script.

Pour la journalisation : les diagnostics vont sur la sortie d’erreur, les données sur la sortie standard, et rien d’autre ne se mélange. Sous systemd, le journal capte les deux et les rattache à l’unité, horodatage compris ; ailleurs, logger -t envoie vers le journal système. Un fichier de log maison sans rotation est une panne de disque à échéance inconnue.

Le code de retour est l’interface du script avec la supervision :

  • 0 pour un succès, et uniquement pour un succès vérifié ;
  • 1 à 125 pour les échecs du script, avec des valeurs distinctes et documentées par cause (75, le EX_TEMPFAIL de sysexits.h, convient à « réessayer plus tard ») ;
  • 126 et 127 produits par le shell : commande non exécutable, commande introuvable ;
  • 128 + N pour une mort par signal N : 137 pour SIGKILL, 143 pour SIGTERM.

La valeur est tronquée à 8 bits : exit 256 vaut 0. Côté supervision, alerter sur l’échec (OnFailure= sous systemd) ne suffit pas : un script qui ne se lance plus n’échoue pas. Il faut aussi alerter sur l’absence d’un signal de bonne fin.

ShellCheck est un analyseur statique qui repère la plupart des erreurs décrites ici : SC2086 (expansion sans guillemets), SC2046 (substitution sans guillemets), SC2155 (déclaration et affectation sur la même ligne), SC2164 (cd sans traitement de l’échec). Il se branche dans la chaîne d’intégration comme contrôle bloquant, au même rang que les tests de sécurité automatisés et les autres garde-fous d’un pipeline CI/CD (Continuous Integration / Continuous Delivery) sécurisé. Toute directive # shellcheck disable= porte un commentaire qui la justifie.

Ce qu’il ne garantit pas : il ne suit pas la provenance des données, ne connaît ni les droits des fichiers ni l’environnement du lanceur, et ne dit rien de la logique. Un script sans avertissement est un script sans faute de syntaxe dangereuse, pas un script sûr.

La portabilité se décide à la première ligne. /bin/sh est dash sur Debian et Ubuntu, et un ash BusyBox sur Alpine, où Bash n’est pas installé par défaut (point à vérifier dans la généalogie des images de base). [[ ]], les tableaux, la substitution de processus <( ) et le mot-clé function n’existent pas dans POSIX. pipefail n’est entré dans POSIX qu’avec l’édition 2024 de la norme, que tous les sh déployés n’implémentent pas. Un script en #!/bin/sh rempli de constructions Bash fonctionne là où sh est un lien vers Bash et casse ailleurs, en général le jour d’une migration.

Le choix dépend d’une seule question : la cible est-elle maîtrisée ? Sur un parc où Bash est installé, écrire du Bash et le déclarer. sh POSIX se réserve aux conteneurs minimalistes, aux environnements de démarrage et aux scripts d’installation destinés à des systèmes inconnus, en acceptant de perdre les tableaux et [[ ]].

Exemple : déroulé d’un script de purge planifié

Section intitulée « Exemple : déroulé d’un script de purge planifié »

Le schéma montre l’ordre des protections dans un script qui purge des archives anciennes, lancé par un minuteur systemd. Chaque étape protège la suivante. Les trois sorties portent des codes distincts, ce qui permet à la supervision de séparer « rien à faire », « déjà en cours » et « cassé ».

Chaîne d'étapes verticale d'un script de purge. Le minuteur systemd lance le script sous un compte dédié, avec l'environnement de l'unité ; le script pose son en-tête strict, fixe PATH et umask 077, puis prend un verrou par flock non bloquant. Si le verrou est déjà tenu, message sur la sortie d'erreur et sortie 75. Si le verrou est obtenu, viennent les vérifications : répertoire cible absent, vide ou non monté mène à la sortie 1. Sinon mktemp -d et piège de nettoyage, purge par find -print0 et suppression avec --, journal du nombre de fichiers et de la durée, puis sortie 0, signal de bonne fin attendu par la supervision à chaque cycle.

L’ordre compte. Le verrou vient avant toute écriture. La vérification du montage précède la purge : un répertoire non monté ressemble à un répertoire vide, ou pire, à la racine. Le piège de nettoyage se pose dès que le répertoire temporaire existe, pas trois lignes plus loin.

Faut-il écrire #!/bin/bash ou #!/usr/bin/env bash ?

Section intitulée « Faut-il écrire #!/bin/bash ou #!/usr/bin/env bash ? »

env cherche Bash dans le PATH de l’appelant. C’est commode pour un outil de développement, et indésirable pour un script système lancé avec des privilèges, qui laisse ainsi l’environnement choisir son interpréteur. Pour un script d’administration sur un parc connu, le chemin absolu /bin/bash est le bon choix.

Non : le noyau Linux ignore ce bit sur les scripts interprétés, précisément à cause des courses qu’il permettrait. L’élévation passe par une règle sudo restreinte à un script que l’appelant ne peut pas modifier, ou par une unité systemd exécutée sous le compte voulu.

Sans ces garde-fous, un script d’administration échoue en silence et réussit en apparence : une commande ratée au milieu laisse la suite s’exécuter sur un état faux, et le code de retour final annonce zéro. La supervision ne voit rien, puisqu’elle lit ce code. Une sauvegarde vide se découvre le jour de la restauration. Côté attaque, tout compte local capable de créer un fichier au nom choisi, de positionner une variable d’environnement ou d’écrire dans un répertoire partagé obtient un levier sur un processus qui tourne en root, sans exploiter la moindre vulnérabilité logicielle : le script fait le travail à sa place. Et faute de journal, l’analyse après coup se réduit à constater qu’il manque des fichiers.

Un script Bash est un fichier texte contenant des commandes que l’ordinateur exécute l’une après l’autre, sans personne devant l’écran. Par défaut, quand une commande échoue, le script passe à la suivante sans rien dire : il faut donc lui demander explicitement de s’arrêter à la première erreur, et contrôler à la main les étapes qui comptent, car cet arrêt automatique a des trous. Ensuite, chaque valeur manipulée (un nom de fichier, un texte venu d’ailleurs) est redécoupée et réinterprétée par le programme, sauf si elle est entourée de guillemets : sans eux, un nom contenant un espace devient deux noms, et un texte écrit par un inconnu peut devenir une commande. Le script dépend aussi de ce qui l’entoure : la liste des dossiers où il cherche ses programmes change selon qui le lance, donc il la fixe lui-même au début. Les mots de passe ne s’écrivent ni dans le fichier ni à la suite d’une commande, car les autres utilisateurs de la machine peuvent les y lire ; ils se lisent dans un fichier protégé. Un verrou empêche deux exemplaires de tourner en même temps. Enfin, le script écrit ce qu’il fait et termine par un nombre : zéro si tout va bien, autre chose sinon. Si ce nombre ment, la surveillance ne voit rien et la panne se découvre trop tard.

À lire aussi

Pages voisines

: les deux pages se citent l’une l’autre.