Главная » Хабрахабр » [Перевод] Как безопасно программировать в bash

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Правильно: "`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.

4: Альтернативный вариант для bash 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 не используется, рассмотрите возможность обработки ошибок даже для последней команды, так что её статус выхода не будет замаскирован, если к сценарию добавится дополнительный код. Для errexit в первую очередь никогда не используются условия в виде списка AND.

Плохо:

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-скрипта — это нормально.

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

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

Если это должна быть строка (например, потому, что она должна работать через 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 несовместима на данном этапе.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

В школах Кировской области заработала Фабрика программистов

Мы запустили проект по бесплатному обучению школьников основам современной веб-разработки в стеке Node.js / React. Пока проект работает в пилотном режиме в нескольких школах Кировской области, но мы принимаем заявки на подключение из других регионов – https://coderfactory.ru. Предыстория Все началось ...

Китай подтверждает лидерство в азиатской лунной гонке

В нулевых годах в Азии началась вторая «лунная гонка». В отличие от первой, когда в 1960-х соревновались СССР и США, стран-участников оказалось больше, а вот бюджеты меньше, и общие сроки дольше. На старте было три участника — Индия, Китай, Япония. ...