ECRで1000件のCVE地獄を脱出した話|優先順位付けとライフサイクル管理

ECRで1000件のCVEが検出されてチームがフリーズした実話。重大度の見極めから自動化まで、本当に効いた対策を実装ベースで解説します。

ECRで1000件のCVEが出てチームが固まった日

先月の話なんですけど、朝の定例会でセキュリティチームから爆弾を落とされたんです。「ECRで検出された脆弱性が1000件超えました」って。その瞬間、会議室の空気が凍った。

うちのチームは去年の暮れからEKSを本番化したばかりで、脆弱性スキャンを「有効化したら終わり」って状態だった。正直、大丈夫だろうって思ってた。でも現実は、ベースイメージから本体OSまで、セキュリティパッチが当たってないレイヤーがゴロゴロ出てきたわけです。

「1000件全部修正するの?」「いつまでやるの?」って質問が飛び交って、プロダクト開発が完全にストップした。その週末、自分たちがどう対応するか考えにふけてたんですが、気づいたのは「スキャンは得点」でしかないってこと。重要なのは、そのあとのライフサイクル管理とその優先順位付けなんです。

実際に困った3つのこと

1. 優先順位がつけられない

1000件のアラート…どこから手をつけたらいいのか全くわからなかったんですよ。重大度がCriticalなやつもあれば、mediumもあるし、実際に本番で実行される可能性があるのかないのか、素人判断では区別がつかない。

スキャンレポートを見ると、Node.jsのベースイメージのライブラリから、Pythonのpipパッケージまで、かなりの粒度で報告されてる。でも「このライブラリって実際に使われてんの?」っていう質問にすら答えられなかった。セキュリティチームも「全部対応してくれ」としか言わないし、正直、困った。

2. 修正したはずなのに再検出される

ある日、チームで「Dockerfile更新して脆弱性が修正された」ってイメージをビルドしたんです。でもECRのスキャンレポートを見ると、同じCVEがまだ出ていた。理由を調べたら、ベースイメージ自体がまだパッチを当てていないバージョンだったんですよ。

これって、イメージビルド時点での脆弱性と、実行時の脆弱性を分けて考える必要があるってことなんだ。でも運用中に、新しい脆弱性が発見されることもあって、本番環境で動いてるイメージがいつの間にか「脆弱性持ち」になることもある。ホントに厄介。

3. イメージの「生存期間」が明確じゃない

うちのシステムって、開発環境、ステージング、本番に同じイメージを流してるんですけど、開発環境で使ってるイメージが6ヶ月古かったりするんです。セキュリティスキャンも「現在」のイメージだけをチェックしてて、どのバージョンがいつまで本番で動くのか、把握できてなかった。

古いイメージが環境に残ったままだと、スキャン結果の管理も曖昧になるし、ロールバックの時も「あ、このイメージ使ってた」みたいなことになる。つまり、イメージのライフサイクル自体がちぐはぐだったわけです。

実装した対策:段階的なフィルタリング

最初は「1000件全部対応しろ」と言われてたんですが、そんなのは現実的じゃない。代わりに、セキュリティチームと一緒に「どの脆弱性から修正するべきか」って優先順位の階層を決めることにしました。

# ECR脆弱性スキャン:段階的対応基準
Priority 1 (即対応):
  - CVSS Score >= 9.0
  - 既知のエクスプロイトが存在
  - 本番環境で実行中のコンポーネント
  - Criticalで、かつPackageがアプリケーションで使用中

Priority 2 (2週間以内):
  - CVSS Score 7.0 - 8.9
  - High severity
  - 本番環境で実行中
  - ベースイメージだがパッチ予定あり

Priority 3 (1ヶ月以内):
  - CVSS Score 5.0 - 6.9
  - Medium severity
  - 開発/ステージング環境のみ
  - 次回デプロイ時に修正

Ignore List:
  - OS標準ライブラリで、アプリケーションで使用されていない
  - 既知の誤検出
  - ベンダーが修正予定なし(既存バージョンで対応策あり)

この基準を運用している間に気づいたのは、「CVSSスコアが全てじゃない」ってことなんです。Critical判定されてても、そのライブラリがコンテナの中で実際に使われていなければ、攻撃可能性はゼロに近い。ここまで理解するのに、セキュリティチームとの議論が何度も必要でしたよ。

ビルド時検証:Trivyをパイプラインに組み込む

脆弱性スキャンって、イメージがECRにPushされてから初めて実行されるじゃないですか。でうちのチームでは、ビルド時点でも脆弱性をチェックしたいって話が出たんです。

そこで、Trivy(オープンソースのコンテナスキャナ)をCI/CDパイプラインに組み込みました。ビルド段階で危険な脆弱性が見つかれば、ECRへのPushそのものを止めちゃう仕組みです。

# CodeBuildのbuildspec.yml
version: 0.2

phases:
  install:
    commands:
      - apt-get update && apt-get install -y wget
      - wget https://github.com/aquasecurity/trivy/releases/download/v0.51.0/trivy_0.51.0_Linux-64bit.tar.gz
      - tar zxvf trivy_0.51.0_Linux-64bit.tar.gz

  pre_build:
    commands:
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com

  build:
    commands:
      - docker build -t $IMAGE_REPO_NAME:$IMAGE_TAG .

  post_build:
    commands:
      # Trivy スキャン実行
      - ./trivy image --severity HIGH,CRITICAL --exit-code 0 $IMAGE_REPO_NAME:$IMAGE_TAG > trivy-report.json
      # CVSSスコア9以上の場合はビルド失敗
      - |
        HIGH_VULN=$(cat trivy-report.json | jq '[.Results[] | select(.Vulnerabilities) | .Vulnerabilities[] | select(.Severity=="CRITICAL" and .CVSS.nvd.V3Score>9.0)] | length')
        if [ "$HIGH_VULN" -gt 0 ]; then
          echo "Critical vulnerability detected"
          exit 1
        fi
      # ECRにPush
      - docker push $IMAGE_REPO_NAME:$IMAGE_TAG
      # ECRのスキャンも実行させる
      - aws ecr start-image-scan --repository-name $IMAGE_REPO_NAME --image-id imageTag=$IMAGE_TAG

artifacts:
  files:
    - trivy-report.json
    - trivy-report.html

こうすることで、ビルド時に高リスクの脆弱性が検出されたら、ECRへのPushそのものを止められます。ただし、全部を検出段階で落とすと、デプロイが止まりまくるので、重大度のしきい値は社内ポリシーに合わせて調整しました。個人的には、CVSSスコア9以上で即落とす、ぐらいがちょうどいいと思います。

ライフサイクル管理:イメージのリティア戦略

セキュリティスキャンと同じくらい大事なのが、「いつまでそのイメージを使うのか」ってはっきり決めること。うちのチームでは、以下のルールを導入しました。

# ECRイメージライフサイクルポリシーの例
import json

lifecycle_policy = {
    "rules": [
        {
            "rulePriority": 1,
            "description": "Untagged images: Delete after 7 days",
            "selection": {
                "tagStatus": "untagged",
                "countType": "sinceImagePushed",
                "countUnit": "days",
                "countNumber": 7
            },
            "action": {
                "type": "expire"
            }
        },
        {
            "rulePriority": 2,
            "description": "Development images: Keep only 10 most recent",
            "selection": {
                "tagStatus": "tagged",
                "tagPrefixList": ["dev-"],
                "countType": "imageCountMoreThan",
                "countNumber": 10
            },
            "action": {
                "type": "expire"
            }
        },
        {
            "rulePriority": 3,
            "description": "Staging images: Keep only 5 most recent",
            "selection": {
                "tagStatus": "tagged",
                "tagPrefixList": ["staging-"],
                "countType": "imageCountMoreThan",
                "countNumber": 5
            },
            "action": {
                "type": "expire"
            }
        },
        {
            "rulePriority": 4,
            "description": "Production images: Keep 3 versions + 30 days",
            "selection": {
                "tagStatus": "tagged",
                "tagPrefixList": ["prod-"],
                "countType": "sinceImagePushed",
                "countUnit": "days",
                "countNumber": 30
            },
            "action": {
                "type": "expire"
            }
        }
    ]
}

このルールの肝は、環境ごとに保持するイメージの数や期間を変えるってことなんです。開発環境は古いイメージがたまりやすいので数で制限し、本番環境はロールバック対応も考えて一定期間は保持する。地味ですが、これがないと古いイメージがずっと残ったままになりますよ。

AWS構成図:脆弱性スキャン + ライフサイクル管理

graph TB
    subgraph "Developer Workflow"
        DEV["👨‍💻 Developer Push"]
        GIT["GitHub/CodeCommit"]
    end

    subgraph "Build & Scan"
        CB["CodeBuild"]
        TRIVY["Trivy Scan"]
        TRIVYRESULT{"High Risk?"}
    end

    subgraph "ECR Registry"
        ECR["ECR Repository"]
        ECRSCAN["ECR ImageScan"]
        LIFECYCLE["Lifecycle Policy"]
    end

    subgraph "Monitoring & Remediation"
        EVENTBRIDGE["EventBridge"]
        SNS["SNS Notification"]
        DASHBOARD["CloudWatch Dashboard"]
        LAMBDA["Lambda: Auto-Remediate"]
    end

    subgraph "Deployment"
        EKS["EKS Cluster"]
        ADMISSIONCONTROLLER["Admission Controller"]
    end

    DEV -->|Push| GIT
    GIT -->|Trigger| CB
    CB -->|Build Docker| TRIVY
    TRIVY -->|Check Severity| TRIVYRESULT
    TRIVYRESULT -->|❌ Critical| TRIVYRESULT
    TRIVYRESULT -->|✅ Pass| ECR
    ECR -->|Start Scan| ECRSCAN
    ECRSCAN -->|Results| EVENTBRIDGE
    EVENTBRIDGE -->|CVE Found| SNS
    EVENTBRIDGE -->|Log| DASHBOARD
    ECRSCAN -->|Cleanup| LIFECYCLE
    LIFECYCLE -->|Delete Old| ECR
    EKS -->|Pull Image| ADMISSIONCONTROLLER
    ADMISSIONCONTROLLER -->|Check Scan Result| EKS
    ADMISSIONCONTROLLER -->|Reject High Risk| LAMBDA
    LAMBDA -->|Alert| SNS

    style TRIVYRESULT fill:#ff9999
    style ADMISSIONCONTROLLER fill:#99ccff
    style ECRSCAN fill:#99ff99

実装した自動修復:CVSSスコアベースのアラート

ECRでスキャンが完了すると、EventBridgeが自動的にアラートを発火するようにしました。ただし、全てのアラートを通知するんじゃなくて、優先度でフィルタリングしています。

# Lambda: CVE優先度判定と通知
import json
import boto3
import re
from datetime import datetime

ssm = boto3.client('ssm')
sns = boto3.client('sns')

def lambda_handler(event, context):
    # ECR ImageScan completionイベントをパース
    detail = event['detail']
    image_digest = detail['image-digest']
    scan_status = detail['scan-status']
    
    if scan_status != 'COMPLETE':
        return {'statusCode': 200, 'body': 'Scan not complete'}
    
    # ECRからスキャン結果を取得
    ecr = boto3.client('ecr')
    response = ecr.describe_image_scan_findings(
        repositoryName=detail['repository-name'],
        imageId={'imageDigest': image_digest}
    )
    
    findings = response['imageScanFindings']
    critical_count = findings['findingSeverityCounts'].get('CRITICAL', 0)
    high_count = findings['findingSeverityCounts'].get('HIGH', 0)
    
    # 優先度決定
    priority = 'INFO'
    if critical_count > 0:
        priority = 'CRITICAL'
    elif high_count > 0:
        priority = 'HIGH'
    
    # Parameterストアから通知先を取得
    ssm_response = ssm.get_parameter(
        Name=f'/ecr/notification/topic-{priority}',
        WithDecryption=False
    )
    topic_arn = ssm_response['Parameter']['Value']
    
    # Slack/SNS通知
    message = f"""
    ECR脆弱性検出アラート [{priority}]
    ============================================
    Repository: {detail['repository-name']}
    Image Tag: {detail.get('image-tag', 'untagged')}
    Scan Time: {datetime.now().isoformat()}
    
    Critical: {critical_count}
    High: {high_count}
    Medium: {findings['findingSeverityCounts'].get('MEDIUM', 0)}
    Low: {findings['findingSeverityCounts'].get('LOW', 0)}
    
    詳細: https://console.aws.amazon.com/ecr/repositories
    """
    
    sns.publish(
        TopicArn=topic_arn,
        Subject=f'ECR Scan Alert: {priority}',
        Message=message
    )
    
    # 本番環境の場合、CRITICAL検出時は自動ロールバック
    if priority == 'CRITICAL' and 'prod' in detail['repository-name']:
        trigger_rollback(detail['repository-name'])
    
    return {
        'statusCode': 200,
        'body': json.dumps(f'Notification sent: {priority}')
    }

def trigger_rollback(repo_name):
    """本番イメージのCRITICAL脆弱性時に前バージョンに戻す"""
    ecr = boto3.client('ecr')
    eks = boto3.client('eks')
    
    # 前回デプロイ済みのイメージを取得
    images = ecr.describe_images(
        repositoryName=repo_name,
        filter={'tagStatus': 'TAGGED'}
    )
    
    # 最新と前のバージョンを比較
    if len(images['imageDetails']) >= 2:
        prev_image = sorted(images['imageDetails'], 
                          key=lambda x: x['imagePushedAt'])[-2]
        prev_tag = prev_image['imageTags'][0] if prev_image.get('imageTags') else None
        
        if prev_tag:
            print(f"Rolling back to {prev_tag}")
            # EKSのデプロイメント更新はCD/GitOpsツールで処理

本番環境でのアドミッションコントローラー設定

ECRでHighリスク以上が検出されたイメージは、EKSへのデプロイをそもそも許可しないようにしました。

# ValidatingWebhookConfiguration: ECR脆弱性チェック
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: ecr-vulnerability-check
webhooks:
  - name: ecr-vulnerability.security.local
    admissionReviewVersions: ["v1"]
    clientConfig:
      service:
        name: admission-webhook
        namespace: security
        path: "/validate"
      caBundle: LS0tLS1CRUdJTi...
    rules:
      - operations: ["CREATE", "UPDATE"]
        apiGroups: ["apps"]
        apiVersions: ["v1"]
        resources: ["deployments", "statefulsets", "daemonsets"]
    failurePolicy: Fail
    namespaceSelector:
      matchLabels:
        scan-enforcement: "true"

このWebhookは、デプロイメント時にイメージレジストリをチェックして、High以上の未修正脆弱性が残ってたら拒否します。「本番環境だけは絶対」って要件があったので、namespace labelで制御しています。

実装して気づいた3つのハマりどころ

1. スキャン時間がバカにならない

ECRのスキャンって、イメージサイズが大きいと5分〜10分かかることもあるんです。ビルドパイプラインがここで止まると、デプロイが遅延する。最初は同期的に待ってたんですが、今はEventBridgeで非同期化して、スキャン完了後に通知が来る仕組みにしました。これだけで体感的にストレスが全然違う。

2. ベースイメージの更新タイミング

ubuntu:latestとか、めちゃくちゃ古いタグを使ってるチームって多いじゃないですか。でも「ubuntu:latest→ubuntu:24.04」に変えたとたん、破壊的な変更に遭遇することもあります。ベースイメージを更新するときは、stagingで最低1週間は検証してから本番に上げるようにしました。焦って本番に上げたら、絶対後悔します。

3. False Positiveとの付き合い方

1000件出た脆弱性のうち、実際に影響があるのって実は100件もないんです。例えば、開発用ツール(debuggerとか)に含まれる脆弱性は、本番環境では実行されないので対応不要。こういう「無視していいCVE」を整理するのに、セキュリティチームと何度も議論しました。

最終的には、以下のルールで「無視OK」判定にしました:

  • ビルドステージのみに含まれるパッケージ
  • 実行ファイルなしの開発ライブラリ
  • 修正版がリリースされていない既知の問題(ベンダー公表)

この判定基準を明文化しておくと、新しいチームメンバーも判断しやすくなるし、セキュリティレビューも楽になりますよ。

3ヶ月運用した結果

対策を実装した1月から6月までで、検出脆弱性数がどう変わったか、数字で見るとこんな感じです:

xychart-beta
  title "ECR脆弱性検出数の推移"
  x-axis ["1月", "2月", "3月", "4月", "5月", "6月"]
  y-axis "検出脆弱性数" 0 --> 1200
  line [1050, 850, 620, 480, 320, 180]
  line [280, 200, 120, 60, 30, 8] label:"Critical"
  line [450, 350, 250, 180, 90, 40] label:"High"

検出脆弱性数が大幅に減りました。特にCriticalは、ほぼ毎回修正されるようになった。優先度付けして自動化したおかげで、修正サイクルが回り始めたんですね。

でも正直なところ、「ゼロ」にはなってません。セキュリティはキリのない戦いで、新しい脆弱性は毎日発見されます。ただ、重大度別に対応優先度をつけて、自動スキャン→自動修復のフローが回るようになったおかげで、セキュリティチームの負荷が激減しました。プロダクト開発も止まらなくなったし、これは地味に大きい。

まとめ

1000件のCVEに直面したときは絶望的でしたが、やってみて気づいたのは「全部同じ優先度じゃない」ってこと。CVSSスコアと実行環境の組み合わせで、対応する脆弱性と無視していい脆弱性を分けられます。

次のアクション:

  • ECRライフサイクルポリシーを環境ごとに設定(開発は短く、本番は長めに)
  • CodeBuildにTrivyを組み込んで、ビルド時検証を開始
  • セキュリティチームと「無視OK」ポリシーを明文化
  • EventBridge + Lambdaで、優先度別アラートの仕組みを構築
  • EKS Admission Controllerで、本番環境へのデプロイ時チェック追加

セキュリティ対応って「完璧を目指す」より「継続できる仕組み」が大事だと痛感しました。完全なセキュリティを求めるあまり、開発が止まっちゃったら本末転倒ですしね。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事