ユーザー入力を安全に受け付ける: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

覚えておくべき2つの詳細があります。IFS= は、Bashが読み取り時に先頭と末尾の空白をトリミングするのを防ぎます。-r は、read がバックスラッシュをエスケープ文字として扱わないようにします。-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 '\n15秒以内に回答がありませんでした。再起動をスキップしました。\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 が有効な状態で出力したり、プロセスリストに表示されるコマンドラインに渡したり、一時ファイルに書き込んだりしないでください。シークレットが本番ワークフローにとって重要な場合は、シークレットストア、厳格な権限を持つトークンファイル、またはターゲットツールのネイティブパスワードプロンプトを優先してください。

実用的なルールの1つ:周囲のスクリプトがトレーシングを使用している場合は、シークレット処理の周りでxtraceを無効にします。

set +x
IFS= read -r -s -p 'APIトークン: ' api_token
printf '\n'
set -x

さらに良いのは、トークンがコマンドによって参照されなくなるまで、xtraceを再度有効にしないことです。

希望的エスケープではなく、許可リストで検証する

入力検証はジョブに一致させる必要があります。ブランチ名、ユーザー名、ポート番号、自由形式の説明は異なる種類のテキストです。1つの曖昧な関数ですべてをサニタイズしないでください。

単純なデプロイメント環境の場合、既知の値のみを許可します:

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 lastIFS で分割します。ユーザーが変数よりも多くの単語を入力すると、最後の変数が残りを受け取ります。これは名前に便利ですが、驚かせることもあります。

IFS= read -r -p '姓と名: ' first_name last_name

入力が Mary Jane Watson の場合、first_nameMary になり、last_nameJane Watson になります。行全体が必要な場合は、1つの変数に読み取ります。構造化入力が必要な場合は、区切り文字を選択して意図的に解析します。

コロン区切りの値の場合:

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バグのほとんどは消えます。

受け入れすぎる yes/no プロンプトを避ける

確認プロンプトは退屈で厳格であるべきです。空でない回答を承認として扱わないでください。私はスクリプトが次のパターンを使用しているのを見たことがあります:

read -r -p '続行しますか? ' answer
if [[ $answer ]]; then
  deploy_to_production
fi

これは、nowaitこれは何をするの? がすべて「はい」としてカウントされることを意味します。case ステートメントを使用し、デフォルトを安全にします:

IFS= read -r -p '本番環境にデプロイしますか? 続行するには yes と入力してください: ' answer
case $answer in
  yes) deploy_to_production ;;
  *)
    printf 'デプロイメントがキャンセルされました。\n' >&2
    exit 1
    ;;
esac

特にリスクの高い操作では、yes/no プロンプトよりも正確なリソース名を要求する方が良いです:

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のデフォルトがあるかもしれません。

私は、意図的に扱いにくい値を使って短いドライランを行うのが好きです。スペースを含むパスを使用します。空のオプション値を使用します。ダッシュで始まるファイル名を試します。異なる作業ディレクトリからスクリプトを実行します。1つの期待される環境変数なしでコンテナを起動します。これらのテストは派手ではありませんが、通常最初に壊れる前提をキャッチします。

また、失敗メッセージも確認してください。出力が failed だけの場合、記事のアドバイスは実装に反映されていません。有用な失敗は、どの値が使用されたか、どのチェックが失敗したか、オペレーターが何を変更できるかを示します。これは、すべての環境変数をダンプしたり、シークレットを印刷したりすることを意味しません。具体的な情報が役立つ場合に具体的にすることを意味します:設定パス、欠落しているコマンド名、ネットワーク名、サービスホスト名、プロセスがバインドしようとしたポートなど。

最後の習慣は、例をシステムが実際に実行される方法に近づけることです。本番環境がComposeを使用する場合は、Composeでテストします。スクリプトがsystemdによって起動される場合は、systemdまたは同様に最小限の環境でテストします。コマンドがコピー&ペーストしても安全であるべき場合は、例自体に引用符、-- セパレーター、検証を含めます。読者は、警告よりも動作するパターンをコピーすることが多いです。

そのレビューパスは官僚主義ではありません。小さな自動化を退屈に保つ方法です。退屈さは、シェルプロンプト、設定ローダー、変数展開、コンテナ診断、Dockerネットワーキングに求めるものです。動作の驚きが少なければ少ないほど、次のオペレーターがそれを信頼しやすくなります。