Emacsからの安全なシェルコマンド実行

どうも、Emacs Lispアドベントカレンダーです。嘘です。

Emacsから外部コマンドを起動する方法として、大別すると「コマンドラインシェルで実行したい文字列(コマンド名と引数をまるごと含む)を渡す関数」 (**shell-command系**) と、「コマンドとコマンドライン引数を個別の引数として渡す関数」 (**process系**) の二種類の機能に分類できます。

シェルコマンドの方が使用方法がおてがる、かつポータブル(後述)なので私は特別な理由がない限りは前者の**shell-command系の使用を勧めるの**ですが、これは同時に典型的なバグや脆弱性の温床なので注意が必要です。

## 用語

いろいろややこしい

 * **コマンド**
    * キーを入力したり、<kbd><kbd>M</kbd>-<kbd>x</kbd></kbd>を押してコマンド名を入力すると実行されるEmacsの機能のこと
    * コマンドは関数の一種で、Emacs Lispから関数として利用すること*も*できる
 * **シェル** ／ コマンドラインシェル
    * **シェル**は、広義にはOSに対して人間が操作するためのUIのこと
    * 狭義には[Unixシェル](https://ja.wikipedia.org/wiki/Unix%E3%82%B7%E3%82%A7%E3%83%AB)と、その影響を受けた[コマンドラインインタプリタ](https://ja.wikipedia.org/wiki/%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89%E3%83%A9%E3%82%A4%E3%83%B3%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%97%E3%83%AA%E3%82%BF)のこと
    * Emacsからは `shell-file-name` 変数にセットされたプログラムがシェルとして利用される
        * 基本的には`SHELL`環境変数にセットされたシェルか、`/bin/sh`が利用される
        * Windowsで無設定時には `cmd.exe` (コマンドプロンプト)が利用される
 * **シェルコマンド**
    * シェル(UNIXシェルあるいは`cmd.exe`)を操作するためのテキストのこと
    * 起動したいプログラム名に続いて空白文字区切りでプログラムに渡すパラメータををテキストで入力して記述する記法を基本的な操作体系とする
    * プログラムに対してファイルの内容を入力する`<`やプログラムの出力をファイルに書き込む`>`、プログラムの出力を別のプログラムの入力にする`|`などの記法がある
 * **コマンド文字列**
    * この記事では、シェルにシェルコマンドとして実行させるために(Lispで)組み立ててた文字列のこと
 * **プログラム** ／ **外部コマンド**
    * ここでは、[実行ファイル](https://ja.wikipedia.org/wiki/%E5%AE%9F%E8%A1%8C%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB)あるいは[インタプリタ](https://ja.wikipedia.org/wiki/%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%97%E3%83%AA%E3%82%BF)で実行される[スクリプト](https://ja.wikipedia.org/wiki/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88%E8%A8%80%E8%AA%9E)のこと
    * プログラムファイルのフルパスまたは `PATH` 環境変数で指定されたディレクトリから探索される
 * **プロセス**
    * <q>プロセスとは、情報処理においてプログラムの動作中のインスタンスを意味し、プログラムのコードおよび全ての変数やその他の状態を含む。</q> by [プロセス - Wikipedia](https://ja.wikipedia.org/wiki/%E3%83%97%E3%83%AD%E3%82%BB%E3%82%B9)
    * ざっくり、**実行中のプログラム**のこと。


## 今回の記事の対象としない操作

Emacsの操作中にシェルコマンドを実行するのは極めて簡単です。特定のバッファを編集中に<kbd><kbd>M</kbd>-<kbd>!</kbd></kbd>を押せば、ターミナルからコマンドラインシェルに打ち込むのと同じように、シェルコマンドを実行できます。また、範囲をリージョンで選択して<kbd><kbd>C</kbd>-<kbd>u</kbd> <kbd>M</kbd>-<kbd>|</kbd></kbd>を押してシェルコマンドを入力すると、**リージョン内を標準入力としてコマンドに渡し、実行結果で置換する**ことができます。つまりテキスト編集中にEmacsから一歩も出ずにシェル芸ができるのです。

……とまあ、これらのコマンドはミニバッファにシェルコマンドを直接入力するようになっていますが、普通にターミナルからのシェルコマンド実行に慣れてればいつも通りに実行できると思うので、特別に注意すべきことはあまりありません。

問題は人間が直接入力して実行するのではなく、シェルに実行させるシェルコマンドをスクリプトで動的に構築→実行するような場合です。これには、いつものシェル実行とは別の意識が必要になります。

## コマンドの「安全な実行」とは何か

外部コマンドを**意図した通り**に実行できることです。具体的には、ファイルやユーザー入力文字列に` `(空白文字)や`$`や`"`や`;`が含まれても、特定の値を欠損させたり、パラメータを破壊したりせず正常に実行できるでしょうか。ホームディレクトリのpathが`/home/hoge fuga/`だったとしても問題ないでしょうか。

エラーが発生して動作が停止する程度ならまだましな方で、問題は意図しないコマンドが実行させられるようなパターンです。これはいわゆる**OSコマンドインジェクション**と呼ばれるもので、一種の脆弱性です。

> これは完全に余談ですが、OSコマンドインジェクションができようとできまいとLispパッケージ作者はインストール時(バイトコンパイル時)にすら`rm -rf ~`のようなシェルコマンド実行が可能なので、Lispパッケージをインストールすることは実はそれなりにリスキーで、開発者を信頼しなければできない行為です。

## 該当する関数と該当しない関数

**shell-command系**と分類するのは、以下の関数／コマンドです。

* `shell-command`
    * 基本的にはコマンドとして利用する(<kbd><kbd>M</kbd>-<kbd>!</kbd></kbd>または<kbd><kbd>M</kbd>-<kbd>x</kbd> <kbd>shell-command</kbd></kbd>)
    * Lispから利用することはない
    * コマンド文字列に `&` を含めることで非同期実行もできる
* `shell-command-to-string`
    * シェルコマンドを**同期的に**実行し、結果を文字列として得たいときに利用する関数
    * **同期実行する**(つまり、コマンド実行完了するまでブロックする)ため、時間のかかるコマンドには向かない
* `call-process-shell-command`
    * コマンドを同期的に実行する関数
* `shell-command-on-region`
    * <kbd><kbd>M</kbd>-<kbd>|</kbd></kbd> または <kbd><kbd>C</kbd>-<kbd>u</kbd> <kbd>M</kbd>-<kbd>|</kbd></kbd>でコマンドとして利用できる
    * 内部は`call-shell-region`
* `call-shell-region`
    * コマンドの
    * **バッファの内容をコマンドに送る**ことができる
    * **実行結果を任意のバッファに書き込む**ことができる
    * 標準入出力や標準エラー出力を得たいときに利用する
    * コマンド文字列に `&` を含めることで非同期実行もできる
* `compile`
    * **非同期実行しながら出力をバッファに表示する**コマンドに利用する
    * 典型的にはコンパイルやユニットテストの実行など
    * コマンドを実行しながら、ユーザーにはEmacs上で別の操作をさせることができる
* `async-shell-command`
    * コマンドをバックグラウンド実行できる関数
    * 中ではコマンド文字列に `&` を付けて `shell-command` を呼んでる
* `start-process-shell-command`
    * シェルのプロセスを起動してシェルコマンドを実行し、`process`オプジェクトを返す

なんか抜けてるものがあるかもしれませんが、だいたいこんな感じです。 ~~多すぎだろ~~

今回の主題ではありませんが、**process系**の関数はこの記事の問題が当てはまるものもありますし、当てはまらないものもあります。このあと言及する「シェルのエスケープ」の件は当てはまりません。

## よろしくないシェルコマンドの組み立て

### concatする

ありがちです。

```el
(shell-command-to-string (concat "echo " a " " b " "))
```
変数 `a` や `b` にIFS(空白文字や改行などの区切り文字)が含まれると、引数パラメータの位置がずれる(正しい引数として渡せない)など、意図しない挙動をします。また、`;`が含まれる場合シェルはOSコマンドインジェクションが可能です。

### 引数部分を `"` で括る

実コードで使ってるところはめったに見ませんが、これもありえます。

```el
(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`](https://www.gnu.org/software/emacs/manual/html_node/elisp/Shell-Arguments.html)を利用します。これは**シェルで特別な意味をもって解釈されうる文字**をエスケープします。

```el
(shell-command-to-string
 (mapconcat #'shell-quote-argument
            (list "echo" a b)
            " "))
```

[`mapconcat`](https://www.gnu.org/software/emacs/manual/html_node/elisp/Mapping-Functions.html)は第二引数に渡したリストのそれぞれの要素に第一引数で渡した関数を適用し、それを第三引数の文字列で結合します。

ここでは、文字列`"echo"`, 変数`a`, 変数`b`のそれぞれを`shell-quote-argument`で変換した結果を`" "`(空白文字)でくっつける意味になります。

<dl><dt>注意</dt><dd><p><code>shell-quote-argument</code>はシェルで特別な意味を持つ文字列をエスケープする目的の機能なので、<strong>process系</strong>の関数の引数に対して利用してはいけません。</p></dd></dl>

### `combine-and-quote-strings`は使ってはいけない

`shell-quote-argument`と似た関数として`combine-and-quote-strings`があります。これは引数に文字列のリストをとり、それぞれの要素を **`"` でエスケープして** 結合して返す関数です。

これは一見して、`mapconcat`と`shell-quote-argument`の組み合せよりもすっきり書けそうに見えます。しかしながらこの記法には罠があり、`"`の中でも展開される記法に対する耐性がありません。

```el
(shell-command-to-string
   (combine-and-quote-strings (list "echo" a b)))
```

つまり、OSコマンドインジェクションも可能です。

```el
;; 危険
(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 ~")`は通常意図通りに動きますが、以下のようにすると動かなくなります。

```el
(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`](https://www.gnu.org/software/emacs/manual/html_node/elisp/File-Name-Expansion.html)を利用して**シェルに渡す前に**展開します。

```el
(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](https://github.com/nlamirault/phpunit.el/pull/60)

### コマンドラインオプションの `--`

これも**shell-command系**だけではなく、**process系**の機能でも該当する問題です。

例として`cat`コマンドには`-n`オプションがありますが`-n`というファイルを標準出力することと区別できません。意味は異なりますが`ls`にも`-n`オプションがあります。

これらのコマンドは**オプションの解釈が優先される**ため、もしコマンドライン文字列を組み立てた結果が `"echo -n"` や `"cat -n"` になったとき、これは期待通りに実行されません。

このようなUNIXコマンドの多くでは、オプションと区別する目的で、**コマンドライン引数 `--` 以降はオプション引数とは解釈しないという慣習**があります。

```el
;; 不適切
(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](https://www.gnu.org/software/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`を利用するとよい**です。

```el
(let* ((default-directory (expand-file-name "~")))
  (shell-command-to-string
   (mapconcat #'shell-quote-argument
              (list "pwd")
              " ")))
```

カレントディレクトリをセットする際は、コマンド文字列内で`cd`するのではなく、`let`を使ってセットする習慣をつけてください。

## 筆者による実装

@tadsanは外部コマンドをラップするLispパッケージをいくつも実装してきたので設計パターンについての意見や反省はいくつもあるのですが、この記事の余白がないので、筆者が開発(あるいはメンテナンスに携った)ものを紹介して短いコメントをつけるに留めます。

 * [zonuexe/pandoc.el](https://github.com/zonuexe/pandoc.el)
    * コマンドを実行する部分は`call-process`です
    * ローカルファイル以外も変換して表示できるように作り込んであります
 * [zonuexe/php-util.el](https://github.com/zonuexe/php-util.el)
    * PHP Modeに機能を実装する前の個人的な実験場です
    * PHPのビルトインサーバーを起動する機能は`make-comint`(**process系**)です
    * `php-util-thingatpt-php-token`は`call-process-region`で実装してます
 * [nlamirault/phpunit.el](https://github.com/nlamirault/phpunit.el)
    * コマンドを実行する部分は`compile` です
    * PHPUnitをSSHサーバー上やDockerを使って実行するために小細工をいろいろやってます
 * [emacs-php/composer.el](https://github.com/emacs-php/composer.el)
    * Composerのラッパーですが、コマンドと関数のどちらとしても使えるインターフェイスを提供しています
    * 実行部分が`shell-command-to-string`と`async-shell-command`と`compile` でスイッチできるよう作り込んであります
 * [emacs-php/phpactor.el](https://github.com/emacs-php/phpactor.el)
    * コマンド実行そのものよりもRPCをdispatchして`phpactor`コマンドと相互に通信する周りが作りこんで楽しかったです
    * 私は**shell-command系**だけで実装したかったのですが…
    * [バッファの扱いがめんどくさい場合があって](https://github.com/emacs-php/phpactor.el/pull/30)~~押し切られて~~`call-process-region`も使ってるよくばりセットです
    * この記事を書いてて気付いたのですが、**`call-shell-region`にしておけばよかったのでは…？**
 * [emacs-php/phpstan.el](https://github.com/emacs-php/phpstan.el) (flycheck-phpstan)
    * コマンドを実行する周りは[Flycheck](http://www.flycheck.org/en/latest/)にお任せしてます
    * Flycheckの制約を抜け出してDockerを使って実行させるために[邪なテクニック](https://github.com/emacs-php/phpstan.el/pull/4)を編み出すのが楽しかったです
 * [zonuexe/easel.el](https://github.com/zonuexe/easel.el)
    * HTTPサーバーを起動する部分を[rejeep/prodigy.el](https://github.com/rejeep/prodigy.el)に任せてみました
 * [emacs-php/php-runtime.el](https://github.com/emacs-php/php-runtime.el)
    * Emacs LispからPHPのコードを実行するためのラッパーです
    * これは**shell-command系**ではなく、`call-process`を使って起動してます

「俺は**shell-command系**がおすすめだ」と言っておきながら、意外と**process系**が多かったですね…

## まとめ

本当はOSコマンドインジェクションの話だけをするつもりだったんだ。信じてくれ…

[^1]: この例は`printf`コマンドを利用することで適切に変更できます
[^2]: このことは、`let`がレキシカルスコープを作成することとは矛盾しません