Безопасный прием пользовательского ввода: основные методы команды Bash read
Научитесь безопасно и эффективно принимать пользовательский ввод в Bash-скриптах с помощью команды `read`. Это руководство охватывает основные методы: запросы, скрытый ввод паролей с `-s`, установка тайм-аутов с `-t`, а также базовая проверка и очистка ввода для создания более надежных и безопасных интерактивных скриптов.
Безопасный прием пользовательского ввода: основные методы команды Bash read
Команда Bash read выглядит безобидно, пока полученное значение не будет использовано в пути к файлу, аргументе команды или запросе пароля. Большинство проблем возникает не из-за самого read. Они возникают из-за слишком раннего доверия к тексту, забывания о том, что пробелы и метасимволы оболочки — это обычный пользовательский ввод, или из-за того, что скрипт зависает навсегда, потому что никто не ответил на запрос.
Хороший интерактивный Bash-скрипт относится к вводу как к ненадежному тексту. Он четко запрашивает, внимательно читает, проверяет перед действием и хранит секреты вдали от логов. Это звучит формально, но в повседневной версии все просто: заключайте переменные в кавычки, используйте IFS= read -r по умолчанию, проверяйте статус возврата и отклоняйте значения, с которыми не умеете работать.
Начните с самого безопасного значения по умолчанию
Для большинства однострочных запросов я использую такой шаблон:
printf 'Имя проекта: '
IFS= read -r project_name
if [[ -z $project_name ]]; then
printf 'Имя проекта обязательно.\n' >&2
exit 1
fi
Здесь стоит обратить внимание на две детали. IFS= предотвращает обрезку начальных и конечных пробелов при чтении. -r указывает read не интерпретировать обратную косую черту как escape-символ. Без -r кто-то, вводящий C:\Users\me или строку, содержащую \n, может не получить обратно точный введенный текст.
Вы также можете использовать -p для запроса:
IFS= read -r -p 'Окружение [dev/staging/prod]: ' env_name
Это нормально для интерактивного терминала. Я все еще использую printf, когда хочу, чтобы запрос и чтение было легче тестировать отдельно, или когда мне нужны более строгие привычки переносимости в отношении форматирования вывода.
Проверьте, действительно ли read выполнился успешно
read возвращает статус. Используйте его. Неудачное чтение может означать конец файла, тайм-аут или прерванный терминал. Если следующая строка вашего скрипта предполагает, что переменная значима, вы можете случайно работать со старым значением или пустой строкой.
if ! IFS= read -r -p 'Тег развертывания: ' tag; then
printf 'Ввод не получен. Прерывание.\n' >&2
exit 1
fi
Это важно в скриптах, которые иногда запускаются человеком, а иногда в CI. В неинтерактивной задаче read может сразу достичь EOF. Понятная ошибка намного лучше, чем команда развертывания, выполняющаяся с пустым тегом.
Используйте тайм-ауты для запросов, которые не должны блокироваться вечно
Скрипт обслуживания, ожидающий подтверждения, может незаметно задержать развертывание или cron-задачу. read -t устанавливает тайм-аут в секундах:
if IFS= read -r -t 15 -p 'Перезапустить службу сейчас? [y/N] ' answer; then
case $answer in
y|Y|yes|YES) systemctl restart myapp ;;
*) printf 'Перезапуск пропущен.\n' ;;
esac
else
printf '\nНет ответа в течение 15 секунд; перезапуск пропущен.\n' >&2
fi
Поддержка тайм-аута — это функция Bash, а не POSIX sh. Обычно это нормально для статьи о Bash, но стоит помнить, если скрипт может выполняться с /bin/sh на небольшом базовом образе.
Скрывайте пароли, но не притворяйтесь, что они защищены навсегда
read -s предотвращает отображение вводимых символов на терминале:
IFS= read -r -s -p 'Пароль: ' password
printf '\n'
IFS= read -r -s -p 'Подтвердите пароль: ' confirm_password
printf '\n'
if [[ $password != "$confirm_password" ]]; then
printf 'Пароли не совпадают.\n' >&2
exit 1
fi
Это защищает от подглядывания через плечо и прокрутки терминала. Это не превращает Bash в безопасный менеджер секретов. Значение все еще существует в переменной оболочки, пока скрипт выполняется. Не выводите его с включенным set -x, не передавайте через командные строки, которые отображаются в списках процессов, и не записывайте во временные файлы. Если секрет предназначен для серьезного рабочего процесса, предпочтите хранилище секретов, файл токена со строгими правами доступа или собственный запрос пароля целевого инструмента.
Одно практическое правило: отключайте xtrace вокруг обработки секретов, если окружающий скрипт использует трассировку.
set +x
IFS= read -r -s -p 'API токен: ' api_token
printf '\n'
set -x
Еще лучше — не включать xtrace обратно до тех пор, пока токен больше не будет использоваться в командах.
Проверяйте по белому списку, а не по желаемому экранированию
Проверка ввода должна соответствовать задаче. Имя ветки, имя пользователя, номер порта и описание произвольной формы — это разные типы текста. Не очищайте все одной расплывчатой функцией.
Для простого окружения развертывания разрешайте только известные значения:
IFS= read -r -p 'Окружение [dev/staging/prod]: ' env_name
case $env_name in
dev|staging|prod) ;;
*)
printf 'Недопустимое окружение: %s\n' "$env_name" >&2
exit 1
;;
esac
Для TCP-порта проверяйте как формат, так и диапазон:
IFS= read -r -p 'Порт: ' port
if ! [[ $port =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
printf 'Введите порт от 1 до 65535.\n' >&2
exit 1
fi
Для локального имени файла решите, что именно вы разрешаете. Если ваш скрипт поддерживает только простое имя файла в текущем каталоге, скажите об этом и отклоняйте косые черты:
IFS= read -r -p 'Имя выходного файла: ' filename
if ! [[ $filename =~ ^[A-Za-z0-9._-]+$ ]]; then
printf 'Используйте только буквы, цифры, точку, подчеркивание и дефис.\n' >&2
exit 1
fi
printf 'Запись в %s\n' "$filename"
Избегайте шаблона построения строки команды и последующего ее выполнения с помощью eval. printf %q может отобразить представление с экранированием оболочки, но это не лицензия на сборку ненадежных команд. Предпочитайте массивы, чтобы оболочка сохраняла каждый аргумент отдельно:
cmd=(tar -czf "$filename.tar.gz" "$filename")
"${cmd[@]}"
Читайте несколько значений только тогда, когда разделение намеренно
read first last разделяет по IFS. Если пользователь вводит больше слов, чем переменных, последняя переменная получает остаток. Это может быть полезно для имен, но также может вас удивить.
IFS= read -r -p 'Имя и фамилия: ' first_name last_name
Если ввод — Мэри Джейн Уотсон, first_name станет Мэри, а last_name станет Джейн Уотсон. Если вам нужна вся строка, читайте в одну переменную. Если вам нужен структурированный ввод, выберите разделитель и разбирайте его намеренно.
Для значений, разделенных двоеточием:
IFS=: read -r host port <<<"$target"
Затем проверьте оба поля. Не предполагайте, что разделитель появился.
Обрабатывайте значения по умолчанию, не скрывая ошибки
Значения по умолчанию полезны, когда они видны:
IFS= read -r -p 'Уровень логирования [INFO]: ' log_level
log_level=${log_level:-INFO}
Для разрушительных операций избегайте значений по умолчанию, которые делают опасное действие. Запрос типа Удалить данные? [y/N] должен обрабатывать Enter как нет, а не да.
IFS= read -r -p 'Удалить локальный кеш? [y/N] ' answer
case $answer in
y|Y|yes|YES) rm -rf -- "$cache_dir" ;;
*) printf 'Кеш оставлен на месте.\n' ;;
esac
Обратите внимание на -- перед путем. Это предотвращает интерпретацию имени файла, начинающегося с -, как опции для rm.
Сделайте запросы работающими в конвейерах и скриптах
Если ваш скрипт читает данные из стандартного ввода, интерактивный запрос может случайно потребить конвейерные данные вместо чтения с терминала. В этом случае читайте запросы из /dev/tty:
printf 'Продолжить? [y/N] ' > /dev/tty
IFS= read -r answer < /dev/tty
Этот шаблон полезен для таких инструментов, как:
generate-list | ./review-and-delete.sh
Скрипт может обрабатывать конвейерные записи из stdin, одновременно запрашивая подтверждение у оператора на управляющем терминале.
Небольшая переиспользуемая функция для запросов
Для скриптов с несколькими запросами крошечный помощник обеспечивает согласованное поведение:
prompt_required() {
local label=$1 value
while true; do
IFS= read -r -p "$label: " value || return 1
if [[ -n $value ]]; then
printf '%s\n' "$value"
return 0
fi
printf '%s обязателен.\n' "$label" >&2
done
}
project_name=$(prompt_required 'Имя проекта') || exit 1
Функция выводит принятое значение в stdout, чтобы вызывающие могли его захватить. Ошибки идут в stderr. Это сохраняет ее пригодной для использования в подстановке команд без смешивания запросов и результатов.
Короткая версия: read достаточно безопасен, когда вы сохраняете текст как данные. Используйте IFS= read -r, проверяйте сбои, скрывайте секреты с реалистичными ожиданиями, проверяйте именно то, что планируете делать, и передавайте значения как аргументы в кавычках или элементы массива. Большинство ошибок Bash, связанных с вводом, исчезают, когда эти привычки становятся автоматическими.
Избегайте запросов да/нет, которые принимают слишком много
Подтверждающий запрос должен быть скучным и строгим. Не рассматривайте любой непустой ответ как одобрение. Я видел скрипты, использующие этот шаблон:
read -r -p 'Продолжить? ' answer
if [[ $answer ]]; then
deploy_to_production
fi
Это означает, что нет, подожди и что это делает? все считаются за да. Используйте оператор case и сделайте значение по умолчанию безопасным:
IFS= read -r -p 'Развернуть в продакшн? Введите yes для продолжения: ' answer
case $answer in
yes) deploy_to_production ;;
*)
printf 'Развертывание отменено.\n' >&2
exit 1
;;
esac
Для особенно рискованных операций лучше требовать точное имя ресурса, чем запрос да/нет:
printf 'Введите %s для удаления этого пространства имен: ' "$namespace"
IFS= read -r confirmation
if [[ $confirmation != "$namespace" ]]; then
printf 'Имя не совпало. Ничего не удалено.\n' >&2
exit 1
fi
Это защищает от того, чтобы кто-то нажал Enter, не прочитав запрос.
Будьте осторожны с опциями, доступными только в терминале
Некоторые опции read предполагают наличие терминала. Тихий ввод, запросы и тайм-ауты предназначены для интерактивного использования. Если ваш скрипт может выполняться в CI, точке входа Docker или cron, проверьте, является ли stdin терминалом:
if [[ -t 0 ]]; then
IFS= read -r -p 'Имя релиза: ' release_name
else
release_name=${RELEASE_NAME:?RELEASE_NAME обязателен в неинтерактивном режиме}
fi
Это дает людям запрос, а автоматизации — четкий контракт с переменной окружения. Это также предотвращает зависание задачи сборки до тех пор, пока платформа не убьет ее.
Не используйте read для структурированных форматов, когда есть парсер
Нормально читать простое значение от человека. Менее нормально разбирать JSON, YAML, CSV или синтаксис оболочки с помощью случайного цикла read, если только формат не является действительно простым. Запятая внутри поля CSV или кавычка внутри JSON могут быстро сломать ручной разбор.
Для JSON используйте jq. Для файлов .env предпочитайте намеренно маленький формат и документируйте его. Если вы читаете построчную конфигурацию, сохраняйте строку и явно пропускайте комментарии:
while IFS= read -r line; do
[[ -z $line || $line == \#* ]] && continue
printf 'строка конфига: %s\n' "$line"
done < settings.conf
Этот цикл не волшебным образом разбирает любой формат конфигурации. Он просто честно читает строки, что является правильной отправной точкой.
Реальный обзор перед выпуском
Прежде чем назвать скрипт или настройку контейнера завершенными, прочитайте его один раз так, как будто вы следующий человек, которому придется отлаживать его в 2 часа ночи. Это меняет то, что вы замечаете. Запрос, который имел смысл при написании скрипта, может быть неоднозначным, когда он появляется в логе CI. Имя службы Docker, которое казалось очевидным, может не соответствовать имени переменной в приложении. Значение Bash по умолчанию может быть безопасным для разработки и опасным для продакшна.
Мне нравится проводить короткий пробный прогон с намеренно неудобными значениями. Используйте путь с пробелами. Используйте пустое необязательное значение. Попробуйте имя файла, начинающееся с дефиса. Запустите скрипт из другого рабочего каталога. Запустите контейнер без одной ожидаемой переменной окружения. Эти тесты не являются изысканными, но они выявляют предположения, которые обычно ломаются в первую очередь.
Также проверьте сообщение об ошибке. Если единственный вывод — failed, то совет из статьи не был реализован. Полезная ошибка сообщает, какое значение использовалось, какая проверка не удалась и что оператор может изменить. Это не означает сброс каждой переменной окружения или печать секретов. Это означает быть конкретным там, где конкретность помогает: путь к конфигу, отсутствующее имя команды, имя сети, имя хоста службы или порт, который процесс пытался привязать.
Последняя привычка — держать примеры близкими к тому, как система на самом деле работает. Если в продакшне используется Compose, тестируйте с Compose. Если скрипт запускается systemd, тестируйте его с systemd или с аналогично минимальным окружением. Если команда должна быть безопасной для копирования и вставки, включите кавычки, разделители -- и проверку в сам пример. Читатели копируют рабочие шаблоны чаще, чем копируют предупреждения.
Этот обзор — не бюрократия. Это то, как маленькая автоматизация остается скучной. Скучное — это то, что вам нужно от запросов оболочки, загрузчиков конфигурации, подстановки переменных, диагностики контейнеров и сети Docker. Чем менее удивительно поведение, тем легче следующему оператору доверять ему.