【実務・中級編】 クラウド環境におけるコンテナイメージの脆弱性スキャンと署名検証 – 暗号理論・認証基盤 & エンドポイントセキュリティ防御ガイド

「よし、みんな集まってくれ。今日もタフなセキュリティの話をしよう。

最近、うちの開発チームでも『コンテナ化してKubernetes(K8s)にデプロイすれば、それだけでインフラは安全だ』と思い込んでいるメンバーを見かける。だが、それは手品のタネ明かしを見ていないのと同じだ。

攻撃者は今、稼働中の本番サーバーを直接ハッキングするような面倒なことはしない。もっとスマートに、もっと静かに、君たちが毎日使っているCI/CDパイプラインやコンテナレジストリを狙っている。これが世に言う「サプライチェーン攻撃」だ。

今回は、暗号理論(特に楕円曲線暗号 ECC)をベースにした『コンテナイメージの署名検証』と『継続的な脆弱性スキャン』を組み合わせ、ビルドからデプロイまでを完全にロックダウンする極意を教える。

教科書的なお勉強は抜きだ。実際に攻撃者が突いてくる盲点と、今日から君たちのプロジェクトにコピペで組み込める具体的な実装・設定ファイルを共有する。席に戻って、コンソールを開く準備をしてほしい。」

—

1. 攻撃者が狙う盲点:なぜ「ビルド成功」は安全を意味しないのか?

まずは、敵の視点に立ってみよう。攻撃者が君たちのシステムに侵入する最も効率的なルートは、本番環境のファイアウォールをこじ開けることではない。「信頼されている開発パイプラインの成果物(コンテナイメージ)をすり替える」ことだ。

リアルな攻撃シナリオ:レジストリ汚染と中間者攻撃

1. ベースイメージの汚染(Upstream Poisoning)
君たちが FROM node:lts や FROM python:3.9-slim と書いたとき、そのイメージが本当に公式のものだと誰が保証している? 攻撃者は、公式に酷似した名前(例: pyth0n:3.9-slim などのタイポスクワッティング)でバックドア入りのイメージを公開し、君たちがうっかりそれを取り込むのを待っている。
2. コンテナレジストリの認証奪取とイメージすり替え
万が一、CI/CDに設定されたレジストリのクレデンシャルが漏洩した場合(開発者のローカルPCがマルウェアに感染するなど)、攻撃者は既存のタグ(例: app:latest や app:v1.2.0)を、脆弱性のあるコードや暗号資産マイナーを仕込んだ同名の不正イメージで上書き(プッシュ)する。

[開発者] ---> [GitHub Actions] ---> [コンテナレジストリ] 
                                            | (ここでイメージをすり替え!)
                                            v
                                      [Kubernetes本番環境] (不正イメージを実行)

Kubernetesの Kubelet は、指定されたタグのイメージを愚直にプルして実行するだけだ。中身が本物か、あるいは改ざんされた偽物かを見分ける術を、デフォルトの状態では持っていない。

これを防ぐための唯一の防衛線が、「暗号理論に基づいた署名検証(Cosign / Notary)」と「自動化された脆弱性スキャン(Trivy)」の二重装甲だ。

—

2. 暗号理論を実務に落とし込む:なぜここで「楕円曲線暗号(ECC)」なのか?

ここで少しだけ、背景にある暗号理論に触れておこう。暗号は、正しく使い分けなければ意味がない。

コンテナの署名と検証において、かつて主流だったRSAではなく、現在は楕円曲線暗号(ECC / ECDSA)、特にEd25519などのアルゴリズムが標準的に使われている。理由は極めて実務的だ。

  • 鍵サイズとパフォーマンスの違い

RSAで十分な強度(2048〜4096ビット)を確保しようとすると、計算コストが高く、署名データも肥大化する。一方、ECC(例えば secp256r1 や Ed25519)はわずか 256ビットの鍵サイズ で、RSA 3072ビットと同等以上のセキュリティ強度を提供する。

  • CI/CDやエッジでの検証速度

コンテナのデプロイは一瞬で行われなければならない。K8sのデプロイメントがスケールアウトするたびに、重いRSA署名の検証でコンテナ起動が遅延することは許されない。ECCは検証プロセスが非常に高速であり、リソース消費も極めて少ない。

コンテナ署名ツールである Cosign は、このECCの特性を最大限に活かし、コンテナレジストリ自体に署名(メタデータ)を軽量なOCI(Open Container Initiative)オブジェクトとして格納する設計になっている。

—

3. 完全防衛レシピ:CI/CDでの「Trivyスキャン」と「Cosign署名」

能書きはここまでだ。手を動かそう。

以下は、GitHub Actionsを利用した実用的なワークフローの完全版だ。このパイプラインは以下のステップを厳密に実行する。

1. アプリケーションコードからコンテナイメージをビルドする。
2. Trivy を用いて、イメージ内に既知の脆弱性(CVE)がないかディープスキャンする(High/Criticalな脆弱性があればビルドを強制終了する)。
3. スキャンをパスした場合のみ、Cosign と事前に生成した非公開鍵(ECC)を使って、ビルドされたイメージにデジタル署名を施す。
4. 署名付きイメージをレジストリにプッシュする。

事前準備:Cosign鍵ペアの生成

まず、ローカル環境でCosignを使って署名用の鍵ペア(ECDSA-P256)を生成する。

# cosignのインストール(Macの場合)
brew install cosign

# 鍵ペアの生成(パスフレーズを求められるので設定する。自動化する場合は環境変数に格納する)
cosign generate-key-pair

生成すると、秘密鍵 cosign.key と公開鍵 cosign.pub が出力される。

  • cosign.key の中身(およびパスフレーズ)を、GitHubリポジトリの Secrets にそれぞれ COSIGN_PRIVATE_KEY、COSIGN_PASSWORD として登録する。
  • cosign.pub(公開鍵)は、後ほどKubernetesクラスタ側で検証に使う。

GitHub Actions ワークフロー定義 (.github/workflows/secure-ci.yml)

このファイルをそのまま君のプロジェクトの .github/workflows/ に配置してくれ。必要な箇所(レジストリ名など)を書き換えるだけで動作する。

name: Secure Build, Scan, and Sign Pipeline

on:
  push:
    branches: [ "main" ]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-secure:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write # CosignのKeyless署名(OIDC)を利用する場合に必要

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      # 1. Cosign のインストール
      - name: Install Cosign
        uses: sigstore/cosign-installer@v3.3.0

      # 2. Docker Buildx のセットアップ
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # 3. コンテナレジストリへのログイン
      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # 4. コンテナイメージのビルド(一時的にローカルに保存してスキャンする)
      - name: Build Docker Image (Local)
        uses: docker/build-push-action@v5
        with:
          context: .
          load: true # スキャンのためにローカルのDockerデーモンにロード
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

      # 5. Trivy による脆弱性スキャン
      # CRITICAL および HIGH の脆弱性が1つでも見つかればビルドをフェイルさせる
      - name: Run Trivy Vulnerability Scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          format: 'table'
          exit-code: '1' # 脆弱性発見時にステップを異常終了させる
          ignore-unfixed: true # 修正不可能な脆弱性は無視(実務的なノイズ削減)
          severity: 'HIGH,CRITICAL'

      # 6. 本番用レジストリへのプッシュ(スキャンをパスした場合のみ実行される)
      - name: Push Docker Image to Registry
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

      # 7. Cosignによる署名の実行(ECC秘密鍵を使用)
      - name: Sign the published Docker image
        env:
          COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
          COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
        run: |
          # 署名対象のイメージダイジェストを取得
          IMAGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest)
          
          echo "Signing image: ${IMAGE_DIGEST}"
          
          # 秘密鍵を使ってイメージにデジタル署名を施す
          echo "${COSIGN_PRIVATE_KEY}" > cosign.key
          cosign sign --key cosign.key --yes ${IMAGE_DIGEST}
          rm -f cosign.key

—

4. ゲートキーパーの配置:Kubernetes側で署名のないイメージを「拒絶」する

CI/CDで署名をしただけでは、セキュリティ対策は半分完了したに過ぎない。最後の、そして最も重要なステップは、「署名のない(あるいは署名が不正な)イメージのデプロイを本番環境(K8s)で断固として拒否する」ことだ。

これを実現するために、KubernetesのAdmission Controllerである Kyverno を使用する。

Kyverno による署名検証ポリシーの設定 (kyverno-policy.yaml)

以下のマニフェストは、特定のネームスペース(ここでは production)にデプロイされるすべてのコンテナイメージに対し、事前に生成した公開鍵(cosign.pub)による検証を義務付けるポリシーだ。

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signature
  annotations:
    policies.kyverno.io/title: Verify Image Signature with Cosign
    policies.kyverno.io/subject: Container
    policies.kyverno.io/description: >
      本番環境(production)において、指定された公開鍵で署名されていない
      すべてのコンテナイメージのデプロイをブロックします。
spec:
  validationFailureAction: Enforce # Enforceにすることで、検証失敗時にデプロイを完全にブロック
  background: false
  rules:
    - name: verify-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
              namespaces:
                - production # 本番環境のネームスペースのみを厳しく制限
      verifyImages:
        - imageReferences:
            - "ghcr.io/your-org/*" # 君の組織のレジストリパスに書き換える
          attestations: []
          authority:
            keyless: false
            # 事前に作成した「cosign.pub」の中身をここに貼り付ける
            key: |
              -----BEGIN PUBLIC KEY-----
              MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuyr9X3vB1mKyP1U26MmdZqLpX9Jp
              ...(ここに君のcosign.pubの内容をそのまま記述)...
              -----END PUBLIC KEY-----

このポリシーが防ぐシナリオ

仮に、攻撃者がレジストリの認証情報を盗み出し、マルウェアを仕込んだ ghcr.io/your-org/app:latest を無理やりプッシュしたとしよう。

しかし、攻撃者は君たちの秘密鍵(GitHub Secretsに厳重に保管されている COSIGN_PRIVATE_KEY)を持っていないため、正当な署名を作成できない。

この状態でKubernetesにデプロイしようとすると、Kyvernoがインターセプトし、以下のようなエラーを吐き出してデプロイを瞬時に却下する。

Error from server: error when creating "deployment.yaml": admission webhook "validate.kyverno.svc-fail" denied the request: image verify-image-signature failed: .spec.containers[0].image: signature verification failed for ghcr.io/your-org/app:latest

これで、サプライチェーンの最終防衛ラインは見事に保たれた。

—

5. シニアエンジニアからのアドバイス:運用を破綻させないためのプラクティス

最後に、この仕組みを実際に運用していく上での「現実的な泥臭いアドバイス」をいくつか送る。

1. 秘密鍵(PrivateKey)のライフサイクル管理
今回はGitHub Secretsに秘密鍵を置く方法を提示したが、さらにエンタープライズな環境では、HashiCorp Vault や AWS KMS / GCP Cloud KMS などのマネージドな鍵管理サービス(KMS)とCosignを連携させるべきだ。Cosignは標準で cosign sign --key awskms://... のようなKMS連携をサポートしている。これにより、秘密鍵そのものをCI/CD環境にすら露出させない運用が可能になる。
2. スキャン結果の「トリアージ」ルールを決めておく
Trivyで exit-code: 1(異常終了)を設定すると、日々見つかる新しい脆弱性(CVE)のせいで、深夜の緊急パッチ当ての最中にビルドが通らなくなるといった「運用上の自爆」が起きる。
あらかじめ、trivyignore ファイルを活用して「影響がないと評価済みの脆弱性」や「修正プログラム(Fix)が未提供の脆弱性」をフィルタリングする運用ルールをチーム内で合意しておくこと。セキュリティはビジネスを加速させるためのものであり、止めるためのものではない。

暗号理論は、ただの「数学のパズル」ではない。こうしてコンテナのビルドから本番デプロイまでのライフサイクルに正しく組み込むことで、初めて「改ざん不可能な信頼の連鎖(Chain of Trust)」を構築する強力な武器になる。

さあ、まずは開発環境のレジストリからこのパイプラインを組み込んでみてくれ。何か詰まったら、いつでもコードを持って私の席に来るといい。安全なコードをデプロイしよう!」

コメント

タイトルとURLをコピーしました