Écrire des scripts Bash système fiables et sûrs en production
- Accueil
- Développement, applications & chaîne logicielle
- É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.
Un script, c’est une liste de consignes laissée à un employé infatigable qui prend tout au pied de la lettre et possède les clés de tout le bâtiment. Bien rédigée, la liste fait gagner des heures chaque nuit. Mal rédigée, elle fait vider la mauvaise pièce sans que personne ne s’en aperçoive avant le matin.
→ Lire l’explication complète de la marmotte
Prérequis : version de Bash et outils attendus
Section intitulée « Prérequis : version de Bash et outils attendus »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.
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/bashset -Eeuo pipefail # arrêt sur erreur, variable non définie, tube en échecshopt -s inherit_errexit # Bash 4.4 minimumPATH=/usr/sbin:/usr/bin:/sbin:/bin # chemin fixé par le script, pas par l'appelantexport PATH LC_ALL=C # tri et messages d'erreur stablesumask 077 # fichiers créés lisibles par leur seul propriétairetrap 'echo "echec ligne $LINENO: $BASH_COMMAND (code $?)" >&2' ERRCe 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ù
-eest ignoré. Une commande testée parif,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-edésactivé pour tout son corps. Les échecs internes passent alors sans bruit. - Affectation masquée.
local v=$(commande)renvoie le code delocal, donc zéro, même quandcommandeéchoue. Même effet avecexportetreadonly. 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.
greprenvoie 1 quand il ne trouve rien, ce qui n’est pas une panne. Souspipefail,commande | head -n 1peut échouer avec le code 141, parce que le producteur reçoitSIGPIPEquandheadferme le tube. set -uet le vide. Une variable vide n’est pas une variable non définie.rm -rf "$dir/"*avecdirvide 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.
f='rapport final *.log'rm $f # trois mots : "rapport", "final" et le motif *.log, développé en tous les .log du répertoirerm "$f" # un argument : le fichier nommé « rapport final *.log »rm -- "$f" # idem, et un nom commençant par un tiret reste un nom de fichierLes 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 eneval. - 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 parfind -print0relu avecmapfile -d ''ouxargs -0. - Motif sans correspondance. Par défaut il reste littéral : la boucle tourne une fois sur la chaîne
./*.log.shopt -s nullglobdonne une liste vide,failglobune erreur ; l’un des deux doit être choisi explicitement.
[[ ]] face à [ ]
Section intitulée « [[ ]] face à [ ] »[ 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 | [ ] | [[ ]] |
|---|---|---|
| Nature | commande POSIX (Portable Operating System Interface) | mot-clé propre à Bash |
| Variable sans guillemets | découpée puis développée | ni découpage ni développement de noms |
== avec partie droite sans guillemets | comparaison littérale | comparaison à un motif |
< et > | redirections, sauf échappement | comparaison de chaînes |
| Expressions rationnelles | absentes | opé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.
Les contre-mesures, dans l’ordre d’efficacité :
- Supprimer la relecture. Pas de
eval. Pas debash -c "... $var": la donnée se passe en argument positionnel (bash -c 'traiter "$1"' _ "$var"). Même règle pourfind -exec sh -c, où{}ne s’insère jamais dans la chaîne de commande. - Terminer les options.
--avant tout argument issu d’une variable, et./*plutôt que*, pour qu’aucun nom ne commence par un tiret. La commandetardes distributions Linux accepte par exemple les options--checkpoint=1et--checkpoint-action=exec=...; deux fichiers portant ces noms, ramassés par un*, deviennent une exécution de commande.echon’honore pas--:printf '%s\n' "$var"le remplace. - É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 unshdistant. - 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, sousenv -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.
Fichiers temporaires : mktemp et trap
Section intitulée « Fichiers temporaires : mktemp et trap »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.
PATH et environnement sous cron, systemd et sudo
Section intitulée « PATH et environnement sous cron, systemd et sudo »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.
| Lanceur | PATH et environnement | Interprétation | Sortie | Piège propre |
|---|---|---|---|---|
| cron | minimal, souvent /usr/bin:/bin ; aucun fichier de profil chargé | /bin/sh par défaut | courriel 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é systemd | valeur par défaut fixée à la compilation ; Environment= et EnvironmentFile= | aucun shell : ExecStart= n’interprète ni tube ni redirection | journal, rattachée à l’unité | répertoire courant / sans WorkingDirectory= |
| sudo | purgé par env_reset, sauf liste env_keep ; PATH remplacé si secure_path est défini | celle du script lancé | terminal de l’appelant | ré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.
Verrou flock, journalisation et codes de retour
Section intitulée « Verrou flock, journalisation et codes de retour »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.
exec 9>/run/purge-archives/lock # le descripteur 9 porte le verrouflock -n 9 || { echo "execution deja en cours, abandon" >&2; exit 75; }# suite du script : le noyau relâche le verrou quand le processus se termineTrois 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_TEMPFAILdesysexits.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 pourSIGTERM.
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 et portabilité sh POSIX face à Bash
Section intitulée « ShellCheck et portabilité sh POSIX face à Bash »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é ».
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.
Un script Bash peut-il porter le bit setuid ?
Section intitulée « Un script Bash peut-il porter le bit setuid ? »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.
🐝 Ce qui arrive si on s’en passe
Section intitulée « 🐝 Ce qui arrive si on s’en passe »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.
🦫 Explication de la marmotte 🍫
Section intitulée « 🦫 Explication de la marmotte 🍫 »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
- Pollution de variable d'environnement héritée dans un conteneurDémonstration en laboratoire du détournement du comportement d'un processus enfant via une variable d'environnement injectable dans un conteneur, avec le mécanisme d'héritage et les contre-mesures d'assainissement.
- JavaScript et SQL côté web, ce qu'il faut savoir des deux langagesCe qu'il faut connaître du modèle d'exécution de JavaScript et de SQL pour lire un code web, relire une revue de sécurité ou interpréter un rapport de scan.
- Élévation de privilège via un binaire SUID root mal configuréDémonstration en laboratoire de l'élévation de privilège locale par abus d'un exécutable SUID root trop permissif, avec le mécanisme du bit SUID et les contre-mesures d'audit et de capabilities.
- Détournement de bibliothèque partagée Linux et ld.soOrdre de recherche du chargeur dynamique Linux, LD_PRELOAD, RPATH et RUNPATH, mode secure-execution et contre-mesures contre le détournement de bibliothèque partagée.
Pages voisines
La citent
- JavaScript et SQL côté web, ce qu'il faut savoir des deux langages
- Langages informatiques : histoire, inventeurs et usages
Écrire des scripts Bash système fiables et sûrs en production
Elle cite
- Lien dans les deux sens :Écrire un outil jetable proprement, sans faille ni dette
- Python pour l'automatisation défensive, usages et garde-fous
- Images Docker de base et confiance héritée
- Intégrer SAST, DAST et IAST dans une CI/CD
- Prévenir les secrets codés en dur dans Git
- Sécuriser les secrets et permissions CI/CD
- Linux shell, permissions et processus, le modèle de contrôle d'accès
- Systemd : gérer services, unités et journal sous Linux
: les deux pages se citent l’une l’autre.
