[Перевод] Как безопасно программировать в bash

habr.png


В bash есть массивы и безопасный режим. При правильном использовании bash почти соответствует практикам безопасного кодирования.

В fish сложнее допустить ошибку, но там нет безопасного режима. Поэтому хорошей идеей станет прототипирование в fish, а затем трансляция с fish на bash, если вы умеете правильно это делать.


Данное руководство сопровождает ShellHarden, но автор также рекомендует ShellCheck, чтобы правила ShellHarden не расходились с ShellCheck.

Bash — не тот язык, где самый правильный способ решить проблему одновременно является самым простым. Если принимать экзамен по безопасному программированию в bash, то первое правило BashPitfalls звучало бы так: всегда используй кавычки.


Маниакально ставить кавычки! Незакавыченная переменная должна расцениваться как взведённая бомба: она взрывается при контакте с пробелом. Да, «взрывается» в смысле разделения строки на массив. В частности, расширения переменных вроде $var и подстановки команд вроде $(cmd) подвергаются расщеплению слов, когда внутренняя строка расширяется в массив из-за расщепления в специальной переменной $IFS с пробелом по умолчанию. Это обычно незаметно, потому что чаще всего результатом становится массив из 1 элемента, неотличимый от ожидаемой строки.
Расширяется не только это, но и групповые символы (*?). Этот процесс происходит после расщепления слова, так что если в слове есть хоть один групповой символ, то слово превращается в групповой шаблон, который распространяется на любые подходящие пути файлов. Так что эта фича начинает применяться к файловой системе!

Закавычивание подавляет и расщепление слов, и расширение шаблона для переменных и подстановок команд.

Расширение переменной:

  • Хорошо: "$my_var"
  • Плохо: $my_var


Подстановка команды:

  • Хорошо: "$(cmd)"
  • Плохо: $(cmd)


Есть исключения с необязательными кавычками, но кавычки никогда не помешают, а общее правило — бояться незакавыченных переменных, так что ради вашего блага не станем искать пограничные исключения. Это выглядит неправильно, и неправильная практика достаточно распространена, чтобы вызвать подозрение: написано немало скриптов со сломанной обработкой имён файлов и пробелов в них…

ShellHarden упоминает только несколько исключений — это переменные с численным содержимым, такие как $?, $# и ${#array[@]}.

Нужно ли использовать обратные галочки?


Подстановки команд могут иметь и такой вид:

  • Правильно: "`cmd`"
  • Плохо: `cmd`


Хотя такой стиль можно использовать правильно, но он выглядит менее удобным в кавычках и менее читабельным при вложенности. Консенсус тут довольно ясен: избегайте его.

ShellHarden переписывает такие галочки в форму скобки в долларах.

Нужно ли использовать фигурные скобки?


Скобки используются для интерполяции строк, так что обычно избыточны:

  • Плохо: some_command $arg1 $arg2 $arg3
  • Плохо и многословно: some_command ${arg1} ${arg2} ${arg3}
  • Хорошо, но многословно: some_command "${arg1}" "${arg2}" "${arg3}"
  • Хорошо: some_command "$arg1" "$arg2" "$arg3"


Теоретически всегда использовать фигурные скобки не является проблемой, но по опыту вашего автора существует сильная отрицательная корреляция между ненужным использованием фигурных скобок и правильным использованием кавычек — почти каждый выбирает «плохую и многословную» вместо «хорошей, но многословной» формы!

Теории вашего автора:

  • Из-за страха сделать что-то неправильно: вместо настоящей опасности (отсутствие кавычек) новички могут беспокоиться, что переменная $prefix вызовет расширение переменной "$prefix_postfix", но всё работает не так.
  • Карго-культ: написание кода по завету неправильного страха, который ему предшествовал.
  • Скобки конкурируют с кавычками за лимит допустимой многословности.


Поэтому было решено запретить ненужные фигурные скобки: ShellHarden заменяет эти варианты самой простой хорошей формой.

А теперь об интерполяции строк, где фигурные скобки действительно полезны:

  • Плохо (конкатенация): $var1"more string content"$var2
  • Хорошо (конкатенация): "$var1""more string content""$var2"
  • Хорошо (интерполяция): "${var1}more string content${var2}"


Конкатенация и интерполяция в bash эквиваленты даже в массивах (что нелепо).

Поскольку ShellHarden не форматирует стили, ему не положено изменять правильный код. Это справедливо для варианта «хорошо (интерполяция)»: с точки зрения ShellHarden это будет канонически правильная форма.

Сейчас ShellHarden добавляет и удаляет фигурные скобки по мере необходимости: в плохом примере var1 снабжается скобками, но они не допускаются для var2 даже в случае «хорошо (интерполяция)», поскольку они никогда не нужны в конце строки. Последнее требование вполне может быть отменено.

Попался: нумерованные аргументы


В отличие от названий переменных нормального идентификатора (в regex: [_a-zA-Z][_a-zA-Z0-9]*), нумерованные аргументы требуют скобок (интерполяция строк не требует). ShellCheck говорит:

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


ShellHarden отказывается это исправлять (считает слишком тонкой разницей).

Поскольку скобки разрешены до 9, то ShellHarden разрешает их для всех нумерованных аргументах.


Чтобы иметь возможность закавычивать все переменные, вы должны использовать настоящие массивы, а не разделённые пробелами псевдомассивные строки.

Синтаксис многословный, но придётся справиться. Этот башизм — только одна причина отказаться от совместимости POSIX для большинства shell-скриптов.

Хорошо:

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


Плохо:

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


Вот почему массивы — настолько базовая функция для оболочки: аргументы команд фундаментально — это массивы (а shell-скрипты — это команды и аргументы). Можно сказать, что оболочка, которая искусственно делает невозможной передачу нескольких аргументов, будет комичной и негодной. Некоторые широко распространённые оболочки из этой категории включают Dash и Busybox Ash. Это минимальные POSIX-совместимые оболочки —, но что хорошего в совместимости, если самый важный материал не на POSIX?

Исключительные случаи, когда вы реально собираетесь разбить строку


Пример с \v в качестве разделителя данных (обратите внимание на второе вхождение):

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


Так мы избегаем расширения шаблона, и способ работает даже если разделителем данных будет \n. Второе вхождение разделителя данных защищает последний элемент, если он окажется пробелом. По какой-то причине первым должен идти параметр -d, так что сцепить параметры в -rad '' заманчиво, но не сработает. Поскольку в данном случае read возвращает ненулевое значение, то его следует защитить от errexit (|| true), если это включено. Протестировано в bash 4.0, 4.1, 4.2, 4.3 и 4.4.

Альтернативный вариант для bash 4.4:

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


С чего-нибудь такого:

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


Это включает в себя:

  • Шебанг:
    • Вопросы переносимости: абсолютный путь к env вероятно лучше для переносимости, чем абсолютный путь к bash. Можно посмотреть на пример NixOS. POSIX требует наличия env, но не bash.
    • Вопросы безопасности: ни для какого языка здесь не будут благосклонно приняты варианты вроде -euo pipefail! Такое становится невозможным при использовании редиректа env, но даже если ваш шебанг начинается с #!/bin/bash, это не место для параметров, которые влияют на значение скрипта, потому что они могут быть переопределены, что сделает возможным неправильное выполнение скрипта. Однако в качестве бонуса можно сделать переопределяемыми опции, не влияющие на значение скрипта, такие как set -x, если они используются.
  • Что нам нужно из неофициального строгого режима Bash, с проверкой фич set -u. Нам не нужен весь строгий режим Bash, потому что совместимость shellcheck/shellharden означает закавычивание всего и вся, что уже гораздо строже. Кроме того, опция set -u не должна использоваться в Bash 4.3 и более ранних версиях. Поскольку данная опция в тех версиях расценивает пустые массивы как сброшенные, то массивы невозможно использовать для целей, описанных здесь. Использование массивов — второй по важности совет из этого руководства (после кавычек) и единственная причина, по которой мы жертвуем совместимостью с POSIX, поэтому такое никак недопустимо: либо вообще не применяйте set -u, либо используйте Bash 4.4 или другую нормальную оболочку вроде Zsh. Такое легче сказать, чем сделать, ведь существует вероятность, что некто всё-таки запустит ваш скрипт в древней версии Bash. К счастью, всё работающее с set -u будет работать и без него (для set -e такого не скажешь). Вот почему важно использовать проверку версии. Остерегайтесь предположения, что тестирование и разработка происходят в оболочке, совместимой с Bash 4.4 (так что аспект set -u протестируют). Если вас это беспокоит, то другой вариант отказаться от совместимости (сбой скрипта при сбое проверки версии), или отказаться от set -u.
  • shopt -s nullglob заставляет корректно работать for f in *.txt, если *.txt не находит файлов. Поведение по умолчанию (aka passglob) передаёт шаблон без изменений, что в случае нулевого результата опасно по нескольким причинам. Для globstar это активирует рекурсивную подстановку. Подстановку легче правильно использовать, чем find. Так что используйте её.


Но не:

IFS=''
set -f
shopt -s failglob


  • Установка внутреннего разделителя полей пустой строкой сделает невозможным расщепление слова. Звучит как идеальное решение. К сожалению, это неполная замена для закавычивания переменных и подстановок команд, а поскольку вы собираетесь использовать кавычки, то это ничего не даёт. Причина, почему кавычки по-прежнему нужно использовать, заключается в том, что в противном случае пустые строки становятся пустыми массивами (как в test $x = "") и по-прежнему возможно непрямое расширение шаблона. Более того, проблемы с этой переменной также вызовет проблемы с использующими её командами вроде read, что поломает конструкции типа cat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done'.
  • Отключается расширение шаблона: не только печально известного косвенного расширения, но и беспроблемного прямого, который, как я говорил, вы должны использовать. Так что это трудно принять. И это ещё и совершенно необязательно для скрипта, совместимого с shellcheck/shellharden.
  • В отличие от nullglob, failglob сбоит при нулевом результате. Хотя для большинства команд это имеет смысл, например, rm -- *.txt (потому что для большинства команд всё равно не ожидается выполнения при нулевом результате), очевидно, failglob можно использовать только если вы не предполагаете нулевой результат. Это значит, что обычно вы не станете размещать групповые шаблоны в аргументах команды, если не предполагаете то же самое. Но что всегда может произойти, так это использование nullglob и расширение шаблона на нулевые аргументы в конструкциях, которые могут их принимать, таких как цикл или присваивание значений массиву (txt_files=(*.txt)).


Статус выхода скрипта — это статус последней выполненной команды. Удостоверьтесь, что она представляет реальный успех или неудачу.

Самое худшее — это оставить решение несвязанному условию в виде списка AND в конце скрипта. Если условие ложно, то последней выполненной командой будет само это условие.

Для errexit в первую очередь никогда не используются условия в виде списка AND. Если errexit не используется, рассмотрите возможность обработки ошибок даже для последней команды, так что её статус выхода не будет замаскирован, если к сценарию добавится дополнительный код.

Плохо:

condition && extra_stuff


Хорошо (вариант errexit):

if condition; then
    extra_stuff
fi


Хорошо (вариант с обработкой ошибки):

if condition; then
    extra_stuff || exit
fi
exit 0


Как set -e.

Отсроченная очистка на уровне программы


Если errexit работает как следует, используйте это для установки любой необходимой очистки при выходе.

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


Попался: errexit игнорируется в аргументах команды


Вот очень хитрая ветвящаяся «бомба», понимание которой дорогого мне стоило. Мой скрипт сборки отлично работал на разных машинах разработчиков, но поставил на колени сервер сборки:

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


Правильно (подстановка команды в задании):

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


Предупреждение: встроенные команды local и export остаются командами, так что такое по-прежнему остаётся неправильным:

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


ShellCheck предупреждает только об особенных командах вроде local в данном случае.

Для использования local, отделите декларацию от задания:

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


Попался: errexit игнорируется в зависимости от контекста вызывающей стороны


Иногда POSIX ужасен. Errexit игнорируется в функциях, групповых командах и даже подоболочках, если вызывающая сторона проверяет её успех. Все эти примеры печатают Unreachable и Great success, как бы странно это ни казалось.

Подоболочка:

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


Групповая команда:

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


Функция:

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


Из-за этого bash с errexit практически непригоден для компоновки: да, возможно обернуть функции errexit, чтобы они работали, но возникают сомнения, что сэкономленные усилия (над явной обработкой ошибок) стóят того. Вместо этого рассмотрите возможность разделения на полностью автономные скрипты.
При вызове команды из других языков программирования проще всего ошибиться и неявно вызвать оболочку. Если эта команда оболочки статична, то хорошо — она либо работает, либо нет. Но если ваша программа как-то обрабатывает строки для сборки этой команды, то нужно понимать — вы генерируете shell-скрипт! Редко хочется такое делать, и весьма утомительно всё правильно обставить:

  • закавычивать каждый аргумент;
  • экранировать соответствующие символы в аргументах.


Независимо от того, на каком языке программирования вы это делаете, существует минимум три способа правильно построить команду. В порядке предпочтительности:

План А: обойтись без оболочки


Если это просто команда с аргументами (то есть никаких функций оболочки вроде конвейерной пересылки или перенаправления), то выберите вариант массива.

  • Плохо (python3): subprocess.check_call('rm -rf ' + path)
  • Хорошо (python3): subprocess.check_call(['rm', '-rf', path])


Плохо (C++):

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


Хорошо (C/POSIX), минус обработка ошибок:

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


План B: статичный shell-скрипт


Если требуется оболочка, пусть аргументы будут аргументами. Вы могли подумать, что было громоздким писать специальный shell-скрипт в собственном файле и обращение к нему, пока не увидите такой трюк:

Плохо (python3): subprocess.check_call('docker exec {} bash -ec "printf %s {} > {}"'.format(instance, content, path))
Хорошо (python3): subprocess.check_call(['docker', 'exec', instance, 'bash', '-ec', 'printf %s "$0" > "$1"', content, path])

Можете заметить shell-скрипт?

Всё верно, команда printf с перенаправлением. Обратите внимание на корректно закавыченные нумерованные аргументы. Внедрение статичного shell-скрипта — это нормально.

Эти примеры запускаются в Docker, потому что иначе они не будут такими полезными, но Docker тоже прекрасный пример команды, которая запускает другие команды на основе аргументов. В отличие от Ssh, как увидим далее.

Последний вариант: обработка строк


Если это должна быть строка (например, потому, что она должна работать через ssh), то её невозможно обойти. Придётся закавычивать каждый аргумент и экранировать любые символы, необходимые для выхода из этих кавычек. Простейшим является переход на одинарные кавычки, потому что у них простейшие правила экранирования. Только одно правило: ''\".

Типичное имя файла в одинарных кавычках:

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


Как использовать этот трюк для безопасного выполнения команд по ssh? Это невозможно! Ну, вот «часто правильное» решение:

  • «Часто правильное» решение (python3): subprocess.check_call(['ssh', 'user@host', "sha1sum '{}'".format(path.replace("'", "'\\''"))])


Мы должны сами объединить все аргументы в строку, чтобы Ssh не сделал это неправильно: если вы попытаетесь передать несколько аргументов ssh, он начнёт предательски объединять аргументы без кавычек.

Причина, по которой это обычно невозможно, заключается в том, что правильное решение зависит от предпочтений пользователя на другом конце, а именно удалённой оболочки, которая может быть чем угодно. В принципе, это может быть даже ваша мама. «Часто правильно» предполагать, что удалённой оболочкой является bash или другая POSIX-совместимая оболочка, но fish несовместима на данном этапе.

© Habrahabr.ru