金色の配線パターンが広がる基板の接写
現場の実践

コンテナイメージ署名は10分、検証強制は未完了という現場の落とし穴

目次を見る

業務システムをKubernetesやOpenShiftで運用していて、コンテナイメージの署名を導入済み、あるいは導入を検討しているインフラ担当・SREの方に向けた内容です。Cosign(コンテナイメージの署名・検証を行うOSSツール)で署名を仕込んだのに、クラスタ側では何も検証していないという状態は珍しくありません。これは設定ミスというより、構築したチームの大半が最後まで完走していない工程だからです。

何が起きるか:署名はあるのに誰も見ていない

Cosignでイメージに署名すると、署名データはOCIアーティファクト(コンテナレジストリに保存できる汎用的な成果物の形式)として、イメージ本体の隣に追加されます。

問題は、この署名を追加しただけではkubelet(Kubernetesの各ノードでコンテナを起動する常駐プロセス)の動作が一切変わらないことです。

kubeletはタグを解決し、レジストリが返したものをそのままpullして起動します。署名の有無もその中身も確認しません。

つまり、kubectl run でDocker Hubの任意のイメージを指定すれば、署名ポリシーをどれだけ整備していてもそのまま本番環境で起動してしまいます。スライド資料では立派なサプライチェーンセキュリティ対策に見えても、実行時の防御力はゼロという状態です。

影響範囲は広く、放置すると次の3つの経路がすべて開いたままになります。

  • レジストリの乗り換え:app:v1.4.2 のようなタグは可変ポインタで、CIトークン漏洩や乗っ取られたbotアカウントがあれば誰でも向き先を変更できる
  • タイポスクワッティングや確認用デプロイ:ghrc.io(ghcr.io の打ち間違い)や検証用のdocker.ioイメージが、本番に近いnamespaceにうっかりデプロイされる
  • 来歴の証明不在:あるdigestが自社のパイプライン・リポジトリ・保護されたブランチから出てきたという機械的な証拠がない

なぜ起きるか:署名と強制は別工程だから

原因を分解すると、署名の仕組みと「強制する仕組み」が技術的にも組織的にも別物であることに尽きます。

第一に、Cosignの署名はレジストリへの追加情報に過ぎません。クラスタ側で検証ロジックを実装しない限り、署名の存在自体が何の制約にもなりません。

第二に、検証を導入する際によくあるのが、Admission Controller(Podの作成リクエストをKubernetes APIが受け付ける前にチェックする仕組み)をAudit(監査)モードのまま本番投入してしまうケースです。Auditモードは違反をログに記録するだけで、作成自体は通してしまいます。これはログ機能であって制御機能ではありません。Enforce(強制)モードに切り替えて初めて「拒否」が機能します。

第三に、鍵管理の負担です。従来型の鍵ベース署名では、CIのシークレットに長期間有効な秘密鍵を置く必要があり、鍵のローテーションや漏洩時の失効作業が発生します。この運用負荷が、検証ポリシーの整備を後回しにする一因になっています。

Cosignのkeyless署名(秘密鍵を持たない署名方式)は、この鍵管理の問題をGitHub ActionsなどCIのOIDC(OpenID Connect、CI/CDのワークフローIDを証明する仕組み)トークンに置き換えることで解決します。Fulcio(短命な証明書を発行するSigstoreの認証局)が約10分だけ有効な証明書を発行し、署名とともにRekor(署名の透明性ログ)に記録されます。鍵ファイルの保管・ローテーションが不要になる一方、検証側の整備というもう一段の作業が残ったままになりがちです。

自分のプロジェクトが該当するか確認する方法

次の3点を順に確認すると、自分のクラスタが「署名はあるが検証なし」の状態かどうか判断できます。

1つ目は、署名が実際に存在するかの確認です。

cosign verify \
  --certificate-identity-regexp "^https://github\.com/ORG/REPO/.+$" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/ORG/REPO@sha256:xxxx

このコマンドが成功すれば署名自体は存在します。ここまでは多くのチームが到達済みです。

2つ目は、クラスタ側にAdmission Controllerの検証ポリシーが存在するかどうかです。Kyverno(Kubernetes向けのポリシーエンジン)を使っている場合は、次で確認します。

kubectl get clusterpolicy -A
kubectl get clusterpolicy verify-images -o yaml

取得したYAMLの spec.validationFailureAction という項目を確認してください。値が Audit であれば、違反イメージはログに残るだけで実際には起動してしまいます。Enforce になっていない限り、実質的な防御は機能していません。

3つ目は、実際に未署名イメージのPodを作成してみる実地テストです。

kubectl run test-unsigned --image=docker.io/library/nginx:latest -n <検証用namespace>

これが拒否されず起動してしまう場合、該当のクラスタは「署名はしているが強制はしていない」状態に当てはまります。本番namespaceではなく、必ず検証用の使い捨てnamespaceで試してください。

対策の手順

該当した場合の対応は、CI側とクラスタ側の両方に手を入れる必要があります。

CI側の対応として、まずイメージはタグではなくdigest(sha256ハッシュで固定された一意の識別子)に対して署名します。タグに対する署名は、ビルド後にタグが別のdigestへ向け直されるリスクを防げません。CycloneDX形式のSBOM(Software Bill of Materials、ソフトウェア部品表)をSyft(SBOM生成ツール)で生成し、Cosignのattest機能でdigestに結び付けておくと、脆弱性監査の際に「このイメージに何が含まれているか」を機械的に追跡できます。

クラスタ側の対応として、Kyvernoなどのverify-images系ポリシーを用意し、次の2点を必ず確認してから本番に適用します。

  • 期待する証明書の発行者(issuer)と署名者の identity(regexpでリポジトリ名・ワークフロー名まで絞り込む)が正しく設定されているか
  • validationFailureAction が Enforce になっているか(Auditのまま放置しない)

導入時は、いきなり全namespaceにEnforceを適用するとビルド未対応の既存イメージが一斉にブロックされるリスクがあります。対象namespaceを限定し、段階的にEnforceの範囲を広げる進め方が現実的です。

既存の大規模システムでは、移行期間中にすべてのイメージへ署名を仕込み終えるまで時間がかかります。その間は対象を「新規にデプロイするマイクロサービス」などに絞り、既存の未署名イメージには猶予期間を設ける運用が無理のない落としどころです。

確認しておきたいこと

署名と検証強制は別の工程であり、どちらか一方だけでは守りになりません。

次の一歩として、まず自分のクラスタで clusterpolicy の validationFailureAction を確認してください。

Auditのままであれば、検証用namespaceで未署名イメージを実際に起動してみて、拒否されるかどうかを目で確認するのが確実です。

CIのワークフローにkeyless署名とSBOM添付をすでに組み込んでいるなら、残る作業はクラスタ側のEnforce化だけという状態のプロジェクトも多いはずです。

参考

Sign and Verify Container Images with Cosign and an Admission Policy That Actually Blocks

この記事について: 本記事は AI を活用して作成し、forva AI 編集部が内容を確認・監修しています。

AI 駆動開発のご相談は forva AI へ。まずはお気軽にどうぞ。