Comment programmer en toute sécurité dans bash

Pourquoi bash?


Il y a des tableaux et un mode sans échec dans bash. Lorsqu'il est utilisé correctement, bash est presque compatible avec les pratiques de codage sûres.

Il est plus difficile de faire une erreur dans le poisson, mais il n'y a pas de mode sans échec. Par conséquent, le prototypage chez le poisson et la traduction du poisson en bash devraient être une bonne idée si vous savez comment le faire correctement.

Préface


Ce guide accompagne ShellHarden, mais l'auteur recommande également ShellCheck afin que les règles ShellHarden ne s'écartent pas de ShellCheck.

Bash n'est pas une langue où la manière la plus correcte de résoudre un problème en même temps est la plus simple . Si vous passez l'examen de programmation sécurisée bash, la première règle de BashPitfalls serait: utilisez toujours des guillemets.

La principale chose que vous devez savoir sur la programmation en bash


Guillemets maniaques! Une variable non cotée doit être considérée comme une bombe armée: elle explose au contact d'un espace. Oui, il explose dans le sens de diviser une chaîne en un tableau . En particulier, les extensions de variables comme $var et les substitutions de commandes comme $(cmd) sont divisées en mots lorsque la chaîne interne est développée dans un tableau en raison de la division en une variable $IFS spéciale avec un espace par défaut. Ceci est généralement invisible, car le plus souvent le résultat est un tableau de 1 élément, impossible à distinguer de la chaîne attendue.

Non seulement cela est étendu, mais aussi les caractères génériques ( *? ). Ce processus se produit après que le mot est divisé, donc s'il y a au moins un caractère générique dans le mot, le mot se transforme en caractère générique qui s'applique à tous les chemins de fichier appropriés. Cette fonctionnalité commence donc à s'appliquer au système de fichiers!

La citation supprime le fractionnement de mots et l'expansion de modèle pour les variables et les substitutions de commandes.

Extension variable:

  • Bon: "$my_var"
  • Mauvais: $my_var

Substitution de commande:

  • Bon: "$(cmd)"
  • Mauvais: $(cmd)

Il y a des exceptions avec des guillemets facultatifs, mais les guillemets ne feront jamais de mal, et la règle générale est de faire attention à ne pas citer de variables sans guillemets, donc nous ne chercherons pas les exceptions de bordure à votre avantage. Cela semble faux et la mauvaise pratique est suffisamment répandue pour éveiller les soupçons: de nombreux scripts ont été écrits avec un traitement défectueux des noms de fichiers et des espaces en eux ...

ShellHarden ne mentionne que quelques exceptions - ces variables ont-elles un contenu numérique tel que $? , $# et ${#array[@]} .

Dois-je utiliser des backticks?


Les substitutions de commandes peuvent également prendre la forme suivante:

  • Correct: "`cmd`"
  • Mauvais: `cmd`

Bien que ce style puisse être utilisé correctement, il semble moins pratique entre guillemets et moins lisible lorsqu'il est imbriqué. Le consensus ici est assez clair: évitez-le.

ShellHarden réécrit ces coches entre parenthèses en dollars.

Faut-il utiliser des accolades bouclées?


Les parenthèses sont utilisées pour interpoler les chaînes, elles sont donc généralement redondantes:

  • Mauvais: some_command $arg1 $arg2 $arg3
  • Pauvre et verbeux: some_command ${arg1} ${arg2} ${arg3}
  • Bon, mais détaillé: some_command "${arg1}" "${arg2}" "${arg3}"
  • Bon: some_command "$arg1" "$arg2" "$arg3"

Théoriquement, toujours utiliser des accolades n'est pas un problème, mais selon l'expérience de votre auteur, il existe une forte corrélation négative entre l'utilisation inutile des accolades et l'utilisation correcte des guillemets - presque tout le monde choisit la forme «mauvaise et verbeuse» au lieu de la forme «bonne mais verbeuse»!

Théories de votre auteur:

  • Par crainte de faire quelque chose de mal: au lieu du vrai danger (manque de guillemets), les débutants peuvent craindre que la variable $prefix provoque l' "$prefix_postfix" variable "$prefix_postfix" , mais cela ne fonctionne pas de cette façon.
  • Culte du fret: écrire du code dans l'alliance de la mauvaise peur qui l'a précédé.
  • Les crochets rivalisent avec les guillemets pour la limite de verbosité autorisée.

Par conséquent, il a été décidé d'interdire les accolades inutiles: ShellHarden remplace ces options par la forme la plus simple.

Et maintenant sur l'interpolation de chaînes, où les accolades sont vraiment utiles:

  • Mauvais (concaténation): $var1"more string content"$var2
  • Bon (concaténation): "$var1""more string content""$var2"
  • Bon (interpolation): "${var1}more string content${var2}"

La concaténation et l'interpolation en bash sont équivalentes même dans les tableaux (ce qui est ridicule).

Étant donné que ShellHarden ne met pas en forme les styles, il n'est pas censé modifier le code correct. Cela est vrai pour l'option «bonne (interpolation)»: du point de vue ShellHarden, ce sera la forme canoniquement correcte.

ShellHarden ajoute et supprime maintenant des accolades selon les besoins: dans un mauvais exemple, var1 est fourni avec des crochets, mais ils ne sont pas autorisés pour var2 même dans le cas de "bon (interpolation)", car ils ne sont jamais nécessaires à la fin de la ligne. La dernière exigence pourrait bien être inversée.

Gotcha: arguments numérotés


Contrairement aux noms d' identificateurs de variables normaux (dans l'expression rationnelle: [_a-zA-Z][_a-zA-Z0-9]* ), les arguments numérotés nécessitent des crochets (l'interpolation de ligne non). ShellCheck dit:

 echo "$10" ^-- SC1037: Braces are required for positionals over 9, eg ${10}. 

ShellHarden refuse de le réparer (considère la différence trop subtile).

Comme les parenthèses sont autorisées jusqu'à 9, ShellHarden les autorise pour tous les arguments numérotés.

Utilisation de tableaux


Pour pouvoir citer toutes les variables, vous devez utiliser de vrais tableaux, pas des chaînes pseudo-massives séparées par des espaces.

La syntaxe est détaillée, mais vous devez la gérer. Ce bashisme n'est qu'une des raisons pour abandonner la compatibilité POSIX pour la plupart des scripts shell.

Bon:

 array=( a b ) array+=(c) if [ ${#array[@]} -gt 0 ]; then rm -- "${array[@]}" fi 

Mauvais:

 pseudoarray=" \ a \ b \ " pseudoarray="$pseudoarray c" if ! [ "$pseudoarray" = '' ]; then rm -- $pseudoarray fi 

C'est pourquoi les tableaux sont une fonction de base pour un shell: les arguments des commandes sont fondamentalement des tableaux (et les scripts shell sont des commandes et des arguments). On peut dire que la coque, qui rend artificiellement impossible de passer plusieurs arguments, sera comique et sans valeur. Certains obus courants de cette catégorie incluent Dash et Busybox Ash. Ce sont des shells compatibles POSIX minimes - mais à quoi sert la compatibilité si le plus important n'est pas sur POSIX?

Cas exceptionnels où vous allez vraiment casser une ligne


Exemple avec \v comme séparateur de données (notez la deuxième occurrence):

 IFS=$'\v' read -d '' -ra a < <(printf '%s\v' "$s") || true 

De cette façon, nous évitons l'expansion du modèle et la méthode fonctionne même si le séparateur de données est \n . La deuxième occurrence du séparateur de données protège le dernier élément s'il s'avère être un espace. Pour une raison quelconque, l'option -d devrait être -rad '' en premier, donc -rad '' options dans -rad '' tentant, mais cela ne fonctionnera pas. Puisque read renvoie une valeur différente de zéro dans ce cas, elle doit être protégée contre errexit ( || true ), si elle est activée. Testé en bash 4.0, 4.1, 4.2, 4.3 et 4.4.

Alternative pour bash 4.4:

 readarray -td $'\v' a < <(printf '%s\v' "$s") 

Où démarrer un script bash


De quelque chose comme ça:

 #!/usr/bin/env bash if test "$BASH" = "" || "$BASH" -uc "a=();true \"\${a[@]}\"" 2>/dev/null; then # Bash 4.4, Zsh set -euo pipefail else # Bash 4.3 and older chokes on empty arrays with set -u. set -eo pipefail fi shopt -s nullglob globstar 

Cela comprend:

  • Shebang:
    • Problèmes de portabilité: le chemin absolu vers env probablement meilleur pour la portabilité que le chemin absolu vers bash . Vous pouvez regarder l'exemple de NixOS . POSIX nécessite env , mais pas bash.
    • Problèmes de sécurité: Pour aucune langue, les options telles que -euo pipefail ne seront pas acceptées favorablement -euo pipefail ! Cela devient impossible lorsque vous utilisez la redirection env , mais même si votre shebang commence par #!/bin/bash , ce n'est pas l'endroit pour les paramètres qui affectent la valeur du script, car ils peuvent être remplacés, ce qui permettra d'exécuter le script de manière incorrecte. Cependant, en bonus, les options qui n'affectent pas la valeur du script, telles que set -x , si elles sont utilisées, peuvent être redéfinies.
  • De quoi avons-nous besoin du mode strict non officiel de Bash , avec la vérification de la fonction set -u . Nous n'avons pas besoin de tout le mode Bash strict, car la compatibilité shellcheck / shellharden signifie citer tout et tout ce qui est beaucoup plus strict. De plus, l'option set -u ne doit pas être utilisée dans Bash 4.3 et versions antérieures. Étant donné que cette option considère les tableaux vides comme ignorés dans ces versions, les tableaux ne peuvent pas être utilisés aux fins décrites ici. L'utilisation de tableaux est la deuxième astuce la plus importante de ce guide (après les guillemets) et la seule raison pour laquelle nous sacrifions la compatibilité avec POSIX, donc ce n'est en aucun cas inacceptable: soit n'utilisez pas du tout set -u , soit utilisez Bash 4.4 ou un autre shell normal comme Zsh. C'est plus facile à dire qu'à faire, car il est possible que quelqu'un exécute toujours votre script dans l'ancienne version de Bash. Heureusement, tout ce qui fonctionne avec set -u fonctionnera sans lui (pour set -e vous ne pouvez pas le dire). C'est pourquoi il est important d'utiliser la vérification de version. Méfiez-vous de l'hypothèse que les tests et le développement ont lieu dans un shell compatible avec Bash 4.4 (donc l'aspect set -u est testé). Si cela vous dérange, une autre option consiste à refuser la compatibilité (le script échoue lorsque la vérification de la version échoue), ou à refuser set -u .
  • shopt -s nullglob oblige for f in *.txt à fonctionner correctement si *.txt ne trouve pas de fichiers. Le comportement par défaut (aka passglob ) transmet le modèle inchangé, ce qui en cas de résultat nul est dangereux pour plusieurs raisons. Pour globstar, cela active la recherche récursive. La substitution est plus facile à utiliser qu'à find . Alors utilisez-le.

Mais pas:

 IFS='' set -f shopt -s failglob 

  • La définition du délimiteur de champ interne sur une chaîne vide rend impossible le fractionnement du mot. Cela ressemble à la solution parfaite. Malheureusement, il s'agit d'un remplacement incomplet pour les guillemets et les substitutions de commandes, et puisque vous allez utiliser des guillemets, cela ne donne rien. La raison pour laquelle les guillemets doivent encore être utilisés est que, sinon, les chaînes vides deviennent des tableaux vides (comme dans le test $x = "" ) et l'expansion indirecte du modèle est toujours possible. De plus, des problèmes avec cette variable entraîneront également des problèmes avec des commandes comme read , ce qui casse des constructions comme cat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done' cat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done' cat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done' .
  • L'extension du modèle est désactivée: non seulement l'extension indirecte infâme, mais aussi l'extension directe sans tracas, que, comme je l'ai dit, vous devriez utiliser. C'est donc difficile à accepter. Et cela est également complètement facultatif pour un script compatible shellcheck / shellharden.
  • Contrairement à nullglob , failglob échoue avec un résultat nul. Bien que cela soit logique pour la plupart des commandes, par exemple, rm -- *.txt (car pour la plupart des commandes, il n'est pas prévu de l'exécuter avec un résultat nul), évidemment failglob ne peut être utilisé que si vous n'attendez pas un résultat nul. Cela signifie qu'en général, vous ne placerez pas de modèles de groupe dans les arguments de commande, sauf si vous supposez la même chose. Mais ce qui peut toujours arriver, c'est d'utiliser nullglob et d'étendre le modèle à des arguments null dans des constructions qui peuvent les prendre, comme une boucle ou assigner des valeurs à un tableau ( txt_files=(*.txt) ).

Comment terminer un script bash


L'état de sortie du script est l'état de la dernière commande exécutée. Assurez-vous qu'il représente un véritable succès ou un échec.

Le pire est de laisser la solution à une condition indépendante sous la forme d'une liste ET à la fin du script. Si la condition est fausse, la dernière commande exécutée sera la condition elle-même.

Pour errexit, les conditions sous la forme d'une liste ET ne sont jamais utilisées en premier lieu. Si errexit n'est pas utilisé, envisagez de gérer les erreurs même pour la dernière commande, afin que son état de sortie ne soit pas masqué si du code supplémentaire est ajouté au script.

Mauvais:

 condition && extra_stuff 

Bon (option errexit):

 if condition; then extra_stuff fi 

Bon (option de gestion des erreurs):

 if condition; then extra_stuff || exit fi exit 0 

Comment utiliser errexit


Comme set -e .

Nettoyage différé au niveau du programme


Si errexit fonctionne comme il se doit, utilisez-le pour installer tout nettoyage nécessaire à la sortie.

 tmpfile="$(mktemp -t myprogram-XXXXXX)" cleanup() { rm -f "$tmpfile" } trap cleanup EXIT 

Pris: errexit est ignoré dans les arguments de commande


Voici une "bombe" de branchement très délicate, dont la compréhension valait beaucoup pour moi. Mon script de build a bien fonctionné sur différentes machines de développement, mais a mis le serveur de build à genoux:

 set -e # Fail if nproc is not installed make -j"$(nproc)" 

Correct (substitution de commande dans la tâche):

 set -e # Fail if nproc is not installed jobs="$(nproc)" make -j"$jobs" 

Avertissement: local commandes intégrées local et d' export restent des commandes, donc cela reste faux:

 set -e # Fail if nproc is not installed local jobs="$(nproc)" make -j"$jobs" 

ShellCheck ne met en garde que contre les commandes spéciales comme local dans ce cas.

Pour utiliser local , séparez la déclaration du travail:

 set -e # Fail if nproc is not installed local jobs jobs="$(nproc)" make -j"$jobs" 

Pris: l'errexit est ignoré selon le contexte de l'appelant


Parfois, POSIX est terrible. Errexit est ignoré dans les fonctions, les commandes de groupe et même les sous-coquilles si l'appelant vérifie sa réussite. Tous ces exemples impriment Unreachable Great success Unreachable et Great success , aussi étrange que cela puisse paraître.

Sous-coque:

 ( set -e false echo Unreachable ) && echo Great success 

Équipe de groupe:

 { set -e false echo Unreachable } && echo Great success 

Fonction:

 f() { set -e false echo Unreachable } f && echo Great success 

Pour cette raison, bash avec errexit est pratiquement inapproprié pour la liaison: oui, il est possible d' envelopper les fonctions errexit pour qu'elles fonctionnent, mais il y a des doutes que l'effort économisé (sur la gestion explicite des erreurs) en vaut la peine. Envisagez plutôt de vous scinder en scripts entièrement autonomes.

Éviter d'appeler le shell avec des guillemets incorrects


Lors de l'appel de commandes à partir d'autres langages de programmation, il est plus facile de faire une erreur et d'appeler implicitement le shell. Si cette commande shell est statique, c'est bien - ça marche ou pas. Mais si votre programme traite en quelque sorte les lignes pour construire cette commande, alors vous devez comprendre - vous générez un script shell ! J'ai rarement envie de faire ça, et c'est très fatigant de tout arranger correctement:

  • citer chaque argument;
  • échapper les caractères correspondants dans les arguments.

Quel que soit le langage de programmation dans lequel vous effectuez cette opération, il existe au moins trois façons de constituer correctement une équipe. Par ordre de préférence:

Plan A: se passer d'une coque


S'il s'agit simplement d'une commande avec des arguments (c'est-à-dire, aucune fonction shell comme le pipelining ou la redirection), sélectionnez une option de tableau.

  • Mauvais (python3): subprocess.check_call('rm -rf ' + path)
  • Bon (python3): subprocess.check_call(['rm', '-rf', path])

Mauvais (C ++):

 std::string cmd = "rm -rf "; cmd += path; system(cmd); 

Bon (C / POSIX), moins la gestion des erreurs:

 char* const args[] = {"rm", "-rf", path, NULL}; pid_t child; posix_spawnp(&child, args[0], NULL, NULL, args, NULL); int status; waitpid(child, &status, 0); 

Plan B: un script shell statique


Si un shell est requis, laissez les arguments être des arguments. Vous pourriez penser qu'il était fastidieux d'écrire un script shell spécial dans votre propre fichier et d'y accéder jusqu'à ce que vous voyiez une telle astuce:

Mauvais (python3): subprocess.check_call('docker exec {} bash -ec "printf %s {} > {}"'.format(instance, content, path))
Bon (python3): subprocess.check_call(['docker', 'exec', instance, 'bash', '-ec', 'printf %s "$0" > "$1"', content, path])

Pouvez-vous remarquer le script shell?

C'est vrai, la commande printf est redirigée. Faites attention aux arguments numérotés correctement cités. L'implémentation d'un script shell statique est très bien.

Ces exemples s'exécutent dans Docker car sinon ils ne seront pas aussi utiles, mais Docker est également un excellent exemple de commande qui exécute d'autres commandes basées sur des arguments. Contrairement à Ssh, comme nous le verrons plus loin.

Dernière option: le traitement en ligne


S'il doit s'agir d'une chaîne (par exemple, car elle doit fonctionner via ssh ), elle ne peut pas être contournée. Vous devrez citer chaque argument et échapper tous les caractères nécessaires pour quitter ces guillemets. Le moyen le plus simple est de passer aux guillemets simples, car ils ont les règles d'échappement les plus simples. Une seule règle: ''\" .

Nom de fichier entre guillemets typique:

 echo 'Don'\''t stop (12" dub mix).mp3' 

Comment utiliser cette astuce pour exécuter en toute sécurité les commandes ssh? C'est impossible! Eh bien, voici la solution «souvent correcte»:

  • La solution "souvent correcte" (python3): subprocess.check_call(['ssh', 'user@host', "sha1sum '{}'".format(path.replace("'", "'\\''"))])

Nous devons nous-mêmes combiner tous les arguments dans une chaîne afin que Ssh ne se trompe pas: si vous essayez de passer plusieurs arguments ssh, il commencera à combiner perfidement les arguments sans guillemets.

La raison pour laquelle cela n'est généralement pas possible est que la bonne décision dépend des préférences de l'utilisateur à l'autre extrémité, à savoir le shell distant, qui peut être n'importe quoi. En gros, ça pourrait même être ta maman. Il est «souvent correct» de supposer que le shell distant est bash ou un autre shell compatible POSIX, mais le poisson est incompatible à ce stade .

Source: https://habr.com/ru/post/fr413117/


All Articles