どうも、Emacs Lispアドベントカレンダーです。嘘です。
Emacsから外部コマンドを起動する方法として、大別すると「コマンドラインシェルで実行したい文字列(コマンド名と引数をまるごと含む)を渡す関数」 (shell-command系) と、「コマンドとコマンドライン引数を個別の引数として渡す関数」 (process系) の二種類の機能に分類できます。
シェルコマンドの方が使用方法がおてがる、かつポータブル(後述)なので私は特別な理由がない限りは前者のshell-command系の使用を勧めるのですが、これは同時に典型的なバグや脆弱性の温床なので注意が必要です。
用語
いろいろややこしい
-
コマンド
- キーを入力したり、M-xを押してコマンド名を入力すると実行されるEmacsの機能のこと
- コマンドは関数の一種で、Emacs Lispから関数として利用することもできる
-
シェル / コマンドラインシェル
- シェルは、広義にはOSに対して人間が操作するためのUIのこと
- 狭義にはUnixシェルと、その影響を受けたコマンドラインインタプリタのこと
- Emacsからは
shell-file-name
変数にセットされたプログラムがシェルとして利用される- 基本的には
SHELL
環境変数にセットされたシェルか、/bin/sh
が利用される - Windowsで無設定時には
cmd.exe
(コマンドプロンプト)が利用される
- 基本的には
-
シェルコマンド
- シェル(UNIXシェルあるいは
cmd.exe
)を操作するためのテキストのこと - 起動したいプログラム名に続いて空白文字区切りでプログラムに渡すパラメータををテキストで入力して記述する記法を基本的な操作体系とする
- プログラムに対してファイルの内容を入力する
<
やプログラムの出力をファイルに書き込む>
、プログラムの出力を別のプログラムの入力にする|
などの記法がある
- シェル(UNIXシェルあるいは
-
コマンド文字列
- この記事では、シェルにシェルコマンドとして実行させるために(Lispで)組み立ててた文字列のこと
- プログラム / 外部コマンド
-
プロセス
-
プロセスとは、情報処理においてプログラムの動作中のインスタンスを意味し、プログラムのコードおよび全ての変数やその他の状態を含む。
by プロセス - Wikipedia - ざっくり、実行中のプログラムのこと。
-
今回の記事の対象としない操作
Emacsの操作中にシェルコマンドを実行するのは極めて簡単です。特定のバッファを編集中にM-!を押せば、ターミナルからコマンドラインシェルに打ち込むのと同じように、シェルコマンドを実行できます。また、範囲をリージョンで選択してC-u M-|を押してシェルコマンドを入力すると、リージョン内を標準入力としてコマンドに渡し、実行結果で置換することができます。つまりテキスト編集中にEmacsから一歩も出ずにシェル芸ができるのです。
……とまあ、これらのコマンドはミニバッファにシェルコマンドを直接入力するようになっていますが、普通にターミナルからのシェルコマンド実行に慣れてればいつも通りに実行できると思うので、特別に注意すべきことはあまりありません。
問題は人間が直接入力して実行するのではなく、シェルに実行させるシェルコマンドをスクリプトで動的に構築→実行するような場合です。これには、いつものシェル実行とは別の意識が必要になります。
コマンドの「安全な実行」とは何か
外部コマンドを意図した通りに実行できることです。具体的には、ファイルやユーザー入力文字列に
(空白文字)や$
や"
や;
が含まれても、特定の値を欠損させたり、パラメータを破壊したりせず正常に実行できるでしょうか。ホームディレクトリのpathが/home/hoge fuga/
だったとしても問題ないでしょうか。
エラーが発生して動作が停止する程度ならまだましな方で、問題は意図しないコマンドが実行させられるようなパターンです。これはいわゆるOSコマンドインジェクションと呼ばれるもので、一種の脆弱性です。
これは完全に余談ですが、OSコマンドインジェクションができようとできまいとLispパッケージ作者はインストール時(バイトコンパイル時)にすら
rm -rf ~
のようなシェルコマンド実行が可能なので、Lispパッケージをインストールすることは実はそれなりにリスキーで、開発者を信頼しなければできない行為です。
該当する関数と該当しない関数
shell-command系と分類するのは、以下の関数/コマンドです。
-
shell-command
- 基本的にはコマンドとして利用する(M-!またはM-x shell-command)
- Lispから利用することはない
- コマンド文字列に
&
を含めることで非同期実行もできる
-
shell-command-to-string
- シェルコマンドを同期的に実行し、結果を文字列として得たいときに利用する関数
- 同期実行する(つまり、コマンド実行完了するまでブロックする)ため、時間のかかるコマンドには向かない
-
call-process-shell-command
- コマンドを同期的に実行する関数
-
shell-command-on-region
- M-| または C-u M-|でコマンドとして利用できる
- 内部は
call-shell-region
-
call-shell-region
- コマンドの
- バッファの内容をコマンドに送ることができる
- 実行結果を任意のバッファに書き込むことができる
- 標準入出力や標準エラー出力を得たいときに利用する
- コマンド文字列に
&
を含めることで非同期実行もできる
-
compile
- 非同期実行しながら出力をバッファに表示するコマンドに利用する
- 典型的にはコンパイルやユニットテストの実行など
- コマンドを実行しながら、ユーザーにはEmacs上で別の操作をさせることができる
-
async-shell-command
- コマンドをバックグラウンド実行できる関数
- 中ではコマンド文字列に
&
を付けてshell-command
を呼んでる
-
start-process-shell-command
- シェルのプロセスを起動してシェルコマンドを実行し、
process
オプジェクトを返す
- シェルのプロセスを起動してシェルコマンドを実行し、
なんか抜けてるものがあるかもしれませんが、だいたいこんな感じです。 多すぎだろ
今回の主題ではありませんが、process系の関数はこの記事の問題が当てはまるものもありますし、当てはまらないものもあります。このあと言及する「シェルのエスケープ」の件は当てはまりません。
よろしくないシェルコマンドの組み立て
concatする
ありがちです。
(shell-command-to-string (concat "echo " a " " b " "))
変数 a
や b
にIFS(空白文字や改行などの区切り文字)が含まれると、引数パラメータの位置がずれる(正しい引数として渡せない)など、意図しない挙動をします。また、;
が含まれる場合シェルはOSコマンドインジェクションが可能です。
引数部分を "
で括る
実コードで使ってるところはめったに見ませんが、これもありえます。
(shell-command-to-string (format "echo \"%s\" \"%s\"" a b))
実際動くのですが、これも変数 a
や b
に "
などの文字列が含まれるとコマンド入力としての"
が消える、引数パラメータの位置がずれるなど意図しない挙動が起きます。当然、OSコマンドインジェクションも可能です。
一般にコマンドラインにおいて、以下の三行は同じ結果になります。
$ echo --hoge="piyo piyo"
$ echo "--hoge=piyo piyo"
$ echo --hoge=piyo\ \ piyo
"
の位置に拘る必要はまったくありません。また、"
はIFS文字や~
をクォートする効果はありますが、"
の中であっても$
などの変数は展開されることに気をつけてください。
どうすればよいのか
shell-quote-argument
を利用します。これはシェルで特別な意味をもって解釈されうる文字をエスケープします。
(shell-command-to-string
(mapconcat #'shell-quote-argument
(list "echo" a b)
" "))
mapconcat
は第二引数に渡したリストのそれぞれの要素に第一引数で渡した関数を適用し、それを第三引数の文字列で結合します。
ここでは、文字列"echo"
, 変数a
, 変数b
のそれぞれをshell-quote-argument
で変換した結果を" "
(空白文字)でくっつける意味になります。
- 注意
shell-quote-argument
はシェルで特別な意味を持つ文字列をエスケープする目的の機能なので、process系の関数の引数に対して利用してはいけません。
combine-and-quote-strings
は使ってはいけない
shell-quote-argument
と似た関数としてcombine-and-quote-strings
があります。これは引数に文字列のリストをとり、それぞれの要素を "
でエスケープして 結合して返す関数です。
これは一見して、mapconcat
とshell-quote-argument
の組み合せよりもすっきり書けそうに見えます。しかしながらこの記法には罠があり、"
の中でも展開される記法に対する耐性がありません。
(shell-command-to-string
(combine-and-quote-strings (list "echo" a b)))
つまり、OSコマンドインジェクションも可能です。
;; 危険
(let* ((a "$(whoami)"))
(shell-command-to-string
(combine-and-quote-strings (list "echo" a)))) ; => "tadsan\n"
;; 安全
(let* ((a "$(whoami)"))
(shell-command-to-string
(mapconcat #'shell-quote-argument
(list "echo" a)
" "))) ; => "$(whoami)\n"
そんな用法を期待すべき場面など原則ありませんので、shell-quote-argument
を使ってください。
それでもはまりがちなパターン集
ここから紹介するパターンは仕様の不備や脆弱性そのものではないが、人間の認識違いや配慮の不足によって不具合を引き起こしがちなものです。
これらの多くはshell-command系だけではなく、process系の機能でも該当します。
~
の展開
(shell-command-to-string "ls ~")
は通常意図通りに動きますが、以下のようにすると動かなくなります。
(shell-command-to-string
(mapconcat #'shell-quote-argument
(list "ls" "~")
" "))
; => "ls: cannot access ~: No such file or directory\n"
これは、UNIXにおいてホームディレクトリを意味する~
は基本的にシェルで実行されるためです。ためしに任意のUNIX環境(macOS, Linux含む)のコマンドラインシェルで echo ~
や echo *
を実行してみてください。これは、echo
コマンドを実行する前にシェル側で展開されることを意味します。
ではどうするか。ファイル名に相当する値はexpand-file-name
を利用してシェルに渡す前に展開します。
(let ((file (expand-file-name "~")))
(shell-command-to-string
(mapconcat #'shell-quote-argument
(list "ls" file)
" ")))
これは実際に、私が開発したphpunit.el
で埋め込んでしまった不具合でもあります。
Expand path to PHPUnit execubatle file by zonuexe · Pull Request #60 · nlamirault/phpunit.el
コマンドラインオプションの --
これもshell-command系だけではなく、process系の機能でも該当する問題です。
例としてcat
コマンドには-n
オプションがありますが-n
というファイルを標準出力することと区別できません。意味は異なりますがls
にも-n
オプションがあります。
これらのコマンドはオプションの解釈が優先されるため、もしコマンドライン文字列を組み立てた結果が "echo -n"
や "cat -n"
になったとき、これは期待通りに実行されません。
このようなUNIXコマンドの多くでは、オプションと区別する目的で、コマンドライン引数 --
以降はオプション引数とは解釈しないという慣習があります。
;; 不適切
(let* ((default-directory (expand-file-name "~"))
(msg "-n"))
(shell-command-to-string
(mapconcat #'shell-quote-argument
(list "cat" "--" msg)
" ")))
;; 適切
(let* ((default-directory (expand-file-name "~"))
(msg "-n"))
(shell-command-to-string
(mapconcat #'shell-quote-argument
(list "cat" "--" msg)
" ")))
ただし、これはあくまで慣習なので、コマンドによって解釈は異なります。例としてはUNIXの標準的なコマンドであってもecho
コマンドは、--
のあとはオプションとして解釈を回避できますが、 --
も自体も出力に含まれます。つまり、echo
を使って(--
を含まず)-n
や-E
といった文字列だけを出力することはできません。 (これまでecho
コマンドを使ったサンプルコードを紹介してきましたが、それ自体が良くないコードだとわかりますね?1)
まして、UNIX標準ではない、その辺の誰かが作ったコマンドやスクリプトなどではこのような問題を想定して実装されてない場合が往々にして存在するので、自分ではない誰かが作ったコマンドラインアプリをラップしたLispパッケージを作成する場合は、くれぐれも十分に注意してください。
リダイレクトの>
やパイプの|
、コマンド区切りの;
、バックグラウンド実行&
process系ではなくshell-command系の機能を利用するメリット*(本当?)の一つは、コマンドライン文字列内で複数のコマンドを組み合せて実行しやすいことです。このような記号は*shell-quote-argument
でエスケープしない**ように気をつけて組み立てる必要があります。
default-directory
に気を配る
EmacsにはTRAMPと呼ばれる、リモートサーバーを擬似的なファイルシステムとして利用することができます。その中でもSSH
で接続した場合は、シェルコマンドをも透過的に利用することができます。
これはローカルサーバーではなくリモートサーバーにインストールされたコマンドを非常に簡単に実行できる(例: SSHで接続した特定の開発サーバー上でPHPUnitを実行する)のが非常に有意義である一方で、「リモートサーバー上に対象に期待したコマンドが存在しない」「リモートサーバー上にもコマンドは存在するが、ローカルにあるファイルに(当然)アクセスできない」などといった意図しない挙動を生じることがあります。
Emacs Lispのdefault-directory
変数はカレントディレクトリを意味する変数です。find-file
でファイルシステム上のファイルを開いたときのdefault-directory
はそのファイルが属するディレクトリのパスで、shell-command系の機能を実行したときのカレントディレクトリも、この値になります。
ssh hoge
でアクセスできるリモートサーバー上の~/fuga.txt
をEmacsのTRAMP機能を使ってリモートサーバー上のファイルを開いた場合は、buffer-file-name
は"/ssh:hoge:/home/myuser/fuga.txt"
にdefault-directory
は"/ssh:hoge:/home/myuser/"
になります。
どのサーバーに存在するコマンドが実行されるべきかはコマンドの機能設計によるので対処方法はケースバイケースです。例としてはgit grep
やrg
といった検索系のコマンドはそれぞれのリモートサーバーにあるコマンドが実行されるべきですし、jq
のような特定のディレクトリに依存せずに実行できるフィルタコマンドはローカルマシン上で実行しても支障ないからです。
Emacs Lispは変数がダイナミックスコープの言語であり2、let
式を使って変数の変更を局所化することができます。default-directory
が異常な値になると操作に支障をきたすので、default-directory
を変更するときは**setq
ではなくlet
を利用するとよい**です。
(let* ((default-directory (expand-file-name "~")))
(shell-command-to-string
(mapconcat #'shell-quote-argument
(list "pwd")
" ")))
カレントディレクトリをセットする際は、コマンド文字列内でcd
するのではなく、let
を使ってセットする習慣をつけてください。
筆者による実装
@tadsanは外部コマンドをラップするLispパッケージをいくつも実装してきたので設計パターンについての意見や反省はいくつもあるのですが、この記事の余白がないので、筆者が開発(あるいはメンテナンスに携った)ものを紹介して短いコメントをつけるに留めます。
-
zonuexe/pandoc.el
- コマンドを実行する部分は
call-process
です - ローカルファイル以外も変換して表示できるように作り込んであります
- コマンドを実行する部分は
-
zonuexe/php-util.el
- PHP Modeに機能を実装する前の個人的な実験場です
- PHPのビルトインサーバーを起動する機能は
make-comint
(process系)です -
php-util-thingatpt-php-token
はcall-process-region
で実装してます
-
nlamirault/phpunit.el
- コマンドを実行する部分は
compile
です - PHPUnitをSSHサーバー上やDockerを使って実行するために小細工をいろいろやってます
- コマンドを実行する部分は
-
emacs-php/composer.el
- Composerのラッパーですが、コマンドと関数のどちらとしても使えるインターフェイスを提供しています
- 実行部分が
shell-command-to-string
とasync-shell-command
とcompile
でスイッチできるよう作り込んであります
-
emacs-php/phpactor.el
- コマンド実行そのものよりもRPCをdispatchして
phpactor
コマンドと相互に通信する周りが作りこんで楽しかったです - 私はshell-command系だけで実装したかったのですが…
-
バッファの扱いがめんどくさい場合があって
押し切られてcall-process-region
も使ってるよくばりセットです - この記事を書いてて気付いたのですが、
call-shell-region
にしておけばよかったのでは…?
- コマンド実行そのものよりもRPCをdispatchして
- emacs-php/phpstan.el (flycheck-phpstan)
-
zonuexe/easel.el
- HTTPサーバーを起動する部分をrejeep/prodigy.elに任せてみました
-
emacs-php/php-runtime.el
- Emacs LispからPHPのコードを実行するためのラッパーです
- これはshell-command系ではなく、
call-process
を使って起動してます
「俺はshell-command系がおすすめだ」と言っておきながら、意外とprocess系が多かったですね…
まとめ
本当はOSコマンドインジェクションの話だけをするつもりだったんだ。信じてくれ…