ネットで拾ってきたスクリプトをコピペするのは古からある話ですが、昨今はAIに書かせたものをポンポン動かすことも増えたと思います。
で、そういうスクリプトに例えば「OpenAI API (従量課金) を叩いてデータを取ってきてほしい」ことがあるわけです。
そのとき、何も考えずにメモ帳で .env にキーを置いて、スクリプトにそのまま読ませてませんか?
あろうことか、デポジットのオートチャージしてませんか??
脆弱だなあ!! (某セキュリティ芸人さん風)
先に結論を書きます。信用できないスクリプトにキーを渡すときの正解は、二段構えです。
- 渡すキーそのものを「最悪漏れても被害が限定される」ように呼び出し制限をしておく
- そのキーを、メモ帳で保存するのはやめて、スクリプト側が呼び出したことに気づける仕組みを作る
1.でやるべきことはシンプルです。「不必要に多額のデポジットをチャージしない」「オートチャージを軽率に入れない」「不必要に広い権限をAPI鍵に紐付けない」です。
2.で何をすればいいのかが問題です。
まず「コピペスクリプトは信用できない」が出発点
大前提を確認します。
AIに書かせたスクリプトも、GitHubで星が付いているスクリプトも、まず信用できないと考えなければなりません。
悪意がなくても、依存ライブラリのどれか一個が汚染されてたら終わりです。
npm install した瞬間に ~/.ssh を読んで外に投げるパッケージ、実在します。
なので「信用できないコードを、信用できないまま動かす」前提で考えます。これがサンドボックスの発想です。
コピペスクリプトで得られる利益が、万が一悪意のあるライブラリを読み込んでしまったときの弊害よりも大きいのであれば、今更これを使わないという選択肢を採ることはできないからです。
ここからはLinuxを前提にしていきますが、ここで使うのが bubblewrap(コマンド名 bwrap)です。1コマンドで「隔離された箱」が作れます。
まずはインストール。
sudo apt install bubblewrap # Debian/Ubuntu系
bwrap、一番小さい箱から
いきなり実用構成を見せても何が起きているかわからないので、最小から積み上げます。
ほぼ何もない箱の中で bash を起動してみます。
bwrap \
--ro-bind /usr /usr \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--ro-bind /bin /bin \
--proc /proc \
--dev /dev \
--unshare-all \
bash
-
--ro-bind /usr /usr— ホストの/usrを読み取り専用で箱の中に見せる。バイナリやライブラリがここにあるので、これがないと何も動きません。 -
--proc /proc--dev /dev— 箱専用の/procと最小の/devを用意する。ホストのものをそのまま見せないのがポイント。 -
--unshare-all— namespaceを全部分離する。ネットワークもPIDも何もかも、ホストから切り離す。
この箱の中でls -la ~してみてください。見慣れた「ホームディレクトリにあるはずのもの」が何も見えません。 ホームディレクトリをbindされてないからです。~/.sshも~/.configも、箱の中には存在しない。これが「秘密を入れない箱」の基本形です。
また、--unshare-allしてるのでネットワークもありません。curlしても何も起きません。
「APIを叩いてデータを取る」で必要になるもの
さて、たとえば「OpenAI APIに認証して、スクリプトでデータを取ってきたい」とします。
スクリプトが必要とするのは、最低限これだけです。
- ネットワーク(ないと通信できない)
- スクリプト本体と、それが読み書きする作業ディレクトリ
-
APIに通すための認証情報(トークン)
1と2は素直です。--unshare-allから--share-netでネットだけ戻して、作業ディレクトリを--bindすればいい。
bwrap \
--ro-bind /usr /usr \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--ro-bind /bin /bin \
--proc /proc \
--dev /dev \
--unshare-all \
--share-net \
--bind "$PWD/work" /work \
--chdir /work \
bash
問題は3です。
トークンを「ファイルで渡す」のがなぜダメか
一番安直なのは、トークンを書いたファイルを箱に見せることです。.env を bind するとか。
bwrap \
--ro-bind /usr /usr \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--ro-bind /bin /bin \
--proc /proc \
--dev /dev \
--unshare-all \
--share-net \
--bind "$PWD/work" /work \
--chdir /work \
--ro-bind "$PWD/.env" /work/.env \
bash
bashの手前の1行が増えただけです。これで箱の中の /work/.env から、スクリプトはキーを読めるようになりました。
便利ですね。でも考えてください。
この .env、箱を起動した瞬間から、箱の中の全コードがいつでも読めます。 あなたが意図した「APIを叩く」処理だけでありません。
上の bash で cat /work/.env を何度も叩いて見ましょう。これが自動で行われた場合、1回読まれたのか10回読まれたのかを知ることはできません。
理由はそれだけではありません。
pass でやりたいこと
ところで、中小企業だとありがちな話ですけど、NASの某フォルダに、会社で使っているGoogleアカウントとか、マイクロソフトアカウントとか、OpenAIアカウントとかのログイン情報がtxtかdocxかxlsxで転がっている、なんてのはザラですよね。
悪意あるソフトウェアは、これを本気で探してすっぱ抜きに来ます。
その情報を本来扱うべきプログラムから漏れる、のはもちろん問題ですが、ほかのプログラムが探しに来るのを検知しようがないのも大変に問題です。
だから平文保存をやめて、暗号化ストレージに保存しよう、というのが pass コマンドです。
ただ、これの実用的な運用としては YubiKey のような セキュリティキー を使って、物理鍵で保管庫に鍵を掛けつつ、物理鍵には通常、暗証番号を掛けますので、暗証番号入力を一定時間フリーにする、というところではないでしょうか。
すると、そのフリー時間の間は、任意の悪意あるソフトウェアが pass から中身を抜き放題で、全くセキュアではありません。
pass コマンドを都度承認で渡す
ここで私が提案するのが、私が作った pass-broker です (某商事さん風)。pass の呼出しを都度承認するわけです。
考え方はこうです。
passを使いたいが、そのまま使うには、セキュリティキーの秘密鍵が使える口を見せ、かつその秘密鍵で復号できるファイルが入ったフォルダを bwrap の箱に見せる必要があります。これではNASに平文で置いてあるよりも攻撃対象が明白な分、より脆弱です。
そうではなく、箱の中には「ユーザーにお願いする窓口」だけを置くのが、このサンプルの考え方です。
箱の中のスクリプトは「openai/api-key ってエントリの中身ちょうだい」とお願いするだけです。実際にそれを取り出して返すかどうかは、ホスト側で人間(あなた)が都度ダイアログで決めることです。
ミソは、保管庫へのアクセスと「許可するかどうかの判断」が、箱の外で起きていることです。箱の中の pass は本物じゃなくて、show と ls をお願いすることしかできないシム(薄い代理)に差し替わっています。
箱の中のコードがどれだけ汚染されていても、少なくともユーザーの承認なしに勝手にトークンを抜くことはできない。できるのは「お願い」だけで、それを通すのは人間のボタン操作だからです。
しかも pass show openai/api-key のお願いが来たら、ホスト側に出るダイアログにはどのエントリが要求されてるかがちゃんと表示されます。
openai/api-key を頼まれたのに bank/login が要求されてたら、その場で気づいて Deny できる。
ただし、Allowを押した後は守れない
ここは正直に書きます。pass-broker が守るのは、キーが箱に渡される「瞬間」までです。
あなたが Allow を押した瞬間、openai/api-key の中身は箱の中のスクリプトの手に渡ります。その後そのキーが、APIを叩くのに使われるのか、こっそり攻撃者のサーバに転送されるのか、pass-broker は一切関知しません。
これを検知するのは、今度はサンドボックスのネットワークを監視する話になります。
ここに触れると話が大きくなりすぎるので、この記事ではやめておきます。たとえばFortiGateで監視して差し止めるとかね…。
セットアップ
ホスト側に broker を入れます。README のとおりです。
sudo install -o root -g root -m 0755 pass-broker /usr/local/bin/pass-broker
install -Dm0644 systemd/pass-broker.socket ~/.config/systemd/user/pass-broker.socket
install -Dm0644 systemd/pass-broker@.service ~/.config/systemd/user/pass-broker@.service
systemctl --user daemon-reload
systemctl --user enable --now pass-broker.socket
broker本体は root所有で、サンドボックスから書き込めない場所(/usr/local/bin)に置きます。ここが効いてて、箱の中のシムが改竄されても、承認と本物の pass 実行はあくまで箱の外で起きるので、秘密は漏れません。これが信頼境界です。
systemdの socket activation で常駐するので、毎回 socat を手で起動する必要はありません。お願いが来たときだけ起きる、inetd風の作りになってます。
取り出したいトークンは普通に pass に入れておきます。
pass insert openai/api-key
# (ここでOpenAIのAPIキー sk-... を貼る)
箱の中で、トークンを使ってAPIを叩く
ここからが今回やりたかったことです。OpenAIのAPI(https://api.openai.com)に、APIキーで認証して curl する、というシンプルなスクリプトを箱の中で動かします。叩くのは副作用も課金もほぼ無い GET /v1/models(使えるモデル一覧を返すだけ)にしておきます。
リポジトリに付属の bwsandbox が、箱の組み立てを全部やってくれます。--pass-broker を付けると、
- 箱の中の
passを show/ls しかできないシムに差し替え - brokerのソケットを箱の中に通す
- シムがソケットを見つけられるよう環境変数をセット
を自動でやってくれます。~/.password-storeは渡しません。
実際に動かすとこうです。
./bwsandbox --pass-broker -- bash -c '
KEY="$(pass show openai/api-key)" # ← ここでホスト側にダイアログが出る
curl -s https://api.openai.com/v1/models \
-H "Authorization: Bearer ${KEY}"
'
pass show openai/api-key を呼んだ瞬間、ホスト側に swaynag のダイアログが出ます。
pass show "openai/api-key" を許可しますか? [Allow] [Deny]
あなたが Allow を押したら、openai/api-key の中身だけが、その一回だけ箱の中に返ります。スクリプトが裏で bank/login も読もうとしたら、その瞬間にもう一枚ダイアログが出る。あなたが気づける。これが効きます。
小ネタ:トークンをコマンドライン引数に書かない
上の例だと KEY を変数に入れてから -H "Bearer ${KEY}" に展開してますが、箱の中とはいえ、キーを引数に並べると同じ箱の中の別プロセスから ps で覗ける余地が残ります。気にするなら標準入力経由で curl に食わせます。
./bwsandbox --pass-broker -- bash -c '
pass show openai/api-key \
| sed "s/^/Authorization: Bearer /" \
| curl -s https://api.openai.com/v1/models -H @-
'
-H @- で標準入力からヘッダを読ませる形です。キーが引数にもファイルにも残りません。
何が良くなったのか
最初の「.env を bind する」やり方と比べます。
.env をbindして渡す |
pass-broker越し | |
|---|---|---|
| ディスク上の状態 | 平文でファイルに置く | 暗号化ストレージ(pass)のまま |
| 渡る範囲 | その.envに書いた全部 |
許可した1エントリだけ |
| いつ渡るか | 起動時に無条件 |
pass show の都度 |
| 呼ばれたことに気づけるか | 気づけない(何回読まれたか不明) | 都度ダイアログが出る |
| 何が要求されたか | わからない | ダイアログにエントリ名が出る |
一番効くのは一行目です。.env 渡しは、そもそもキーを平文でディスクに置くというリスクを取らないといけないですね。冒頭のNASの平文txtと、構造としては地続きです。
pass-broker越しなら、キーは最後まで pass の暗号化ストレージの中にいて、平文になるのは「あなたが Allow を押して、箱の中に渡るその一瞬」だけ。ディスクに平文のキーファイルが残りません。
そして、平文を消したうえで「渡るのは許可した1エントリだけ」「呼ばれたら毎回気づける」が乗る。冒頭で書いた「メモ帳保存をやめて、スクリプトが呼び出したことに気づける仕組み」が、これで埋まります。
まとめ
信用できないスクリプトにAPIキーを渡すなら、二段構えでした。
1. 渡すキーを「最悪漏れても被害が限定される」ものにする
呼出し制限の話です。不必要に多額のデポジットをチャージしない。オートチャージを軽率に入れない。不必要に広い権限をAPIに渡さない。pass-broker は出ていったキーを守れないので、先手を打っておきます。
2. そのキーを平文で置かず、呼ばれたら気づける形で渡す
.env にベタ書きするのをやめて、pass の暗号化ストレージに入れ、pass-broker 越しに都度承認で渡す。bwsandbox --pass-broker で配線も込みで動きます。平文ファイルが消え、呼び出しが毎回ユーザーの目を通るようになります。
この記事の内容をきっかけに、AIバイブコーディングのセキュリティ面に、少しは目が向いてくれると私はとても嬉しいです。
追伸
頑張って塞いだつもりがDockerソケットが見えていて台無しとか、ありがちなので、サンドボックスの内側から敵対的レビューするのもお忘れなく。
