はじめに
みんなー、雑にプログラミング問題を解いて遊んでるー!? (ゝω・)v
今日は私用チームに作ったSlackボットについて解説させてくれ。
その名もRubyEvalBot、名前の通りSlackへrubyのスニペットが投稿された際にそれを(ほぼ)安全に評価して標準出力を答えてくれる便利な奴だ。
ちょっとしたコードに言及したい時や仲間が投げつけて来たプログラミング問題へ解答を叩き付けたい時に、コードを投稿したらそれがどういう結果になるのかを出してくれれば便利だよね? それをやってくれるんだ。
はい、GitHubはこっちだよー。
https://github.com/owlworks/slack-ruby-eval
## 全体の解説
難しいことはそれほどやっていない。必要なことは…
- チャットへの投稿を監視してrubyのスニペットが投稿されたら処理を実行する
- スニペットをローカルへダウンロードする
- セーフレベル1で実行し、ついでにFileなどの使われたくないメソッドについては弾く
- 処理時間を監視して長いようなら打ち切る
- 標準出力を受け取る
- 出力結果が長いようなら途中で打ち切る
などだ。
投稿の監視
def run
response = HTTP.post('https://slack.com/api/rtm.start', params: {token: CONFIG.slack.token})
url = JSON.parse(response.body)['url']
EM.run do
@ws = Faye::WebSocket::Client.new(url)
@ws.on :open do
@logger.info '=== Open Bot'
end
@ws.on :message do |event|
data = JSON.parse(event.data)
process_event(data)
end
@ws.on :close do |event|
@logger.info "===Close Bot - CODE: #{event.code}"
@ws = nil
EM.stop
end
end
end
def process_event(data)
# To override this method.
@logger.info data.inspect
end
早速だがここらへんの処理のより詳しい解説について本件の主題ではないので別記事へお任せします。
http://studio-andy.hatenablog.com/entry/ruby-bot
ま、とりあえず ws.on :message
のブロックで受け取れるeventはメッセージの投稿だけではなく、ユーザーがタイピング中であるとか、ログイン状態が変わったとか多種多様な情報を含む。
ここは様々なボットで利用することを想定した基底クラスなのでフィルタリングもせずにproccess_eventへJSONパースした結果を渡している。実際の処理はこのproccess_eventへ書いて行くというワケ。
rubyスニペットを受け取った時の処理
def process_event(data)
return unless is_ruby_snippet?(data)
url = data['file']['url_private_download'] # スニペットのダウンロードurl
file_path = download_snippet(url)
channel = data['channel']
eval_and_post_stdot(file_path, channel)
end
def is_ruby_snippet?(data)
data['subtype'] == 'file_share' && data['file']['filetype'] == 'ruby' # スニペットの共有はファイル共有(file_share)扱いで、例えば画像ファイルをアップされるのと同じ。
end
def eval_and_post_stdot(code_file_path, channel)
begin
output = get_eval_result(code_file_path)
post_text = fetch_result_message(output)
return 'Too long output.' if post_text.length > MAX_POST_LENGTH
params = { type: 'message', text: post_text, channel: channel }.to_json
@ws.send(params)
@logger.info "Posted message =>"
@logger.info params
rescue => e
@logger.error e.inspect
end
end
def get_eval_result(code_file_path)
SafeEvaler.safe_eval(code_file_path)
end
受け取ったメッセージがrubyのスニペットか判定し、そのスニペットをファイルとしてダウンロードする。そしてそれを評価し結果をスニペットが投稿されたチャットへ投稿し返す。それがこのprocess_eventの概要だ。
スニペットのダウンロード
def download_snippet(url)
uri = URI.parse(url)
response = nil
request = Net::HTTP::Get.new(uri.request_uri)
request['Authorization'] = 'Bearer ' + CONFIG.slack.token
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
http.start { |h| response = h.request(request) }
code = response.body.force_encoding('UTF-8')
file_path = File.join('/tmp/', File.basename(url) )
File.open(file_path, "w") { |f| f.puts(code) }
file_path
end
ちょっとコードが汚いなぁ…。 Slackから受け取ったjson形式のメッセージ(data)に含まれているスニペットのurl(data['file']['url_private_download']
)に対してNet::HTTPでSSL接続してアクセスし、そのレスポンスをローカルへファイルとして書き出す。
少し注意したいのは以下の部分で、ヘッダーのAuthorizationにトークンを付与してあげないとアクセス拒否される。
request['Authorization'] = 'Bearer ' + CONFIG.slack.token
だいたい多分、おそらくは安全な評価
rubyにはセキュリティ機構としてセーフモデルという仕組みがあり、これを設定することによって危険な処理を実行してしまうことを避けることが出来る…のだが現在は利用が実質的に推奨されておらず利用できなくなった。
具体的には元々あったセーフレベル0から4(高いほど安全)のうち、レベル2-4が廃止されてしまった。これによって残っているのはデフォルト値であり何も制限しないレベル0と、少しだけ制限するレベル1のみである。
セーフレベル1
セーフレベルを1に設定することで以下の処理が禁止される。
- 汚染された文字列を引数としたDir, IO, File, FileTest のメソッド呼び出し
- ファイルテスト演算子の使用、ファイルの更新時刻比較
- 外部コマンド実行 (Kernel.#system, Kernel.#exec, Kernel.#`, Kernel.#spawn など)
- Kernel.#eval
- トップレベルへの Kernel.#load (第二引数を指定してラップすれば実行可能)
- Kernel.#require
- Kernel.#trap
引数が汚染されていなければDir, IO, File, FileTestのメソッドを使うことができることに注意。機能が制限されたとはいえ、このような機能においてはセーフレベルを高めておくに越したことはないし外部コマンドの実行などは避けたい。投稿されたコードはセーフレベル1で実行することにしよう。
安全…なの?
ぶっちゃけNoだ。タチの悪いスクリプトを実行する方法はいくらでもある。クローズなチーム以外で運用すべきではない。より根本的には実行するunixユーザーを別に作ってパーミッションを絞り込むとか対策が別途必要だろう。このbotをroot権限で動かすだって?
## 処理時間の制限
さて、無限ループや非常に長く掛かる処理のコードが投稿された場合にも備えなくてはならないだろう。
rubyではTimeout.timeoutを使うことで処理時間を制限出来る。これはブロック内の処理が一定時間が過ぎても終わらなければTimeout::Errorを発生させる。
Timeout.timeout(3) do
code_to_eval = "$SAFE = 1;" + code
result = capture(:stdout) { eval(code_to_eval) }
end
このスクリプトでは3秒間待っても処理が終わらなければ例外エラーを発生させるというわけ。
標準出力を受け取る
このコードについては以下の記事で紹介しているものを利用している。コード元はwycatsさん。孫引きで申し訳ない。
http://pochi.hatenablog.jp/entry/20100324/1269413263
def capture(stream)
begin
stream = stream.to_s
eval "$#{stream} = StringIO.new"
yield
result = eval("$#{stream}").string
ensure
eval("$#{stream} = #{stream.upcase}")
end
result
end
# usage
result = capture(:stdout) { eval(code_to_eval) }
ここでは何をしているのか? ちょっとわかりにくいかな
。汎用性をなくして愚直に書き直してみよう。
def capture
begin
eval '$stdout = StringIO.new' # 標準出力先を画面からStringIOに変える
yield # 渡されたブロック(評価したいコード)の実行
result = eval('$stdout').string # 上記で作成して標準出力を受け取ったStringIOを文字列化してresultへ入れる
ensure
eval('$stdout = STDOUT') # エラーが出ようが標準出力先は元に戻す
end
result
end
オーケー。では解説だ。まず$stdout
が謎だろう。これは標準出力先を指定している。初期値はObject::STDOUT、つまりこのオブジェクトに指定されている場合には一般的な標準出力の振る舞いをするというワケ。
そして、これは他のオブジェクトへ指定しなおすことが出来る。例えばStringIOやFileなんかにね。指定先のオブジェクトはwriteメソッドを持つ必要があるからただのStringとかは無理だよ。
# 標準出力の出力先を /tmp/foo に変更
$stdout = File.open("/tmp/foo", "w")
puts "foo" # 出力する
$stdout = STDOUT # 元に戻す
参照: Ruby 2.4.0 リファレンスマニュアル variable $>
といったところで今回のスクリプトで解説すべき点はおしまい。
現場から以上です。╭( ・ㅂ・)و