TT Lab
はじめる
学ぶ 学習パス コース

GitLab CI/CD

パイプラインが事故を起こす場所

TT Labで続きを見る

一言でいうと

パイプラインは、リポジトリで最も強い権限を持って任意のコードを実行する場所なので、事故はたいてい「設定が間違っていたから」ではなく、「誰かがその実行を操れたから」起きます。

なぜ必要なのか

CIジョブは、デプロイの認証情報、レジストリのトークン、クラウドのキーを手に持ったまま動きます。そしてそのジョブが何を実行するかは、リポジトリの中のファイルが決めます。つまり、リポジトリにファイルを入れられる人は、事実上その認証情報で何でもできます。この事実に気づくのが遅いと、「ビルドサーバーは内部ネットワークだから大丈夫」という言葉で何年も過ごすことになります。

サプライチェーンの事件が、この点を繰り返し示してきました。tj-actions/changed-filesの事件で、攻撃者はリポジトリに侵入していません。広く使われていたアクションのタグを悪意のあるコミットに付け替えただけなのに、そのタグを参照していた数多くのパイプラインが、そのまま悪意のあるコードを実行しました。タグは人が付け替えられるラベルであり、パイプラインはそのラベルを信じていたのです。

どう動くのか

防御は4か所に掛けます。

1つ目は、秘密の値です。CI変数はリポジトリではなくプロジェクトの設定に置き、保護変数として指定して、保護ブランチと保護タグで動くジョブにだけ渡るようにします。マスキングはログで値を隠してくれますが、防御というより礼儀に近いものです。値をbase64でエンコードして出力すると、マスキングをすり抜けます。本当の防御は、その値がそもそもそのジョブに入らないようにすることです。

2つ目は、フォークから来たマージリクエストです。他人が書いたパイプライン設定が自分たちのランナーで動く状況なので、基本的に秘密の値は渡しません。キャッシュも信頼境界を越えます。フォークが悪意のある依存関係をキャッシュに仕込んでおくと、以降のビルドがそれをそのまま使う、キャッシュポイズニングが成立します。

3つ目は、他人の設定を引っ張ってくるincludeです。ほかのリポジトリのテンプレートを取り込むとき、refをブランチ名にすると、そのブランチが変わるたびに自分たちのパイプラインの内容も変わります。コミットSHAに固定して初めて、昨日と今日が同じになります。ジョブが使うコンテナイメージも、同じ理由でダイジェストかイミュータブルなタグに固定します。

4つ目は、元に戻せるデプロイです。本番デプロイのジョブは手動承認(when: manualとallow_failure: false)にして、environmentを指定して、何がどこに出たかを記録に残します。そしてデプロイに使うイメージのタグをコミットSHAにしておけば、事故が起きたとき「そのとき出たもの」が何かを、レジストリとGitログだけで5分以内に答えられます。本番にlatestしかなければ、この質問には誰も答えられず、ロールバックの対象もありません。

実務で守る最低ライン

現実的には、すべてを一度にやるのは難しいです。初めて手を入れるチームには、順序を決めてあげるほうがよいです。まずデプロイの認証情報を保護変数に移し、次に本番デプロイに手動承認を掛け、その次にイメージとincludeの参照を固定し、最後にフォークのマージリクエストのパイプラインポリシーを見直します。前の2つは1日で済み、事故の大半を防ぎます。

もう1つあります。パイプラインの実行時間が15分を超え始めると、人々は結果を待たずに別の作業をしに行きます。すると失敗に気づくのが遅れ、遅れて気づいた失敗は、すでに複数のコミットが積み上がったあとなので、原因を絞り込むのが難しくなります。セキュリティと速度は別々の話題のように見えますが、誰も見ていないパイプラインはゲートとしても死んでいるので、結局は同じ話になります。

次のラボですること

変数が入ってくる場所の優先順位と環境スコープを実行で確認し、マスキングのないログに秘密がどう残るかを見たあとで、漏えい検査器と参照固定の検査器を作ります。続いて、environment・手動承認・コミットSHAで元に戻せるデプロイを、最後に、設定ミスをコミット前とパイプラインの最初のジョブで止めるゲートを作ります。ラボ環境にはGitLabサーバーがないので、保護変数・マスキング・CI_JOB_TOKENは再現せず、各ラボにその限界を書きました。ラボのあとのクイズでは、保護変数とマスキングの違い、includeとイメージの参照を固定する理由、手動承認が承認ゲートになるための条件を区別して確かめます。