Como programar com segurança no bash

Por que bater?


Existem matrizes e modo de segurança no bash. Quando usado corretamente, o bash é quase consistente com práticas de codificação seguras.

É mais difícil cometer um erro no peixe, mas não há modo seguro. Portanto, a criação de protótipos em peixes e a tradução de peixes para o bash deve ser uma boa idéia, se você souber como fazê-lo corretamente.

Prefácio


Este guia acompanha o ShellHarden, mas o autor também recomenda o ShellCheck para que as regras do ShellHarden não sejam diferentes do ShellCheck.

Bash não é um idioma em que a maneira mais correta de resolver um problema ao mesmo tempo seja a mais fácil . Se você fizer o exame de programação segura do bash, a primeira regra do BashPitfalls seria: sempre use aspas.

A principal coisa que você precisa saber sobre programação no bash


Aspas maníacas! Uma variável não citada deve ser considerada uma bomba armada: ela explode em contato com um espaço. Sim, ele explode no sentido de dividir uma string em uma matriz . Em particular, extensões variáveis ​​como $var e substituições de comandos como $(cmd) são divididas em palavras quando a cadeia interna é expandida em uma matriz devido à divisão em uma variável especial $IFS com um espaço padrão. Isso geralmente é invisível, porque na maioria das vezes o resultado é uma matriz de 1 elemento, indistinguível da sequência esperada.

Não apenas isso é expandido, mas também curingas ( *? ). Esse processo ocorre depois que a palavra é dividida; portanto, se houver pelo menos um curinga na palavra, a palavra se transformará em um curinga que se aplica a qualquer caminho de arquivo adequado. Portanto, esse recurso começa a se aplicar ao sistema de arquivos!

A cotação suprime a divisão de palavras e a expansão de padrões para variáveis ​​e substituições de comandos.

Extensão variável:

  • Bom: "$my_var"
  • Ruim: $my_var

Substituição de comando:

  • Bom: "$(cmd)"
  • Ruim: $(cmd)

Existem exceções com aspas opcionais, mas as aspas nunca serão prejudicadas, e a regra geral é ter cuidado para não citar variáveis ​​não citadas, portanto, não procuraremos exceções de borda para seu benefício. Parece errado, e a prática errada é generalizada o suficiente para levantar suspeitas: muitos scripts foram escritos com processamento quebrado de nomes de arquivos e espaços neles ...

O ShellHarden menciona apenas algumas exceções - essas variáveis ​​têm conteúdo numérico como $? , $# e ${#array[@]} .

Preciso usar backticks?


As substituições de comando também podem ter o seguinte formato:

  • Correto: "`cmd`"
  • Ruim: `cmd`

Embora esse estilo possa ser usado corretamente, parece menos conveniente entre aspas e menos legível quando aninhado. O consenso aqui é bastante claro: evite-o.

A ShellHarden reescreve essas marcas de verificação entre colchetes em dólares.

Aparelhos precisam ser usados?


Os colchetes são usados ​​para interpolar cadeias de caracteres, portanto, geralmente são redundantes:

  • Ruim: some_command $arg1 $arg2 $arg3
  • Pobre e detalhado: some_command ${arg1} ${arg2} ${arg3}
  • Bom, mas detalhado: some_command "${arg1}" "${arg2}" "${arg3}"
  • Bom: some_command "$arg1" "$arg2" "$arg3"

Teoricamente, sempre usar chaves não é um problema, mas de acordo com a experiência do seu autor, existe uma forte correlação negativa entre o uso desnecessário de chaves e o uso correto de aspas - quase todo mundo escolhe a forma “ruim e detalhada” em vez da forma “boa, mas detalhada”!

Teorias do seu autor:

  • Por causa do medo de fazer algo errado: em vez do perigo real (falta de aspas), os iniciantes podem se preocupar com o fato de a variável $prefix fazer com que a variável "$prefix_postfix" se expanda, mas não funciona dessa maneira.
  • Culto à carga: escrever código na aliança do medo errado que o precedeu.
  • Os colchetes competem entre aspas pelo limite da verbosidade permitida.

Portanto, decidiu-se proibir chaves desnecessárias: o ShellHarden substitui essas opções pela forma mais simples.

E agora sobre interpolação de strings, onde chaves são realmente úteis:

  • Ruim (concatenação): $var1"more string content"$var2
  • Bom (concatenação): "$var1""more string content""$var2"
  • Bom (interpolação): "${var1}more string content${var2}"

Concatenação e interpolação no bash são equivalentes mesmo em matrizes (o que é ridículo).

Como o ShellHarden não formata estilos, não é necessário alterar o código correto. Isso é verdade para a opção "boa (interpolação)": do ponto de vista do ShellHarden, essa será a forma canonicamente correta.

O ShellHarden agora está adicionando e removendo chaves, conforme necessário: em um mau exemplo, o var1 é fornecido com colchetes, mas eles não são permitidos para o var2, mesmo no caso de “bom (interpolação)”, pois nunca são necessários no final da linha. O último requisito pode muito bem ser revertido.

Pegadinha: argumentos numerados


Diferentemente dos nomes normais de identificadores de variáveis ​​(no regex: [_a-zA-Z][_a-zA-Z0-9]* ), os argumentos numerados exigem colchetes (a interpolação de linha não). ShellCheck diz:

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

O ShellHarden se recusa a corrigi-lo (considera a diferença muito sutil).

Como os parênteses são permitidos até 9, o ShellHarden permite todos os argumentos numerados.

Usando matrizes


Para poder citar todas as variáveis, você deve usar matrizes reais, não cadeias pseudo-massivas separadas por espaços.

A sintaxe é detalhada, mas você precisa lidar com isso. Esse basismo é apenas um dos motivos para abandonar a compatibilidade do POSIX para a maioria dos scripts de shell.

Bom:

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

Ruim:

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

É por isso que matrizes são uma função tão básica para um shell: os argumentos dos comandos são fundamentalmente matrizes (e os scripts do shell são comandos e argumentos). Podemos dizer que a concha, que artificialmente impossibilita a passagem de vários argumentos, será cômica e sem valor. Algumas conchas comuns dessa categoria incluem Dash e Busybox Ash. Estes são shells mínimos compatíveis com POSIX - mas de que adianta a compatibilidade se o material mais importante não estiver no POSIX?

Casos excepcionais em que você realmente vai quebrar uma linha


Exemplo com \v como separador de dados (observe a segunda ocorrência):

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

Dessa forma, evitamos a expansão do modelo, e o método funciona mesmo que o separador de dados seja \n . A segunda ocorrência do separador de dados protege o último elemento, se for um espaço. Por alguma razão, a opção -d deve ser a primeira, portanto, -rad '' opções em -rad '' tentador, mas não funciona. Como a leitura retorna um valor diferente de zero nesse caso, ele deve ser protegido contra errexit ( || true ), se ativado. Testado no bash 4.0, 4.1, 4.2, 4.3 e 4.4.

Alternativa para o bash 4.4:

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

Onde iniciar um script bash


De algo assim:

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

Isso inclui:

  • Shebang:
    • Problemas de portabilidade: o caminho absoluto para env provavelmente melhor para portabilidade do que o caminho absoluto para o bash . Você pode ver o exemplo do NixOS . POSIX requer env , mas não bash.
    • Questões de segurança: para nenhum idioma, opções como -euo pipefail não serão aceitas favoravelmente -euo pipefail ! Isso se torna impossível ao usar o redirecionamento env , mas mesmo que seu shebang comece com #!/bin/bash , este não é o lugar para parâmetros que afetam o valor do script, pois eles podem ser substituídos, o que tornará possível a execução incorreta do script. No entanto, como bônus, opções que não afetam o valor do script, como set -x , se usado, podem ser redefinidas.
  • O que precisamos do modo estrito não oficial do Bash , com a verificação do recurso set -u . Não precisamos de todo o modo estrito do Bash, porque a compatibilidade do shellcheck / shellharden significa citar tudo e tudo que é muito mais rigoroso. Além disso, a opção set -u não deve ser usada no Bash 4.3 e versões anteriores. Como essa opção considera matrizes vazias como descartadas nessas versões, as matrizes não podem ser usadas para os fins descritos aqui. O uso de matrizes é a segunda dica mais importante deste guia (após as aspas) e a única razão pela qual sacrificamos a compatibilidade com o POSIX, de modo que isso não é inaceitável: ou não use set -u , ou use o Bash 4.4 ou outro shell normal como o Zsh. É mais fácil falar do que fazer, porque existe a possibilidade de alguém ainda executar o seu script na versão antiga do Bash. Felizmente, tudo o que funciona com set -u funcionará sem ele (para set -e você não pode dizer isso). É por isso que é importante usar a verificação de versão. Cuidado com a suposição de que o teste e o desenvolvimento ocorrem em um shell compatível com o Bash 4.4 (para que o aspecto set -u seja testado). Se isso lhe incomoda, outra opção é recusar a compatibilidade (o script falha quando a verificação da versão falha) ou recusar set -u .
  • shopt -s nullglob força o for f in *.txt a funcionar corretamente se o *.txt não encontrar arquivos. O comportamento padrão (também conhecido como passglob ) passa o modelo inalterado, o que, no caso de um resultado zero, é perigoso por vários motivos. Para globstar, isso ativa a pesquisa recursiva. Substituição é mais fácil de usar do que find . Então use.

Mas não:

 IFS='' set -f shopt -s failglob 

  • Definir o delimitador de campo interno como uma sequência vazia torna impossível dividir a palavra. Parece a solução perfeita. Infelizmente, esse é um substituto incompleto para citar variáveis ​​e substituições de comandos e, como você usará aspas, ele não fornece nada. O motivo pelo qual as aspas ainda precisam ser usadas é porque, caso contrário, as seqüências vazias se tornam matrizes vazias (como no test $x = "" ) e a expansão indireta do modelo ainda é possível. Além disso, problemas com essa variável também causarão problemas com comandos como read , que quebram construções 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' .
  • A extensão do modelo está desativada: não apenas a extensão indireta infame, mas também a extensão direta sem complicações, que, como eu disse, você deve usar. Então é difícil de aceitar. E isso também é totalmente opcional para um script compatível com shellcheck / shellharden.
  • Ao contrário do nullglob , o failglob falha com um resultado nulo. Embora para a maioria dos comandos isso faça sentido, por exemplo, rm -- *.txt (porque para a maioria dos comandos ainda não é esperado que seja executado com resultado zero), obviamente o failglob pode ser usado apenas se você não espera um resultado zero. Isso significa que geralmente você não colocará modelos de grupo em argumentos de comando, a menos que assuma o mesmo. Mas o que sempre pode acontecer é usar nullglob e estender o modelo para argumentos nulos em construções que podem levá-los, como um loop ou atribuir valores a uma matriz ( txt_files=(*.txt) ).

Como concluir um script bash


O status de saída do script é o status do último comando executado. Certifique-se de que isso represente sucesso ou fracasso real.

O pior é deixar a solução em uma condição não relacionada na forma de uma lista AND no final do script. Se a condição for falsa, o último comando executado será a própria condição.

Para errexit, as condições na forma de uma lista AND nunca são usadas em primeiro lugar. Se o errexit não for usado, considere manipular erros mesmo para o último comando, para que seu status de saída não seja mascarado se um código adicional for adicionado ao script.

Ruim:

 condition && extra_stuff 

Bom (opção errexit):

 if condition; then extra_stuff fi 

Bom (opção de tratamento de erros):

 if condition; then extra_stuff || exit fi exit 0 

Como usar o errexit


Como set -e .

Limpeza programada no nível do programa


Se o errexit estiver funcionando como deveria, use-o para instalar qualquer limpeza necessária na saída.

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

Capturado: errexit é ignorado nos argumentos de comando


Aqui está uma "bomba" ramificada muito complicada, cuja compreensão valeu muito para mim. Meu script de build funcionou bem em diferentes máquinas de desenvolvimento, mas colocou o servidor de build de joelhos:

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

Correto (substituição de comando na tarefa):

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

Aviso: local comandos internos local e de export permanecem comandos, portanto, isso ainda permanece errado:

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

ShellCheck avisa apenas sobre comandos especiais como local neste caso.

Para usar local , separe a declaração da tarefa:

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

Capturado: errexit é ignorado dependendo do contexto do chamador


Às vezes, o POSIX é terrível. O Errexit é ignorado em funções, comandos de grupo e até subcascas se o chamador verificar seu sucesso. Todos esses exemplos imprimem Unreachable e Great success , por mais estranho que possa parecer.

Subshell:

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

Equipe do grupo:

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

Função:

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

Por isso, bash com errexit é praticamente inadequado para vinculação: sim, é possível agrupar funções errexit para que funcionem, mas há dúvidas de que o esforço economizado (no tratamento explícito de erros) valha a pena. Em vez disso, considere dividir em scripts totalmente autônomos.

Evitando chamar o shell com aspas incorretas


Ao invocar comandos de outras linguagens de programação, é mais fácil cometer um erro e invocar implicitamente o shell. Se esse comando do shell for estático, é bom - funciona ou não. Mas se o seu programa de alguma forma processar as linhas para criar esse comando, você precisará entender - você está gerando um shell script ! Eu raramente quero fazer isso, e é muito cansativo organizar tudo corretamente:

  • cite cada argumento;
  • escape dos caracteres correspondentes nos argumentos.

Não importa em qual linguagem de programação você faça isso, há pelo menos três maneiras de criar uma equipe corretamente. Em ordem de preferência:

Plano A: faça sem casca


Se este for apenas um comando com argumentos (ou seja, nenhum shell funciona como canalizar ou redirecionar), selecione uma opção de matriz.

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

Ruim (C ++):

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

Bom (C / POSIX), menos manipulação de erros:

 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); 

Plano B: um script de shell estático


Se um shell for necessário, deixe os argumentos serem argumentos. Você pode pensar que foi complicado escrever um shell script especial em seu próprio arquivo e acessá-lo até que você veja esse truque:

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

Você pode perceber o script de shell?

É isso mesmo, o comando printf é redirecionado. Preste atenção aos argumentos numerados corretamente citados. Implementar um script de shell estático é bom.

Esses exemplos são executados no Docker porque, caso contrário, não serão tão úteis, mas o Docker também é um ótimo exemplo de um comando que executa outros comandos com base em argumentos. Ao contrário do Ssh, como veremos mais adiante.

Última opção: processamento de linha


Se for uma sequência de caracteres (por exemplo, porque deve funcionar com o ssh ), não poderá ser ignorada. Você precisará citar cada argumento e escapar dos caracteres necessários para sair dessas aspas. A maneira mais fácil é mudar para aspas simples, porque elas têm as regras de escape mais simples. Apenas uma regra: ''\" .

Nome típico de arquivo com aspas simples:

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

Como usar esse truque para executar com segurança comandos ssh? Isso é impossível! Bem, aqui está a solução "geralmente certa":

  • A solução "frequentemente correta" (python3): subprocess.check_call(['ssh', 'user@host', "sha1sum '{}'".format(path.replace("'", "'\\''"))])

Nós mesmos devemos combinar todos os argumentos em uma string para que o Ssh não faça errado: se você tentar passar vários argumentos do ssh, ele começará a combinar traiçoeiramente os argumentos sem aspas.

A razão pela qual isso geralmente não é possível é porque a decisão correta depende das preferências do usuário do outro lado, a saber, o shell remoto, que pode ser qualquer coisa. Basicamente, poderia até ser sua mãe. É “geralmente correto” supor que o shell remoto seja bash ou outro shell compatível com POSIX, mas o peixe é incompatível nesse estágio .

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


All Articles