ECRで1000件のCVEが出てチームが固まった話と、1年かけた設計

Inspectorを有効化した瞬間にSlackが赤くなった経験、ありませんか?1000件超のCVEに直面したチームがトリアージ基準・ライフサイクル管理・SBOMを1年で整備した実践記録です。

去年の秋、うちのチームのECRリポジトリに対してAmazon Inspector v2の拡張スキャンを有効化した瞬間、Slackが一気に赤くなった。CVE件数が画面に並んで、最終的に1000件を超えた。その日の夕方のミーティングは、みんな黙り込んでいた。

「どこから手をつければいいんだ」という話になって、結局その日は具体的なアクションが決まらないまま終わった。あの感覚、コンテナをある程度の規模で運用してる人には分かってもらえると思う。スキャンを有効化した瞬間に「知らなければよかった」と思う、あれ。

ただ、そのまま放置するわけにもいかないので、この1年かけてECRの脆弱性スキャン・ライフサイクル管理・イメージの棚卸しを一から整備した。正直まだ完璧ではないし、チームによって最適解は変わると思うけど、実際に動いた設計と失敗をそのまま共有する。

最初の1000件を整理した話:トリアージの仕組みから始めた

まず前提として、1000件のCVEを全部直すのは現実的じゃない。それが分かっていても「全部直せ」と言い出すステークホルダーが出てくるので、最初に優先度分類の基準を作ることが重要だった。

Inspector v2は各CVEに対してEPSS(Exploit Prediction Scoring System)スコアとCVSSスコアの両方を提供してくれる。2026年時点では、単なるCVSSだけでなくEPSSが実際に悪用されている可能性を加味した優先度付けができるようになっているので、これを活用した。

import boto3
import json
from datetime import datetime, timedelta

def get_high_priority_findings(repository_name: str, registry_id: str) -> list:
    """
    EPSS > 0.1 かつ CVSS >= 7.0 のFindingを取得する
    """
    client = boto3.client('inspector2', region_name='ap-northeast-1')
    
    findings = []
    paginator = client.get_paginator('list_findings')
    
    filter_criteria = {
        'findingStatus': [{'comparison': 'EQUALS', 'value': 'ACTIVE'}],
        'resourceType': [{'comparison': 'EQUALS', 'value': 'AWS_ECR_CONTAINER_IMAGE'}],
        'ecrImageRepositoryName': [
            {'comparison': 'EQUALS', 'value': repository_name}
        ],
        'findingType': [{'comparison': 'EQUALS', 'value': 'PACKAGE_VULNERABILITY'}]
    }
    
    for page in paginator.paginate(filterCriteria=filter_criteria):
        for finding in page.get('findings', []):
            # EPSS と CVSS でフィルタ
            epss = finding.get('epss', {}).get('score', 0)
            cvss = finding.get('inspectorScore', 0)
            
            if epss >= 0.1 and cvss >= 7.0:
                findings.append({
                    'cve_id': finding.get('packageVulnerabilityDetails', {}).get('vulnerabilityId'),
                    'severity': finding.get('severity'),
                    'epss_score': epss,
                    'cvss_score': cvss,
                    'image_tags': [
                        r.get('ecrContainerImageMetadata', {}).get('tags', [])
                        for r in finding.get('resources', [])
                    ],
                    'fixed_in': finding.get('packageVulnerabilityDetails', {}).get('fixedInVersion'),
                    'updated_at': finding.get('updatedAt')
                })
    
    return sorted(findings, key=lambda x: (x['epss_score'], x['cvss_score']), reverse=True)

if __name__ == '__main__':
    results = get_high_priority_findings('my-app', '123456789012')
    print(f'高優先度 Finding 数: {len(results)}')
    for f in results[:10]:
        print(f"CVE: {f['cve_id']}, EPSS: {f['epss_score']:.3f}, CVSS: {f['cvss_score']}")

実行してみると、1000件のうち「EPSS 0.1以上かつCVSS 7.0以上」に絞れば最初の週は43件になった。これを対応対象として、残りは監視ステータスに落とした。この仕分けをチームに説明したら「なぜ全部直さないのか」という質問が出たけど、EPSSの概念を共有したら納得してもらえた。

CVSSだけ見て「全CRITICAL対応」みたいなルールを作るとチームが疲弊する。EPSSを軸に「実際に悪用されそうなものから潰す」という考え方は、2026年のコンテナセキュリティのスタンダードになってきていると感じる(コンテナセキュリティ完全ガイド2026でもこのあたりが整理されているので参考にしてほしい)。

ライフサイクルポリシーで「ゾンビイメージ」を根絶した

スキャンの優先度付けと並行して痛感したのが、使われていない古いイメージが大量に残っていることだった。ECRのコンソールを開くと、1年以上プッシュされていないイメージが各リポジトリに100枚以上ある。これが脆弱性の温床になっているのは明らかだった。

ライフサイクルポリシーをちゃんと設定したことがなかったチームは意外と多いと思う。うちもそうだった。設定した後に「もっと早くやっておけばよかった」と全員が言ったやつ。

{
  "rules": [
    {
      "rulePriority": 1,
      "description": "本番タグ付きイメージは最新50件を保持",
      "selection": {
        "tagStatus": "tagged",
        "tagPatternList": ["release-*", "v*"],
        "countType": "imageCountMoreThan",
        "countNumber": 50
      },
      "action": {
        "type": "expire"
      }
    },
    {
      "rulePriority": 2,
      "description": "PRブランチイメージは7日で削除",
      "selection": {
        "tagStatus": "tagged",
        "tagPatternList": ["pr-*", "feature-*"],
        "countType": "sinceImagePushed",
        "countUnit": "days",
        "countNumber": 7
      },
      "action": {
        "type": "expire"
      }
    },
    {
      "rulePriority": 3,
      "description": "タグなしイメージは1日で削除",
      "selection": {
        "tagStatus": "untagged",
        "countType": "sinceImagePushed",
        "countUnit": "days",
        "countNumber": 1
      },
      "action": {
        "type": "expire"
      }
    },
    {
      "rulePriority": 4,
      "description": "mainブランチイメージは最新30件を保持",
      "selection": {
        "tagStatus": "tagged",
        "tagPatternList": ["main-*", "develop-*"],
        "countType": "imageCountMoreThan",
        "countNumber": 30
      },
      "action": {
        "type": "expire"
      }
    }
  ]
}

このポリシーを適用したら3日で2300枚のイメージが消えた。ECRのストレージコストが月4万円ほど下がったのは地味に嬉しかった。ただし、本番ECSタスクで使われているイメージが消えないかの確認は絶対に必要で、実際に最初の週はCDKでデプロイ済みのタスクのイメージタグが消えるところだった。ライフサイクルポリシー適用前に、現在稼働中のタスク定義が参照するイメージタグのリストを作っておくことを強くお勧めする。

全体アーキテクチャ:CI/CDパイプラインとの統合

個別のスキャン設定だけ整えても、パイプライン全体と噛み合っていないと穴だらけになる。実際に整備した構成はこうなっている。

graph TB
    subgraph Developer["開発者ワークフロー"]
        DEV["開発者"]
        GH["GitHub"]
    end

    subgraph CICD["CI/CDパイプライン"]
        CP["CodePipeline V2"]
        CB["CodeBuild"]
        subgraph Build["ビルドステージ"]
            BUILD_STEP["Docker Build"]
            TRIVY["Trivy スキャン\n(CIゲート)"]
        end
    end

    subgraph ECR_Region["ECR / Inspector"]
        ECR["Amazon ECR\n(プライベートリポジトリ)"]
        INSPECTOR["Amazon Inspector v2\n(拡張スキャン)"]
        ECR_LIFECYCLE["ライフサイクルポリシー"]
    end

    subgraph Notification["通知・管理"]
        EB["EventBridge"]
        LAMBDA["Lambda\n(Finding集約)"]
        SEC_HUB["Security Hub"]
        SLACK["Slack通知"]
    end

    subgraph EKS_Cluster["EKSクラスター"]
        subgraph AZ1["AZ-1a"]
            NODE1["Worker Node"]
            POD1["Pod"]
        end
        subgraph AZ2["AZ-1c"]
            NODE2["Worker Node"]
            POD2["Pod"]
        end
        GATEKEEPER["OPA Gatekeeper\n(イメージポリシー検証)"]        
    end

    DEV --> GH
    GH --> CP
    CP --> CB
    CB --> Build
    BUILD_STEP --> TRIVY
    TRIVY -->|"CRITICAL検出でブロック"| BUILD_STEP
    TRIVY -->|"パス"| ECR
    ECR --> INSPECTOR
    ECR --> ECR_LIFECYCLE
    INSPECTOR --> EB
    EB --> LAMBDA
    LAMBDA --> SEC_HUB
    LAMBDA --> SLACK
    ECR --> GATEKEEPER
    GATEKEEPER --> POD1
    GATEKEEPER --> POD2

ポイントはCIゲートとECRスキャンの二段構えにしたこと。CIではTrivyでビルド時点のCRITICAL脆弱性を即時ブロックし、ECRプッシュ後はInspector v2が継続的にスキャンして新規CVEを検出する。どちらか片方だけだと穴が生まれる。これを最初から設計に組み込めなかったのが個人的な反省で、最初の3ヶ月はTrivyのCIゲートなしで運用していたため、結構な数のCRITICALがECRに積み上がっていた。

EKS側ではOPA GatekeeperのConstraintでECRの特定パスからのイメージしか実行できないようにしており、野良イメージのデプロイを防いでいる。

# OPA Gatekeeper: 許可されたECRレジストリのみ許可
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
  name: allow-only-ecr
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    namespaces:
      - production
      - staging
  parameters:
    repos:
      - "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/"

Findingを見てもチームが動かなかった問題と、その解決策

技術的な仕組みを整えても「誰が直すのか」が決まらないと何も変わらない。これ、コンテナセキュリティを導入したチームが必ず直面する問題じゃないかと思う。

うちの場合、最初の数ヶ月はInspector v2のFindingがSecurity Hubに貯まるだけで、誰も対応しなかった。原因は「どのCVEがどのチームの責任範囲なのか」が不明確だったこと。ECRリポジトリとサービスオーナーの対応表を作って、EventBridge→Lambda→Slackのパイプラインを整備した。

import boto3
import json
import os
import urllib.request

# リポジトリ名 → SlackチャンネルIDのマッピング
REPO_OWNER_MAP = {
    'api-server': 'C_BACKEND_TEAM',
    'frontend': 'C_FRONTEND_TEAM',
    'batch-worker': 'C_DATA_TEAM',
    'ml-inference': 'C_ML_TEAM',
}

def lambda_handler(event, context):
    """
    Inspector v2のFindingをSlackに通知するLambda
    EventBridgeから呼び出される
    """
    detail = event.get('detail', {})
    
    # CRITICAL のみ即時通知
    severity = detail.get('severity', '')
    if severity not in ['CRITICAL', 'HIGH']:
        return
    
    # リポジトリ名を抽出
    resources = detail.get('resources', [])
    repo_name = None
    for resource in resources:
        ecr_meta = resource.get('details', {}).get('awsEcrContainerImage', {})
        repo_uri = ecr_meta.get('repositoryName', '')
        if repo_uri:
            repo_name = repo_uri
            break
    
    channel = REPO_OWNER_MAP.get(repo_name, 'C_SECURITY_TEAM')
    
    cve_id = detail.get('packageVulnerabilityDetails', {}).get('vulnerabilityId', 'Unknown')
    epss = detail.get('epss', {}).get('score', 0)
    cvss = detail.get('inspectorScore', 0)
    fixed_in = detail.get('packageVulnerabilityDetails', {}).get('fixedInVersion', '修正版なし')
    
    # EPSS 0.1未満のHIGHは通知しない
    if severity == 'HIGH' and epss < 0.1:
        return
    
    message = {
        "channel": channel,
        "text": f":rotating_light: *Inspector v2 Alert* [{severity}]",
        "blocks": [
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"*{cve_id}* が `{repo_name}` で検出されました\n"
                            f"• Severity: `{severity}`\n"
                            f"• CVSS: `{cvss}`\n"
                            f"• EPSS: `{epss:.4f}` (悪用確率)\n"
                            f"• 修正版: `{fixed_in}`"
                }
            }
        ]
    }
    
    webhook_url = os.environ['SLACK_WEBHOOK_URL']
    req = urllib.request.Request(
        webhook_url,
        data=json.dumps(message).encode('utf-8'),
        headers={'Content-Type': 'application/json'},
        method='POST'
    )
    urllib.request.urlopen(req)

このLambdaを入れてから、各チームが自分のSlackチャンネルで通知を受け取るようになった。「自分たちのリポジトリのアラート」として認識してもらうことで、対応速度が劇的に変わった。SecurityチームからSlackで@mentionして回る必要がなくなったのはマジで助かった。正直、これが技術的な改善のどれよりも効いたかもしれない。

1年後の結果:数値で振り返る

xychart-beta
    title "月別 Active Finding数(CRITICAL+HIGH EPSS>=0.1)"
    x-axis ["Sep'25", "Oct'25", "Nov'25", "Dec'25", "Jan'26", "Feb'26", "Mar'26", "Apr'26", "May'26", "Jun'26", "Jul'26"]
    y-axis "Finding数" 0 --> 250
    bar [210, 198, 167, 142, 118, 95, 76, 58, 41, 32, 28]
    line [210, 198, 167, 142, 118, 95, 76, 58, 41, 32, 28]

最初の210件(EPSS 0.1以上のCRITICAL+HIGH)が1年で28件まで下がった。「ゼロにする」は現実的ではないという前提で運用しているが、毎月のFinding数が一桁台を目指せる水準になってきた。

取り組みの成果を整理するとこうなる。

施策導入前導入後改善
ECRイメージ枚数(全リポジトリ合計)8,400枚1,200枚-85%
CRITICAL Finding(EPSS>=0.1)対応SLA未定義72時間以内新設
CIゲートでのブロック数(月平均)0件12件/月
ECRストレージコスト月9.8万円月2.1万円-79%
Slackアラート対応率20%84%+64pt

ECRストレージコストが月7.7万円下がったのは地味に嬉しい。ただ、それよりも「誰も対応しない脆弱性アラート」が減ったことの方が実質的なインパクトは大きかったと思う。インシデント対応のベストプラクティスでも触れられているが、アラートへの対応率が低いと「アラート疲労」が起きてセキュリティイベントへの感度が鈍くなる。これを防ぐために通知の粒度設計が大事だった。

SBOM(Software Bill of Materials)の管理については、正直まだ検証中の部分が多い。InspectorはSBOMのエクスポートをサポートしているが、それをどのように活用するかの運用フローが固まっていない。OWASP Top 10 2024対策を参考に、ソフトウェアサプライチェーンの観点からSBOMを活用した監査ルーティンを整備しようとしているところだ。

もう一点、ベースイメージの選択が思ったより重要だった。ubuntu:latestdebian:latestベースのイメージを使っているとFinding数が多くなる。Distrolessやalpineベースに切り替えるだけで、Findingが40〜60%減るケースが多い。これだけで数ヶ月分の修正コストを節約できるので、最初にベースイメージを整理することを強くお勧めする。地味な作業だけど、効果は大きい。

皆さんのチームではECRの脆弱性スキャン、どのタイミングで対応を判断してますか?EPSSを判断基準に使っているチームはまだ少ない印象があるので、もし使っているチームがあれば知りたい。

まとめ

1年かけて整備してきた内容を振り返ると、技術より「誰がどう動くか」の設計の方がずっとしんどかった。ツールは揃っているのに運用が回らない、という状態が一番もったいない。

  • EPSSを軸にしたトリアージが最初の鍵:1000件のCVEを全部直そうとしない。EPSS 0.1以上かつCVSS 7.0以上に絞ると現実的な対応範囲になる
  • ライフサイクルポリシーは必須インフラ:未設定のリポジトリは今すぐ設定するべき。タグなしイメージの1日削除、古いPRブランチイメージの7日削除で大半のゴミが消える
  • 通知の届け先とオーナーシップを明確に:「SecurityチームのFinding」ではなく「自分たちのリポジトリのアラート」として認識させることが対応率向上のカギ
  • CIゲートとECRスキャンの二段構え:Trivyで即時ブロック、Inspector v2で継続監視。片方だけでは穴が生まれる
  • ベースイメージの選定が数ヶ月分の節約になる:alpine・Distrolessへの切り替えでFinding数が大幅に減る

次のアクションとしては、まずECRの拡張スキャンを有効化してFinding数を確認し、ライフサイクルポリシーを設定するところから始めるのがいい。Findingを見て怖くなっても、EPSSで絞ればすぐ対応可能な件数まで落とせるはずだ。

U

Untanbaby

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

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

関連記事