Programmation défensive en bash
Nouvelle traduction aujourd’hui, d’un article qui risque sans prévenir de disparaître. La programmation défensive consiste à structurer son code pour limiter au strict minimum les surfaces d’attaques. Le JDN a un article qui résume bien des concepts, mais ici, on va s’attarder sur les scripts Bash. En effet, comme avec PHP on peut vite faire de la merde, et il est préférable de suivre certaines pratiques pour que leur qualité ne soit pas trop catastrophique quand il seront réutilisés ailleurs. Je ne les suis pas toutes, mais j’ai trouvé utile de les partager avec vous.
Variables globales immutables
- Essayez de limiter le nombre de variables globales
- Nommage en majuscules
- déclarations en lecture seule
- Utilisez des variables globales pour remplacer les cryptiques $1, $0, etc
- Les globales que j’utilise pratiquement systématiquement dans mes scripts :
1 2 3 |
readonly PROGNAME=$(basename $0) readonly PROGDIR=$(readlink -m $(dirname $0)) readonly ARGS="$@" |
Tout est local
Toutes les variables devraient être locales.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
change_owner_of_file() { local filename=$1 local user=$2 local group=$3 chown $user:$group $filename } change_owner_of_files() { local user=$1; shift local group=$1; shift local files=$@ local i for i in $files do chown $user:$group $i done } |
- des paramètres « auto-documentés »
- Habituellement pour les boucles ont utilise « i », il est vital de la déclarer comme locale
- les variables locales ne fonctionnent pas dans un contexte global :
1 2 |
kfir@goofy ~ $ local a bash: local: can only be used in a function |
main()
- Permet de conserver toutes les variables comme locales
- Intuitif pour la programmation fonctionnelle
- La seule commande globale dans le code est : main
1 2 3 4 5 6 7 8 9 10 |
main() { local files="/tmp/a /tmp/b" local i for i in $files do change_owner_of_file kfir users $i done } main |
Tout est une fonction
- Le seul code qui tourne globalement est
- Les déclarations globales qui sont immutables
- main
- Permet de garder un code propre
- Les procédures sont plus descriptives
1 2 3 |
main() { local files=$(ls /tmp | grep pid | grep -v daemon) } |
1 2 3 4 5 6 7 8 9 10 11 |
temporary_files() { local dir=$1 ls $dir \ | grep pid \ | grep -v daemon } main() { local files=$(temporary_files /tmp) } |
- Le deuxième exemple est bien meilleur. Rechercher des fichiers est le problème de temporary_files() et pas celui de main(). Le code est également mieux « testable », via un test unitaire sur temporary_files().
- Si vous testez la première version, vous mélangez la logique de recherche du reste de main.
1 2 3 4 5 6 7 8 9 10 11 12 |
test_temporary_files() { local dir=/tmp touch $dir/a-pid1232.tmp touch $dir/a-pid1232-daemon.tmp returns "$dir/a-pid1232.tmp" temporary_files $dir touch $dir/b-pid1534.tmp returns "$dir/a-pid1232.tmp $dir/b-pid1534.tmp" temporary_files $dir } |
Comme vous le voyez, ce test ne concerne pas main().
Des fonctions de debug
- Lancez le programme avec le drapeau -x :
1 |
bash -x my_prog.sh |
- Débuggez une portion du code en utilisant set -x et set +x, ce qui va afficher les messages de débug pour le code entouré des commandes.
1 2 3 4 5 6 7 8 9 |
temporary_files() { local dir=$1 set -x ls $dir \ | grep pid \ | grep -v daemon set +x } |
- Affichez le nom de la fonction et ses arguments :
1 2 3 4 5 6 7 8 |
temporary_files() { echo $FUNCNAME $@ local dir=$1 ls $dir \ | grep pid \ | grep -v daemon } |
Donc, en appelant la fonction :
1 |
temporary_files /tmp |
affichera sur la sortie standard :
1 |
temporary_files /tmp |
Clarté du code
Qu’est-ce que fait ce code ?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
main() { local dir=/tmp [[ -z $dir ]] \ && do_something... [[ -n $dir ]] \ && do_something... [[ -f $dir ]] \ && do_something... [[ -d $dir ]] \ && do_something... } main |
Laissez votre code parler :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
is_empty() { local var=$1 [[ -z $var ]] } is_not_empty() { local var=$1 [[ -n $var ]] } is_file() { local file=$1 [[ -f $file ]] } is_dir() { local dir=$1 [[ -d $dir ]] } main() { local dir=/tmp is_empty $dir \ && do_something... is_not_empty $dir \ && do_something... is_file $dir \ && do_something... is_dir $dir \ && do_something... } main |
Chaque ligne fait une seule chose
- Sectionnez vos commandes avec des antislash
. Par exemple :
1 2 3 4 5 |
temporary_files() { local dir=$1 ls $dir | grep pid | grep -v daemon } |
Peut être écrit plus clairement :
1 2 3 4 5 6 7 |
temporary_files() { local dir=$1 ls $dir \ | grep pid \ | grep -v daemon } |
- Les symboles doivent être en début de ligne. Mauvais exemple de symboles à la fin :
1 2 3 4 5 6 7 |
temporary_files() { local dir=$1 ls $dir | \ grep pid | \ grep -v daemon } |
Bon exemple où l’on voit clairement la connexion entre les les lignes et les symboles :
1 2 3 4 5 6 7 |
print_dir_if_not_empty() { local dir=$1 is_empty $dir \ && echo "dir is empty" \ || echo "dir=$dir" } |
Afficher l’usage
Ne faites pas ça :
1 2 3 |
echo "this prog does:..." echo "flags:" echo "-h print help" |
Ça devrait être une fonction :
1 2 3 4 5 |
usage() { echo "this prog does:..." echo "flags:" echo "-h print help" } |
echo est répété à chaque ligne. A la place on a ‘Here Document’ :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
usage() { cat <<- EOF usage: $PROGNAME options Program deletes files from filesystems to release space. It gets config file that define fileystem paths to work on, and whitelist rules to keep certain files. OPTIONS: -c --config configuration file containing the rules. use --help-config to see the syntax. -n --pretend do not really delete, just how what you are going to do. -t --test run unit test to check the program -v --verbose Verbose. You can specify more then one -v to have more verbose -x --debug debug -h --help show this help --help-config configuration help Examples: Run all tests: $PROGNAME --test all Run specific test: $PROGNAME --test test_string.sh Run: $PROGNAME --config /path/to/config/$PROGNAME.conf Just show what you are going to do: $PROGNAME -vn -c /path/to/config/$PROGNAME.conf EOF } |
Attention à bien utiliser des tabulations ‘\t’ pour le début de chaque ligne. Dans vim vous pouvez utiliser cette astuce si votre tabulation consiste en 4 espaces :
1 |
:s/^ /\t/ |
Arguments de ligne de commande
Voici un exemple de complément à la fonction usage d’au-dessus. Ce code est tiré de l’article bash shell script to use getopts with gnu style long positional parameters sur le blog de Kirk :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
cmdline() { # got this idea from here: # http://kirk.webfinish.com/2009/10/bash-shell-script-to-use-getopts-with-gnu-style-long-positional-parameters/ local arg= for arg do local delim="" case "$arg" in #translate --gnu-long-options to -g (short options) --config) args="${args}-c ";; --pretend) args="${args}-n ";; --test) args="${args}-t ";; --help-config) usage_config && exit 0;; --help) args="${args}-h ";; --verbose) args="${args}-v ";; --debug) args="${args}-x ";; #pass through anything else *) [[ "${arg:0:1}" == "-" ]] || delim="\"" args="${args}${delim}${arg}${delim} ";; esac done #Reset the positional parameters to the short options eval set -- $args while getopts "nvhxt:c:" OPTION do case $OPTION in v) readonly VERBOSE=1 ;; h) usage exit 0 ;; x) readonly DEBUG='-x' set -x ;; t) RUN_TESTS=$OPTARG verbose VINFO "Running tests" ;; c) readonly CONFIG_FILE=$OPTARG ;; n) readonly PRETEND=1 ;; esac done if [[ $recursive_testing || -z $RUN_TESTS ]]; then [[ ! -f $CONFIG_FILE ]] \ && eexit "You must provide --config file" fi return 0 } |
On l’utilise ensuite de cette façon en utilisant la variable immutable ARGS qu’on a défini au début du script :
1 2 3 4 |
main() { cmdline $ARGS } main |
Tests unitaires
- Très important dans les langages de haut niveau
- Utilisez shunit2 pour vos tests unitaires
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
test_config_line_paths() { local s='partition cpm-all, 80-90,' returns "/a" "config_line_paths '$s /a, '" returns "/a /b/c" "config_line_paths '$s /a:/b/c, '" returns "/a /b /c" "config_line_paths '$s /a : /b : /c, '" } config_line_paths() { local partition_line="$@" echo $partition_line \ | csv_column 3 \ | delete_spaces \ | column 1 \ | colons_to_spaces } source /usr/bin/shunit2 |
Voici un autre exemple sur la commande df :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
DF=df mock_df_with_eols() { cat <<- EOF Filesystem 1K-blocks Used Available Use% Mounted on /very/long/device/path 124628916 23063572 100299192 19% / EOF } test_disk_size() { returns 1000 "disk_size /dev/sda1" DF=mock_df_with_eols returns 124628916 "disk_size /very/long/device/path" } df_column() { local disk_device=$1 local column=$2 $DF $disk_device \ | grep -v 'Use%' \ | tr '\n' ' ' \ | awk "{print \$$column}" } disk_size() { local disk_device=$1 df_column $disk_device 2 } |
Ici il y a une exception, pour les tests je déclare DF au niveau global et pas en lecture seule. C’est parce que shunit2 n’autorise pas les changements de fonctions au niveau global.
Les plus aguerris au développement logiciel trouveront peut-être évidents ces constructions de scripts, pour ma part j’avais déjà lu certains de ces conseils, notamment pour Python. C’est sympa de voir qu’on peut réutiliser les mêmes concepts pour Bash, par défaut c’est un langage particulièrement permissif, à l’image de PHP. Il n’en faut pas plus pour que les deux se trainent une réputation désastreuse.
Merci pour ce beau recueil de bonnes pratiques !
Salut,
Bizarre, il manque quelques règles de protection.
$ARGS
est un tableau. Il faut protéger les valeurs avec${ARGS[@]}
, non ?Un avis sur shellcheck ? À mon avis la référence dans l’analyze statique de code shell.
Super article au demeurant ! Merci !!
Shellcheck, c’est que du bonheur surtout qu’il peut générer des rapports intégrable très facilement à un bon CI, mais tu vas finir par être obligé de mettre des « # shellcheck disable= » partout 😛
Pis, shunit, on en parle ? 😛
Merci de l’article.
En plus de la majorité de ces pratiques j’utilise également cet entête :
Issu de cet excellent article : http://redsymbol.net/articles/unofficial-bash-strict-mode/
Super intéressant cet article, dans mes favoris sur Bash. La partie sur l’usage est maintenant une évidence, alors que je tourne sur du echo dans tous mes scripts :facepalm
Excellent article, avec plein de choses auxquelles je n’aurais pas pensé comme les mot clés « local » et « readonly ». J’aurais juste ajouté le « set -e » qui est vraiment indispensable, car ilvaut mieux un script correctement planté que des opérations qui marchotent dangereusement.
Par contre, pour parser la ligne de commande ;, j’ai écarté getopts, peu lisible au final et surtout pas toujours présent sur les busybox.
Ma petite recette (désolé pour l’autopromo), expliquée ici https://dascritch.net/post/2018/01/08/En-20-lignes-pas-plus-%3A-g%C3%A9rer-les-options-d-appel-de-tes-scripts-bash
Intéressant, mais il manque ce qui me semble le plus important pour toute idée de défensivité en BaSH : le quoting. Il faut grosso modo quoter *toutes* les variables, sauf les cas particuliers où on veut l’expansion. Par exemple, le snippet tout simple que tu utilises pour obtenir PROGNAME a un comportement que tu n’attends probablement pas si $0 contient des espaces : # avec un script "/tmp/foo bar.bash" : $ bash foo\ bar.bash foo # avec un script "/tmp/a b c d.bash" : $ bash a\ b\ c\ d.bash basename: extra operand ‘c’ Try 'basename --help' for more information. # ouch. Tout ça… Lire la suite »
Un super article et des commentaires tout aussi pertinent, merci beaucoup !
Salut,
Super article. Quelques propositions :
– Pour bien marquer le passage des arguments, l’appel a main se fait avec :
main « $@ »
– Tes fonctions de tests aident énormément à la lisibilité, mais pour plus de concision, perso, je les écris comme :
is_empty() { [[ -z $1 ]] }
– Connais-tu https://github.com/qzb/is.sh ?
Super article mais j’ai jamais vu des scripts bash écrits ainsi.
En général si tu arrives aux scripts bash c’est que tu es déjà dans le serveur/vM /whatthefuck et que l’attaquant est déjà utilisateur avec pas mal de privilèges.