秘密情報はログとレイヤーに残る
一言でいうと
パイプラインで秘密情報が漏れる場所は、ほぼ決まっています。ビルドログ、イメージのレイヤーと設定の履歴、リポジトリのコミット履歴、アーティファクトの4か所です。そして、一度漏れた秘密情報への対応は、消すことではなく、取り消して新しく発行することです。
なぜ必要なのか
秘密情報の扱いでの失敗は、たいてい悪意ではなく、便宜から生まれます。デバッグのために環境変数をまるごと出力し、ビルド引数でトークンを渡すのが最も簡単で、設定ファイルに値を入れたままコミットしました。どれも、1人が数分を節約するためにしたことなのに、結果は、その値を読める人の範囲がまるごと広がることです。
4つの場所を1つずつ見ていきます。
- ビルドログ: ログは、たいてい組織の中で広く読まれ、長く残り、検索されます。値を出力するコマンドが1行あれば十分です。
- イメージのレイヤーと設定の履歴: イメージは、ファイルシステムだけでなく、どのように作られたのかも一緒に含んでいます。ビルドの途中でファイルとして書き込み、あとのステップで削除しても、前のレイヤーにはそのまま残ります。
- リポジトリのコミット履歴: コミットしてしまった値は、次のコミットで削除しても履歴に残ります。その間にクローンされたコピー、フォーク、キャッシュにも残ります。
- アーティファクト: ビルドが作った設定ファイル、テストレポート、ダンプの中に値が混ざり込みます。アーティファクトは、ログより長く保管されることが多いです。
マスキングは対策ではなく、最後の防衛線
CIプラットフォームは、ログに含まれる既知の秘密情報の値を隠してくれます。助けにはなりますが、対策としてはいけません。理由は単純です。マスキングが隠せるのは、知っている文字列とまったく同じものだけだからです。値が少しでも変形されると、そのまま見えます。
- base64でエンコードして出力すると、別の文字列なので隠されません。GitHubのドキュメントも、base64はバイナリをテキストに変換するだけで、暗号化の代わりにはならないと明記しています。
- JSONやURLの中に入り込んでエスケープされると、別の文字列になります。
- 複数行の値が行ごとに分割されて出力されると、各行は登録された値と違います。
- 秘密情報から派生した値(署名、ハッシュ、トークン交換の結果)は、そもそも登録されていません。
そのため、順序は次のとおりです。まず出力しないようにし、マスキングは、ミスを減らしてくれる最後の安全網として置きます。GitHubは、自身のシークレットではない機密性のある値も::add-mask::で登録するよう勧めていますが、これも同じ限界をそのまま抱えています。そして、フォークしたリポジトリから起動されたワークフローには、GITHUB_TOKENを除いて、シークレットが渡されません。これはマスキングよりもはるかに強い保護であり、境界を分けるほうが、隠すほうよりもよいという原則を、そのまま示しています。
ビルド引数とランタイムのシークレット
Docker公式ドキュメントの文章が、結論をそのまま書いています。「ビルド引数と環境変数は、ビルドにシークレットを渡すのに適切ではありません。最終イメージに残るからです。」値を書いて削除しても、イメージが含んでいるビルドの記録に残ります。
代わりに使うのが、ビルド時のシークレットマウントです。
# 값은 그 RUN 명령이 도는 동안에만 존재하고, 레이어에 남지 않는다
RUN --mount=type=secret,id=npmtoken \
NPM_TOKEN="$(cat /run/secrets/npmtoken)" npm ci
既定のマウント先は/run/secrets/<id>(プレースホルダーはidです)で、そのコマンドが終わると消え、イメージのレイヤーに保存されません。
ランタイムのシークレットは、また別の話です。実行時に必要な値は、イメージではなく、実行環境が渡します。環境変数で渡すと便利ですが、プロセス一覧、子プロセス、エラーレポート、ダンプに一緒に載ってしまいます。ファイルで渡すほうがたいてい適切で、そのときは2つを決めます。権限(そのプロセスだけが読めるように絞り、ディレクトリの権限まで確認する)と、有効期間(いつ消えるのか、ローテーションしたら再び読み込むのか)です。
有効期間が短いほうが、構造的によい
有効期間が長い認証情報は、「いつ漏れたのかわからない」という問題を抱えています。数か月前にログへ漏れたトークンが、今も有効です。有効期間が短い認証情報は、漏れた値の使い道に有効期限をかけます。漏洩を防げない代わりに、被害が及ぶ期間を数分に縮めます。
同じ理由で、権限の範囲も狭く与えます。パイプラインが読み取りだけを必要とするなら、読み取りだけを与えます。これは漏洩を防ぐ措置ではなく、漏洩したときの被害を決める措置です。
漏洩のあとの対応は、取り消しと再発行
ここが最もよく間違えられます。値がコミットに入ってしまったとき、人は履歴を書き換えて消そうとします。履歴の修正は、すでにクローンされたコピー、フォーク、キャッシュ、ログ、バックアップには何の影響も与えません。その間にその値を読んだ人がいたかどうかがわからないことが核心です。
そのため、順序はいつも同じです。まず、その認証情報を取り消します。新しい値を発行します。使っている場所を切り替えます。そのあとではじめて、履歴の整理と再発防止の話をします。履歴から消す作業は最初のステップではなく、たいてい最も重要度の低いステップです。
現場での姿
- 失敗を調査するために環境変数をまるごと出力するデバッグのステップが残っています。事故を起こす準備が終わった状態です。必要なら、キーの名前だけを出力し、値は出力しません。
- イメージを開いてみると、ビルド引数で渡したトークンがそのまま出てきます。その値は、すでに取り消しの対象です。
- 秘密情報をファイルで渡しながら、権限を広く開けたままにしていて、同じPodの別のプロセスが読めてしまいます。
- 秘密情報が誤ってコミットされたとき、「履歴を整理したから大丈夫」とまとめられた振り返り。取り消していないなら、大丈夫ではありません。
参考
- Dockerのビルドシークレット: https://docs.docker.com/build/building/secrets/
- Dockerfileのリファレンス: https://docs.docker.com/reference/dockerfile/
- GitHub Actionsでのシークレットの使用: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions
- GitHub Actionsのワークフローコマンド: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
- GitLab CI/CDの変数: https://docs.gitlab.com/ci/variables/
次のラボですること
秘密情報が漏れる4つの場所を、シェルとgit、skopeoで1つずつ再現して防ぎます。まず、環境変数を出力するステップを入れて、ログに値が残ることを確認し、マスキングを模したフィルターを付けたうえで、base64に変換して出力し、そのフィルターをそのまま通り抜ける反例を作ります。続いて、/opt/imagesのoci-archiveをskopeo inspect --rawで開いて、イメージがファイルシステムだけでなく、設定と履歴も一緒に含んでいることを目で確認します。コミット履歴については、値をコミットして削除したあと、古いコミットからそのまま取り出してみることで扱います。最後に、秘密情報のファイルの権限と有効期間を検査するスクリプトと、漏洩への対応の手順を取り消しから書いたチェックリストを作ります。