DockerイメージにGitLab CIとGoogle Cloudで署名し、検証を強制する #docker #security #gcp

はじめに
そのDockerイメージは本物か? cosignによる署名検証では、他人が作ったDockerイメージが改竄されていないことを署名で確認する方法を紹介しました。本稿では立場を変えて、自分が作ったDockerイメージが改竄されていないことをどう示すかを扱います。
2026年に起きたTrivyやLiteLLMの事件では、盗まれた認証情報による配布先への直接の公開や既存のGitタグ・Dockerタグの付け替えといった形で、配布元のリリースの仕組みが狙われました。こうした手口に備えるリリースの仕組みをGitLab CIとGoogle Cloud (GCP)で構築した一例とあわせて紹介します。
何を示すのか
Dockerイメージの配布元が示すのは、「このDockerイメージは配布元のCIが、保護されたGitタグの付いたこのコミットからビルドしたものである」ということです。利用者は署名の検証でこれを確認します。
これが成り立つには、次の四つが必要です。
- 署名の対象を、ビルドしたDockerイメージのダイジェストに固定する。
- 利用者がDockerタグで取得しても、リリースしたDockerイメージを得られる。
- CIの正規のジョブだけが署名できる。
- 利用者が署名付きの情報(attestation・SBOM・provenance)を検証できるようにする。

ダイジェストで参照する
ビルドしたDockerイメージはまずビルド用のレジストリ(本稿の例ではGitLabのコンテナレジストリ)に保存し、検査を終えてからリリース先のレジストリへコピーします。ビルド用のレジストリにはプロジェクトのメンバーや別のパイプラインもプッシュできます。そのためビルドしてから署名するまでのあいだに同じDockerタグで別のDockerイメージがプッシュされる可能性があります。後のジョブがDockerタグで参照していると、検査したDockerイメージと署名するDockerイメージが食い違いかねません。
そこでビルドツールがプッシュしたときに出力するダイジェストを以降のジョブに引き継ぎ、検査・リリース先のレジストリへのコピー・署名・デプロイはすべてそのダイジェストでDockerイメージを参照します。途中でDockerタグを付け替えられても、パイプラインが扱うDockerイメージは変わりません。

リリース先のDockerタグを不変にする
リリース先のレジストリでDockerタグを付け替えられると、Dockerタグで参照した利用者はリリースしたものとは別のDockerイメージを取得してしまいます。そこでリリース先のレジストリではDockerタグを不変にし、一度リリースしたDockerタグを付け替えられないようにします。本稿の例ではGoogle CloudのArtifact Registryのリポジトリの設定を使います。
Dockerタグを不変にすると、latestのように最新版へ付け替えていくDockerタグは使えません。そこでリリースごとに20261112のような別のDockerタグを付けます。

鍵による署名か、キーレス署名か
前回の記事で紹介したように、署名の方式には鍵による署名とキーレス署名があります。
キーレス署名なら署名の鍵を管理する必要がありません。ただし公開のSigstoreでキーレス署名を使えるのは、認証局のFulcioの設定に登録されたCIに限られます。セルフホストのGitLabでキーレス署名を使うには、Fulcioや透明性ログを含むSigstore一式を自前で運用する必要があります(GitLabのドキュメント)。認証局と透明性ログまで運用するのは負担が大きいです。
鍵による署名はCIを選びませんが、署名の鍵を配布元が守る必要があります。
本稿の例では鍵による署名を使い、鍵の守り方を次の節で説明します。
署名の鍵を守る
鍵はクラウドの鍵管理サービスに保存します。本稿の例ではGoogle CloudのCloud KMSを使います。鍵管理サービスの秘密鍵は取り出せず、署名は鍵管理サービスのAPIを呼び出して行います。署名できるのは鍵管理サービスで署名の権限を与えたアカウントだけです。
短期の認証情報を使う
Trivyの 0.69.5 と 0.69.6 は、盗まれたDocker Hubの認証情報で直接プッシュされました(Trivyの発表)。LiteLLMの 1.82.7 と 1.82.8 も、盗まれたPyPIの認証情報で直接公開されました(LiteLLMの発表)。LiteLLMはCIでTrivyを使っており、侵害されたTrivyがCIからPyPIの認証情報を盗んだとされています。どちらもDocker HubのアクセストークンとPyPIのAPIトークンという、一度発行すれば失効させるまで使い続けられる長期の認証情報が盗まれたことで起きています。クラウドのサービスアカウントの鍵やアクセスキーも長期の認証情報であり、CIに保存すれば同じように盗まれる可能性があります。
そこで、配布先への書き込みや署名には短期の認証情報を使います。GitLab CIのジョブはジョブごとに発行されるOIDCのIDトークンをクラウドに提示し、引き換えに有効期間の短い認証情報を得ます。クラウド側ではIDトークンに含まれるクレームに条件を付け、条件を満たすジョブにだけ権限を与えます。条件には保護されたGitタグ(作成できる人を限り、作成した後の付け替えを禁止できるGitタグ)を使います。たとえば次の条件をすべて満たすジョブに限ります。
- 配布元のプロジェクト(数値のID)のジョブである
- 保護されたGitタグのパイプラインである
- Gitタグがリリース用の形式に合っている
条件を満たさないジョブ(フォークや別のプロジェクトのジョブ、ブランチのパイプライン、形式に合わないGitタグのパイプライン)はどの権限も得られません。この条件をクラウド側でも確認することで、リリースを起動できる人を限定できます。本稿の例ではこうした条件をGoogle CloudのWorkload Identity Federationで設定します。

権限をジョブごとに分ける
一つのパイプラインの中でもジョブごとに必要な権限は異なります。そこで権限をジョブの役割ごとに別のサービスアカウントに分けます。たとえば結合テストではアプリケーションのコードや依存パッケージが動きます。依存パッケージが侵害されていればジョブの権限を使われるので、リリース先のレジストリへプッシュする権限は与えません。
| 役割 | 主な権限 | 使うジョブ |
|---|---|---|
| 結合テスト | テストで使うクラウドのサービスの利用 | 結合テスト |
| プッシュ | リリース先のレジストリへのプッシュ | レジストリへのコピー |
| 署名 | 鍵による署名・署名した情報の登録 | 署名 |
| デプロイ | デプロイ先の更新 | デプロイ |
どのサービスアカウントの権限を得るかは、IDトークンのaudience(トークンの宛先)で決まります。GitLab CIはジョブの定義に書いたaudienceのIDトークンしか発行しないので、あるジョブで動くプログラムが別の役割の権限を得ることはできません。
脆弱性スキャンやSBOMの生成のジョブにはIDトークンを与えません。LiteLLMの事件のようにCIで動くスキャナーが侵害されても、そのジョブにはクラウドの権限も署名の権限もないので、盗まれる認証情報がありません。


何に署名するか:attestation・SBOM・provenance
署名のジョブはリリースするDockerイメージのダイジェストに対してattestation・SBOM・provenanceの三つを登録します。三つとも同じ鍵で署名します。
attestation
attestationは「このダイジェストのDockerイメージをこのリポジトリのDockerイメージとしてリリースすることを承認した」という表明に署名を付けたものです。デプロイ時の判定に使います。
本稿の例ではattestationをGoogle CloudのBinary Authorization(デプロイ時にattestationを確認し、条件を満たすDockerイメージだけをデプロイさせる仕組み)に登録します。署名の対象を作り、Cloud KMSの鍵で署名して登録する手順は次のとおりです。
% gcloud container binauthz create-signature-payload \
--artifact-url <REGISTRY>/<IMAGE>@sha256:<DIGEST> > payload.json
% gcloud kms asymmetric-sign --location <LOCATION> --keyring <KEYRING> --key <KEY> --version <VERSION> \
--digest-algorithm sha256 --input-file payload.json --signature-file payload.sig
% gcloud container binauthz attestations create \
--artifact-url <REGISTRY>/<IMAGE>@sha256:<DIGEST> \
--attestor <ATTESTOR> --public-key-id <PUBLIC_KEY_ID> \
--signature-file payload.sig --payload-file payload.json --validate
SBOM
SBOMはDockerイメージに含まれるOSのパッケージとライブラリの一覧です。脆弱性が公表されたとき、どのリリースが該当するパッケージを含んでいたかを特定できます。
SBOMは配布元が自分で作る必要があり、TrivyやSyftなどで作れます。本稿の例ではSyftを使い、ビルド用のレジストリにあるDockerイメージをダイジェストで指定してSPDX形式のSBOMを作ります。
% syft scan registry:<BUILD_REGISTRY>/<IMAGE>@sha256:<DIGEST> -o spdx-json=sbom.spdx.json
作ったSBOMはArtifact Analysisに登録します。Cloud KMSの鍵を指定して登録すれば署名されます。
% gcloud artifacts sbom load --source sbom.spdx.json \
--uri <REGISTRY>/<IMAGE>@sha256:<DIGEST> \
--destination gs://<BUCKET> --kms-key-version <KMSの鍵のバージョン>
SBOMはDSSEという形式で署名されます。DSSEは内容の種類と本文をまとめて署名の対象にするので、三つを同じ鍵で署名しても、ある種類の文書への署名を別の種類の文書の署名として流用することはできません。
provenance
provenanceはどのコミットからどのパイプラインで、どのベースイメージとビルドツールを使ってビルドしたかの記録です。
attestationやSBOMと違って、provenanceを登録するgcloudのコマンドはありません。Google Cloudでprovenanceを作るのはCloud Buildです。GitLab CIなどCloud Build以外でビルドした場合は、配布元がprovenanceを自分で作って署名し、Artifact AnalysisのAPIで登録する必要があります。
まずSLSA provenanceの形式で、リポジトリ・Gitタグ・コミット・ベースイメージ・ビルドツールを記録したJSON(provenance.json)を作ります。
次にSBOMと同じくDSSEの形式で署名します。DSSEの手順では内容の種類(application/vnd.in-toto+json)とJSONを長さとともにつないだものに署名します。
% jq -cj . provenance.json > provenance.min.json
% printf 'DSSEv1 %d %s %d ' 28 application/vnd.in-toto+json "$(wc -c < provenance.min.json)" > pae.bin
% cat provenance.min.json >> pae.bin
% gcloud kms asymmetric-sign --location <LOCATION> --keyring <KEYRING> --key <KEY> --version <VERSION> \
--digest-algorithm sha256 --input-file pae.bin --signature-file provenance.sig
最後にJSONと署名をoccurrence(Dockerイメージに結び付ける個々の情報)にまとめ、Artifact AnalysisのAPIで登録します。登録先のnote(登録する情報の種類)は事前に作っておきます(noteとoccurrenceの作り方、occurrenceの形式)。
% jq -n --arg uri "https://<REGISTRY>/<IMAGE>@sha256:<DIGEST>" \
--arg note "projects/<PROJECT>/notes/<NOTE_ID>" \
--arg payload "$(base64 -w0 provenance.min.json)" --arg sig "$(base64 -w0 provenance.sig)" \
--arg keyid "<KMSの鍵のバージョン>" --slurpfile st provenance.min.json \
'{resourceUri: $uri, noteName: $note, build: {inTotoSlsaProvenanceV1: $st[0]},
envelope: {payload: $payload, payloadType: "application/vnd.in-toto+json",
signatures: [{sig: $sig, keyid: $keyid}]}}' > occurrence.json
% curl -X POST -H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" -d @occurrence.json \
https://containeranalysis.googleapis.com/v1/projects/<PROJECT>/occurrences
検証できるようにし、デプロイ時に強制する
利用者が検証できるように、公開鍵と検証の手順を利用者に提供します。本稿の例では公開鍵をBinary Authorizationのattestor(署名者と公開鍵を登録する設定)に登録しており、検証ではattestation・SBOM・provenanceを取得して公開鍵で署名を確認します。
デプロイの時点で署名の検証を強制すれば、検証を人の注意に頼らずに済みます。本稿の例ではCloud RunやGKEにBinary Authorizationのポリシーを設定し、配布元の鍵による有効なattestationがあるDockerイメージだけを許可します。こうしておけば、CIを通らずにレジストリへプッシュされたDockerイメージはデプロイの時点で拒否されます。ローカルPCからIaCのapplyやCLIでデプロイしようとしても拒否されます。
まとめ
本稿では、自分が作ったDockerイメージが改竄されていないことを示す仕組みをGitLab CIとGoogle Cloudで構築した一例とあわせて見てきました。
ダイジェストに署名し、署名の鍵を鍵管理サービスに保存し、CIのIDトークンと引き換えに得る短期の認証情報を使います。権限はジョブごとに分け、SBOMとprovenanceにも署名し、デプロイの時点で署名の検証を強制します。
なお、署名で示せるのは「誰がどこからビルドしたか」という出自であり、「中身が安全か」ではありません。悪意のあるコードがソースコードや依存パッケージに含まれていたとしても、配布元の正規のパイプラインを通すことができれば正しい署名がなされてしまいます。中身の安全性は脆弱性スキャンなどで対策する必要があります。
それでも、盗まれた認証情報による公開やGitタグ・Dockerタグの付け替えといった手口は短期の認証情報・保護されたGitタグ・不変のDockerタグで防げます。防げなかった場合も、署名の検証で検出できます。本稿が自分の配布するDockerイメージの改竄への備えを考える一助となれば幸いです。
