Cómo programar de forma segura en bash

¿Por qué bash?


Hay matrices y modo seguro en bash. Cuando se usa correctamente, bash es casi consistente con las prácticas de codificación seguras.

Es más difícil cometer un error en el pescado, pero no hay modo seguro. Por lo tanto, la creación de prototipos en peces y luego la traducción de peces a bash debería ser una buena idea si sabe cómo hacerlo correctamente.

Prólogo


Esta guía acompaña a ShellHarden, pero el autor también recomienda ShellCheck para que las reglas de ShellHarden no difieran de ShellCheck.

Bash no es un lenguaje donde la forma más correcta de resolver un problema al mismo tiempo es la más fácil . Si toma el examen de programación segura de bash, entonces la primera regla de BashPitfalls sería: siempre use comillas.

Lo principal que debes saber sobre la programación en bash


Comillas maníacas! Una variable sin comillas debe considerarse como una bomba armada: explota al entrar en contacto con un espacio. Sí, explota en el sentido de dividir una cadena en una matriz . En particular, las extensiones variables como $var y las sustituciones de comandos como $(cmd) se dividen en palabras cuando la cadena interna se expande en una matriz debido a la división en una variable especial $IFS con un espacio predeterminado. Esto suele ser invisible, porque con mayor frecuencia el resultado es una matriz de 1 elemento, indistinguible de la cadena esperada.

No solo esto se expande, sino también comodines ( *? ). Este proceso ocurre después de que se divide la palabra, por lo que si hay al menos un comodín en la palabra, la palabra se convierte en un comodín que se aplica a cualquier ruta de archivo adecuada. ¡Entonces esta característica comienza a aplicarse al sistema de archivos!

La cita suprime la división de palabras y la expansión de patrones para variables y sustituciones de comandos.

Extensión Variable:

  • Bien: "$my_var"
  • Malo: $my_var

Sustitución de comando:

  • Bien: "$(cmd)"
  • Malo: $(cmd)

Hay excepciones con comillas opcionales, pero las comillas nunca afectarán, y la regla general es tener cuidado de no citar variables sin comillas, por lo que no buscaremos excepciones fronterizas para su beneficio. Parece incorrecto, y la práctica incorrecta está lo suficientemente extendida como para levantar sospechas: muchos scripts se han escrito con un procesamiento roto de nombres de archivos y espacios en ellos ...

ShellHarden menciona solo algunas excepciones: ¿son estas variables con contenido numérico como $? , $# y ${#array[@]} .

¿Necesito usar backticks?


Las sustituciones de comandos también pueden tener la siguiente forma:

  • Correcto: "`cmd`"
  • Malo: `cmd`

Aunque este estilo se puede usar correctamente, parece menos conveniente entre comillas y menos legible cuando está anidado. El consenso aquí es bastante claro: evítelo.

ShellHarden reescribe tales marcas de verificación entre paréntesis en dólares.

¿Se deben usar llaves?


Los corchetes se utilizan para interpolar cadenas, por lo que suelen ser redundantes:

  • Malo: some_command $arg1 $arg2 $arg3
  • Pobre y detallado: some_command ${arg1} ${arg2} ${arg3}
  • Bien, pero detallado: some_command "${arg1}" "${arg2}" "${arg3}"
  • Bien: some_command "$arg1" "$arg2" "$arg3"

Teóricamente, el uso de llaves no es un problema, pero según la experiencia de su autor, existe una fuerte correlación negativa entre el uso innecesario de llaves y el uso correcto de comillas: ¡casi todos eligen la forma "mala y detallada" en lugar de la "buena pero detallada"!

Teorías de tu autor:

  • Debido al temor de hacer algo mal: en lugar del peligro real (falta de comillas), los principiantes pueden preocuparse de que la variable $prefix haga que la variable "$prefix_postfix" expanda, pero no funciona de esa manera.
  • Culto a la carga: escribir código en el pacto del miedo equivocado que lo precedió.
  • Los corchetes compiten con comillas por el límite de verbosidad permisible.

Por lo tanto, se decidió prohibir llaves innecesarias: ShellHarden reemplaza estas opciones con la forma más simple.

Y ahora sobre la interpolación de cadenas, donde las llaves son realmente útiles:

  • Malo (concatenación): $var1"more string content"$var2
  • Bien (concatenación): "$var1""more string content""$var2"
  • Bien (interpolación): "${var1}more string content${var2}"

La concatenación y la interpolación en bash son equivalentes incluso en matrices (lo cual es ridículo).

Debido a que ShellHarden no formatea estilos, no se supone que cambie el código correcto. Esto es cierto para la opción "buena (interpolación)": desde el punto de vista de ShellHarden, esta será la forma canónicamente correcta.

ShellHarden ahora está agregando y eliminando llaves según sea necesario: en un mal ejemplo, var1 se suministra con corchetes, pero no están permitidos para var2 incluso en el caso de "bueno (interpolación)", ya que nunca son necesarios al final de la línea. El último requisito bien puede ser revertido.

Gotcha: argumentos numerados


A diferencia de los nombres de identificadores de variables normales (en regex: [_a-zA-Z][_a-zA-Z0-9]* ), los argumentos numerados requieren corchetes (la interpolación de línea no). ShellCheck dice:

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

ShellHarden se niega a arreglarlo (considera la diferencia demasiado sutil).

Dado que los paréntesis están permitidos hasta 9, ShellHarden les permite todos los argumentos numerados.

Usar matrices


Para poder citar todas las variables, debe usar matrices reales, no cadenas pseudo-masivas separadas por espacios.

La sintaxis es detallada, pero hay que manejarla. Este bashismo es solo una razón para abandonar la compatibilidad POSIX para la mayoría de los scripts de shell.

Bueno

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

Malo:

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

Es por eso que las matrices son una función tan básica para un shell: los argumentos de los comandos son fundamentalmente arrays (y los scripts de shell son comandos y argumentos). Podemos decir que el shell, que artificialmente hace imposible pasar varios argumentos, será cómico e inútil. Algunos proyectiles comunes de esta categoría incluyen Dash y Busybox Ash. Estos son shells compatibles con POSIX mínimos, pero ¿de qué sirve la compatibilidad si lo más importante no está en POSIX?

Casos excepcionales cuando realmente vas a romper una línea


Ejemplo con \v como separador de datos (observe la segunda aparición):

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

De esta forma, evitamos la expansión de la plantilla y el método funciona incluso si el separador de datos es \n . La segunda aparición del separador de datos protege el último elemento si resulta ser un espacio. Por alguna razón, la opción -d debería ir primero, por lo que -rad '' opciones en -rad '' tentador, pero no funcionará. Como read devuelve un valor distinto de cero en este caso, debería estar protegido de errexit ( || true ), si está habilitado. Probado en bash 4.0, 4.1, 4.2, 4.3 y 4.4.

Alternativa para bash 4.4:

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

Donde comenzar un script bash


De algo como esto:

 #!/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 

Esto incluye:

  • Shebang:
    • Problemas de portabilidad: la ruta absoluta a env probablemente mejor para la portabilidad que la ruta absoluta a bash . Puedes ver el ejemplo de NixOS . POSIX requiere env , pero no bash.
    • Problemas de seguridad: ¡Sin lenguaje, opciones como -euo pipefail no serán aceptadas favorablemente -euo pipefail ! Esto se vuelve imposible cuando se usa la redirección env , pero incluso si su shebang comienza con #!/bin/bash , este no es el lugar para los parámetros que afectan el valor del script, ya que pueden anularse, lo que hará posible ejecutar el script incorrectamente. Sin embargo, como beneficio adicional, las opciones que no afectan el valor del script, como set -x , si se usan, pueden redefinirse.
  • ¿Qué necesitamos del modo estricto no oficial de Bash , con la comprobación de funciones set -u ? No necesitamos todo el modo estricto de Bash, porque la compatibilidad shellcheck / shellharden significa citar todo y todo lo que es mucho más estricto. Además, la opción set -u no debe usarse en Bash 4.3 y versiones anteriores. Como esta opción considera que las matrices vacías se descartan en esas versiones, las matrices no se pueden usar para los fines descritos aquí. El uso de matrices es el segundo consejo más importante de esta guía (después de las comillas) y la única razón por la que sacrificamos la compatibilidad con POSIX, por lo que esto no es inaceptable: no use set -u , o use Bash 4.4 u otro shell normal como Zsh. Esto es más fácil decirlo que hacerlo, porque existe la posibilidad de que alguien aún ejecute su script en la versión antigua de Bash. Afortunadamente, todo lo que funciona con set -u funcionará sin él (para set -e no se puede decir eso). Es por eso que es importante utilizar la verificación de versiones. Tenga cuidado con la suposición de que las pruebas y el desarrollo tienen lugar en un shell compatible con Bash 4.4 (por lo que se prueba el aspecto set -u ). Si esto le molesta, entonces otra opción es rechazar la compatibilidad (el script falla cuando falla la verificación de la versión) o rechazar set -u .
  • shopt -s nullglob obliga for f in *.txt a funcionar correctamente si *.txt no encuentra archivos. El comportamiento predeterminado (también conocido como passglob ) pasa la plantilla sin cambios, lo que en caso de un resultado cero es peligroso por varias razones. Para globstar, esto activa la búsqueda recursiva. La sustitución es más fácil de usar que find . Así que úsalo.

Pero no:

 IFS='' set -f shopt -s failglob 

  • Establecer el delimitador de campo interno en una cadena vacía hace que sea imposible dividir la palabra. Suena como la solución perfecta. Desafortunadamente, este es un reemplazo incompleto para las variables de comillas y las sustituciones de comandos, y dado que va a usar comillas, no da nada. La razón por la cual las comillas aún deben usarse es porque de lo contrario las cadenas vacías se convierten en matrices vacías (como en la test $x = "" ) y la expansión indirecta de la plantilla aún es posible. Además, los problemas con esta variable también causarán problemas con comandos como read , que rompe construcciones como 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' .
  • La extensión de la plantilla está deshabilitada: no solo la infame extensión indirecta, sino también la extensión directa sin problemas, que, como dije, deberías usar. Entonces es difícil de aceptar. Y esto también es completamente opcional para un script compatible con shellcheck / shellharden.
  • A diferencia de nullglob , failglob falla con un resultado nulo. Aunque para la mayoría de los comandos esto tiene sentido, por ejemplo, rm -- *.txt (porque para la mayoría de los comandos todavía no se espera que se ejecute con un resultado cero), obviamente, failglob solo puede usarse si no espera un resultado cero. Esto significa que generalmente no colocará plantillas de grupo en argumentos de comando a menos que asuma lo mismo. Pero lo que siempre puede suceder es usar nullglob y extender la plantilla a argumentos nulos en construcciones que puedan tomarlos, como un bucle o asignar valores a una matriz ( txt_files=(*.txt) ).

Cómo completar un script bash


El estado de salida del script es el estado del último comando ejecutado. Asegúrese de que represente un verdadero éxito o fracaso.

Lo peor es dejar la solución a una condición no relacionada en forma de una lista AND al final del script. Si la condición es falsa, el último comando ejecutado será la condición misma.

Para errexit, las condiciones en forma de una lista AND nunca se utilizan en primer lugar. Si no se usa errexit, considere manejar los errores incluso para el último comando, por lo que su estado de salida no se enmascarará si se agrega código adicional al script.

Malo:

 condition && extra_stuff 

Bueno (opción errexit):

 if condition; then extra_stuff fi 

Bueno (opción de manejo de errores):

 if condition; then extra_stuff || exit fi exit 0 

Cómo usar errexit


Como set -e .

Limpieza programada a nivel de programa


Si errexit funciona como debería, use esto para instalar cualquier limpieza necesaria al salir.

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

Atrapado: errexit se ignora en los argumentos de comando


Aquí hay una "bomba" de ramificación muy complicada, cuya comprensión me fue muy querida. Mi script de compilación funcionó bien en diferentes máquinas de desarrollo, pero puso de rodillas al servidor de compilación:

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

Correcto (sustitución de comando en la tarea):

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

Advertencia: local comandos integrados local y de export siguen siendo comandos, por lo que esto sigue siendo incorrecto:

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

ShellCheck solo advierte sobre comandos especiales como local en este caso.

Para usar local , separe la declaración del trabajo:

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

Atrapado: errexit se ignora dependiendo del contexto de la persona que llama


A veces POSIX es terrible. Errexit se ignora en funciones, comandos de grupo e incluso subcapas si la persona que llama comprueba su éxito. Todos estos ejemplos imprimen Unreachable y Great success , por extraño que parezca.

Subshell:

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

Equipo grupal:

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

Función:

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

Debido a esto, bash con errexit es prácticamente inadecuado para vincular: sí, es posible ajustar las funciones de errexit para que funcionen, pero existen dudas de que el esfuerzo ahorrado (en el manejo explícito de errores) valga la pena. En cambio, considere dividir en scripts totalmente autónomos.

Evitar llamar al shell con comillas incorrectas


Al invocar comandos de otros lenguajes de programación, es más fácil cometer un error e invocar implícitamente el shell. Si este comando de shell es estático, es bueno, funciona o no. Pero si su programa de alguna manera procesa las líneas para construir este comando, entonces necesita comprender: ¡está generando un script de shell ! Rara vez quiero hacer esto, y es muy agotador organizar todo correctamente:

  • cita cada argumento;
  • escapar de los caracteres correspondientes en los argumentos.

No importa en qué lenguaje de programación haga esto, hay al menos tres formas de construir un equipo correctamente. En orden de preferencia:

Plan A: prescindir de una cáscara


Si esto es solo un comando con argumentos (es decir, no hay funciones de shell como canalizar o redirigir), seleccione una opción de matriz.

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

Malo (C ++):

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

Bueno (C / POSIX), menos manejo de errores:

 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 de shell estático


Si se requiere un shell, deje que los argumentos sean argumentos. Puede pensar que fue engorroso escribir un script de shell especial en su propio archivo y acceder a él hasta que vea ese truco:

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

¿Puedes notar el script de shell?

Así es, el comando printf se redirige. Preste atención a los argumentos numerados correctamente citados. Implementar un script de shell estático está bien.

Estos ejemplos se ejecutan en Docker porque de lo contrario no serán tan útiles, pero Docker también es un gran ejemplo de un comando que ejecuta otros comandos basados ​​en argumentos. A diferencia de Ssh, como veremos más adelante.

Última opción: procesamiento de línea


Si debe ser una cadena (por ejemplo, porque debe funcionar a través de ssh ), no se puede omitir. Tendrá que citar cada argumento y escapar de los caracteres necesarios para salir de estas citas. La forma más fácil es cambiar a comillas simples, porque tienen las reglas de escape más simples. Solo una regla: ''\" .

Nombre de archivo típico entre comillas simples:

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

¿Cómo usar este truco para ejecutar comandos ssh de forma segura? Esto es imposible! Bueno, aquí está la solución "a menudo correcta":

  • La solución "a menudo correcta" (python3): subprocess.check_call(['ssh', 'user@host', "sha1sum '{}'".format(path.replace("'", "'\\''"))])

Nosotros mismos debemos combinar todos los argumentos en una cadena para que Ssh no lo haga mal: si intenta pasar varios argumentos ssh, comenzará a combinar traicioneramente los argumentos sin comillas.

La razón por la que esto generalmente no es posible es porque la decisión correcta depende de las preferencias del usuario en el otro extremo, es decir, el shell remoto, que puede ser cualquier cosa. Básicamente, incluso podría ser tu madre. Es "a menudo correcto" suponer que el shell remoto es bash u otro shell compatible con POSIX, pero fish es incompatible en esta etapa .

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


All Articles