ECRで1000件のCVEが検出されてチームがフリーズした話と、1年かけて整えた設計

初日にCritical 47件・High 312件を叩き出されて途方に暮れた経験、ありませんか?ECR拡張スキャンとInspector v2を1年本番運用して気づいた落とし穴と現実的な対処法をまとめました。

1000個のCVEを前にして途方に暮れた話

去年の夏、CI/CDパイプラインにECRの拡張スキャンを組み込んだら、初日に1000件超のCVEが検出されてチームがフリーズした。あれは正直しんどかった。Critical: 47件、High: 312件、Medium: 689件という数字を見て「これ全部潰すのか…」という絶望感。

ただ、あの経験があってこそ今の設計がある。1年かけてECRの脆弱性スキャンとライフサイクル管理を整備してきた中で、「こう組めばよかった」という知見が溜まってきた。同じ轍を踏まないように実務ベースで書いていく。

なお、コンテナセキュリティ全般についてはコンテナセキュリティ完全ガイド2026|eBPF・SBOM・サプライチェーン対策でも扱っているので、スキャン以外の観点も合わせて読んでほしい。

ECR拡張スキャンとInspector v2の関係を整理する

まず混乱しやすいポイントから。ECRのスキャンには現時点で2種類あって、選択肢があるように見えるけど実質的には択一だと思っている。

機能ベーシックスキャン拡張スキャン(Inspector v2)
エンジンClair(OSS)Amazon Inspector v2
スキャンタイミングプッシュ時のみプッシュ時 + 継続的
対応OS/言語OS依存ライブラリのみOS + プログラミング言語パッケージ
EPSS対応なしあり(2025年以降)
コスト無料$0.11/イメージダイジェスト/月
Lambda/ECS統合なしInspector v2経由であり

2026年時点では、本番で扱うイメージには拡張スキャン一択だと思っている。理由はEPSS(Exploit Prediction Scoring System)スコアが使えること。CVSSスコアだけだとHighが300件あっても優先順位がつけにくいが、EPSSがあると「実際に悪用される確率が高いCVE」を絞り込める。これが最初の1000件問題を解決する鍵だった。

実際に動かしてみた設定がこれ。CDK(TypeScript)で書いている。

import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as cdk from 'aws-cdk-lib';

const repo = new ecr.Repository(this, 'AppRepository', {
  repositoryName: 'myapp/backend',
  imageScanOnPush: true,
  // 拡張スキャンはアカウントレベルの設定がInspector v2側で必要
  // CDKではrepository単体では切り替えられない点に注意
  lifecycleRules: [
    {
      rulePriority: 1,
      description: 'Keep last 5 production images',
      tagStatus: ecr.TagStatus.TAGGED,
      tagPrefixList: ['prod-'],
      maxImageCount: 5,
    },
    {
      rulePriority: 2,
      description: 'Keep last 10 staging images',
      tagStatus: ecr.TagStatus.TAGGED,
      tagPrefixList: ['stg-'],
      maxImageCount: 10,
    },
    {
      rulePriority: 3,
      description: 'Remove untagged images after 7 days',
      tagStatus: ecr.TagStatus.UNTAGGED,
      maxImageAge: cdk.Duration.days(7),
    },
    {
      rulePriority: 4,
      description: 'Keep last 30 any tagged images',
      tagStatus: ecr.TagStatus.ANY,
      maxImageCount: 30,
    },
  ],
  removalPolicy: cdk.RemovalPolicy.RETAIN,
});

ここで地味に落とし穴があった。ライフサイクルルールの優先度は数値が小さいほど先に評価されるので、prod-タグを保護したいなら必ず優先度1か2に置かないといけない。最初これを逆に理解していて、本番イメージが消えそうになってヒヤッとした。

Inspector v2の有効化はConsoleかCLIで行う。

# Inspector v2をECRスキャン込みで有効化
aws inspector2 enable \
  --resource-types ECR \
  --region ap-northeast-1

# 有効化状態の確認
aws inspector2 get-configuration \
  --region ap-northeast-1 | jq '.ecrConfiguration'

実行結果として返ってくるのはこんな感じ。

{
  "rescanDuration": "LIFETIME",
  "pullDateRescanDuration": "DAYS_180"
}

pullDateRescanDurationがポイントで、最後にpullされた日から180日以内のイメージが継続スキャンの対象になる。これを知らないと「なんでこのイメージが継続スキャンから外れてるの?」となる。うちのチームは最初ここで1週間悩んだ。ドキュメントのどこに書いてあるんだという話だけど、まあ仕方ない。

CI/CDパイプラインへの組み込みと実際の閾値設計

スキャン結果をCIで活用するためのパターンを整理した。うちのチームはGitHub ActionsとCodeBuildを併用しているが、ECR API的にはどちらでも同じ。

#!/usr/bin/env python3
# scan_gate.py - ECRスキャン結果を評価してCI/CDを制御するスクリプト

import boto3
import json
import sys
from datetime import datetime, timezone

def get_scan_findings(repository_name: str, image_digest: str, region: str = 'ap-northeast-1'):
    ecr = boto3.client('ecr', region_name=region)
    inspector2 = boto3.client('inspector2', region_name=region)
    
    # Inspector v2経由でfindingsを取得
    paginator = inspector2.get_paginator('list_findings')
    findings = []
    
    filter_criteria = {
        'ecrImageRepositoryName': [{
            'comparison': 'EQUALS',
            'value': repository_name
        }],
        'ecrImageHash': [{
            'comparison': 'EQUALS',
            'value': image_digest
        }],
        'findingStatus': [{
            'comparison': 'EQUALS',
            'value': 'ACTIVE'
        }]
    }
    
    for page in paginator.paginate(filterCriteria=filter_criteria):
        findings.extend(page['findings'])
    
    return findings

def evaluate_findings(findings: list, config: dict) -> tuple[bool, dict]:
    """
    EPSS + CVSSを組み合わせた評価ロジック
    単純なCVSSだけだと誤検知が多すぎる経験から設計
    """
    summary = {'CRITICAL': 0, 'HIGH': 0, 'MEDIUM': 0, 'LOW': 0}
    blocking_findings = []
    
    for finding in findings:
        severity = finding.get('severity', 'UNKNOWN')
        summary[severity] = summary.get(severity, 0) + 1
        
        # EPSSスコアが一定以上かつCritical/Highの場合にブロック
        epss_score = 0.0
        if 'epss' in finding:
            epss_score = finding['epss'].get('score', 0.0)
        
        cvss_score = 0.0
        for score in finding.get('inspectorScore', {}):
            if score == 'score':
                cvss_score = finding['inspectorScore']['score']
        
        # ブロック条件: CriticalかつEPSS > 0.1、またはHigh + EPSS > 0.3
        is_critical_with_epss = (severity == 'CRITICAL' and epss_score > config.get('epss_critical_threshold', 0.1))
        is_high_with_epss = (severity == 'HIGH' and epss_score > config.get('epss_high_threshold', 0.3))
        
        if is_critical_with_epss or is_high_with_epss:
            blocking_findings.append({
                'title': finding.get('title', 'Unknown'),
                'severity': severity,
                'epss_score': epss_score,
                'cvss_score': cvss_score,
                'cve_id': finding.get('packageVulnerabilityDetails', {}).get('vulnerabilityId', 'N/A')
            })
    
    should_block = len(blocking_findings) > 0
    return should_block, {'summary': summary, 'blocking': blocking_findings}

if __name__ == '__main__':
    repo_name = sys.argv[1]
    image_digest = sys.argv[2]
    
    config = {
        'epss_critical_threshold': 0.1,  # Critical + EPSS 10%以上でブロック
        'epss_high_threshold': 0.3,       # High + EPSS 30%以上でブロック
    }
    
    findings = get_scan_findings(repo_name, image_digest)
    should_block, result = evaluate_findings(findings, config)
    
    print(json.dumps(result, indent=2, ensure_ascii=False))
    
    if should_block:
        print(f"\n{len(result['blocking'])} 件のブロッキング脆弱性が検出されました")
        sys.exit(1)
    else:
        print(f"\n✅ ブロッキング脆弱性なし(Total findings: {sum(result['summary'].values())})")
        sys.exit(0)

CVSSだけで判断するとFalse Positiveが多すぎてCIが毎回コケる地獄になる。正直、EPSS閾値の設定はまだ検証中で、チームによって許容リスクレベルが違うので絶対値を押し付けるのは難しい。うちは3ヶ月かけて現在の値に落ち着いた感じだ。「これが正解」という数字は存在しないと思っていた方がいい。

GitHub Actionsでの使い方はこう。

# .github/workflows/ecr-scan.yml
name: ECR Scan Gate

on:
  push:
    branches: [main, develop]

jobs:
  build-and-scan:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ap-northeast-1
      
      - name: Build and push to ECR
        id: build
        run: |
          REGISTRY=${{ secrets.ECR_REGISTRY }}
          REPO=myapp/backend
          TAG=dev-${GITHUB_SHA::8}
          
          docker build -t $REGISTRY/$REPO:$TAG .
          docker push $REGISTRY/$REPO:$TAG
          
          # digestを取得
          DIGEST=$(aws ecr describe-images \
            --repository-name $REPO \
            --image-ids imageTag=$TAG \
            --query 'imageDetails[0].imageDigest' \
            --output text)
          echo "digest=$DIGEST" >> $GITHUB_OUTPUT
          echo "tag=$TAG" >> $GITHUB_OUTPUT
      
      - name: Wait for Inspector v2 scan
        run: |
          # スキャン完了を待機(最大5分)
          for i in $(seq 1 30); do
            STATUS=$(aws inspector2 list-findings \
              --filter-criteria '{"ecrImageHash":[{"comparison":"EQUALS","value":"${{ steps.build.outputs.digest }}"}]}' \
              --query 'length(findings)' \
              --output text 2>/dev/null || echo "0")
            if [ "$STATUS" != "0" ] || [ $i -eq 30 ]; then
              break
            fi
            echo "Waiting for scan... ($i/30)"
            sleep 10
          done
      
      - name: Evaluate scan results
        run: |
          pip install boto3
          python3 scripts/scan_gate.py \
            myapp/backend \
            ${{ steps.build.outputs.digest }}

アーキテクチャ全体像

うちのチームが最終的に落ち着いた構成がこれ。最初は「もっとシンプルでいいのでは」と思っていたが、運用を重ねるうちに各コンポーネントの必要性が身に染みてわかってきた。

graph TB
    subgraph "Developer Workflow"
        DEV[Developer]
        GH[GitHub Actions]
        DEV -->|git push| GH
    end

    subgraph "AWS Account: Shared Services"
        subgraph "CI/CD Pipeline"
            CB[CodeBuild]
            CP[CodePipeline]
            GH -->|trigger| CP
            CP --> CB
        end

        subgraph "ECR"
            ECR_PROD[ECR: prod repos]
            ECR_DEV[ECR: dev/stg repos]
            CB -->|docker push| ECR_DEV
        end

        subgraph "Security"
            INS[Inspector v2]
            SEC_HUB[Security Hub]
            CHAT[Amazon SNS]
            ECR_DEV -->|スキャントリガー| INS
            ECR_PROD -->|継続スキャン| INS
            INS -->|Findings| SEC_HUB
            SEC_HUB -->|Critical/High Alert| CHAT
            CHAT -->|Slack通知| SLACK[Slack]
        end

        subgraph "Promotion Gate"
            LAMBDA[Lambda: scan-gate]
            CB -->|scan評価依頼| LAMBDA
            LAMBDA -->|PASS/FAIL| CP
            LAMBDA -.->|API call| INS
        end
    end

    subgraph "AWS Account: Production"
        subgraph "ap-northeast-1"
            subgraph "VPC"
                subgraph "Private Subnet"
                    EKS[EKS Cluster]
                    NODES[Worker Nodes]
                    EKS --> NODES
                end
            end
            ECR_PROD_R[ECR: prod \n replicated]
            NODES -->|pull| ECR_PROD_R
        end
        ECR_PROD -->|クロスアカウントレプリケーション| ECR_PROD_R
    end

    subgraph "Lifecycle Management"
        EB[EventBridge Scheduler]
        LM_CLEAN[Lambda: lifecycle-cleaner]
        EB -->|毎日02:00| LM_CLEAN
        LM_CLEAN -->|古いイメージ削除| ECR_DEV
        LM_CLEAN -->|コスト最適化| ECR_PROD
    end

特に重要なのはPromotion Gateの部分だ。スキャン結果がPassした場合だけprodリポジトリにイメージを昇格させる仕組みがここの核心で、最初はGitHub Actionsのstepで全部やっていたが、CodePipelineに移したことでApproval stepも組み込めるようになった。Human-in-the-loopを維持しながら自動化できる、という意味ではこの構成に変えたのは正解だったと思っている。

インシデント対応との連携についてはインシデント対応の最新ベストプラクティス2026|DevOps・SRE必読が参考になる。Security HubからのアラートをSlackに流す実装はそちらの設計と合わせて組んでいる。

ライフサイクル管理で月のストレージコストが70%削減された話

ライフサイクルポリシーを入れる前は、ECRのストレージコストが月3.2万円まで膨らんでいた。毎CI/CDでビルドしているので、untaggedなイメージが積み上がる一方だったのが原因だ。地味に痛い出費が続いていたが、4月にライフサイクルポリシーを整備して、5月から効果が出始めた。70%削減というのは実際の数字。

xychart-beta
    title "ECRストレージコスト推移(月次)"
    x-axis ["1月", "2月", "3月", "4月", "5月", "6月", "7月"]
    y-axis "コスト(千円)" 0 --> 40
    bar [32, 34, 33, 35, 11, 10, 9]
    line [32, 34, 33, 35, 11, 10, 9]

ECR標準のライフサイクルポリシーだと細かい条件を組めないので、Lambdaで補完している。実際に組んだカスタムライフサイクルクリーナーのコアロジックがこれだ。

import boto3
import logging
from datetime import datetime, timezone, timedelta
from typing import Optional

logger = logging.getLogger()
logger.setLevel(logging.INFO)

ecr = boto3.client('ecr', region_name='ap-northeast-1')

def get_all_repositories() -> list[dict]:
    repos = []
    paginator = ecr.get_paginator('describe_repositories')
    for page in paginator.paginate():
        repos.extend(page['repositories'])
    return repos

def get_images_with_scan_status(repo_name: str) -> list[dict]:
    images = []
    paginator = ecr.get_paginator('describe_images')
    for page in paginator.paginate(
        repositoryName=repo_name,
        filter={'tagStatus': 'ANY'}
    ):
        images.extend(page['imageDetails'])
    return images

def should_protect_image(image: dict, protected_tags: list[str]) -> bool:
    """本番タグが付いたイメージは絶対に消さない"""
    image_tags = image.get('imageTags', [])
    for tag in image_tags:
        for protected in protected_tags:
            if tag.startswith(protected):
                return True
    return False

def clean_old_dev_images(
    repo_name: str,
    keep_days: int = 7,
    dry_run: bool = False
) -> dict:
    """
    開発環境向けイメージのクリーンアップ
    critical/high脆弱性があるイメージを優先的に削除対象に含める
    """
    images = get_images_with_scan_status(repo_name)
    protected_tags = ['prod-', 'release-', 'v']
    cutoff = datetime.now(timezone.utc) - timedelta(days=keep_days)
    
    to_delete = []
    protected_count = 0
    
    for image in images:
        if should_protect_image(image, protected_tags):
            protected_count += 1
            continue
        
        pushed_at = image.get('imagePushedAt')
        if pushed_at and pushed_at < cutoff:
            # 脆弱性スコアが高いものはさらに古くなる前に削除
            scan_status = image.get('imageScanStatus', {}).get('status', 'UNSUPPORTED')
            to_delete.append({
                'imageDigest': image['imageDigest'],
                'imageTags': image.get('imageTags', []),
                'pushedAt': pushed_at.isoformat(),
                'scanStatus': scan_status
            })
    
    deleted_count = 0
    if not dry_run and to_delete:
        # バッチ削除(100件上限に注意)
        for i in range(0, len(to_delete), 100):
            batch = to_delete[i:i+100]
            ids = [{'imageDigest': img['imageDigest']} for img in batch]
            response = ecr.batch_delete_image(
                repositoryName=repo_name,
                imageIds=ids
            )
            deleted_count += len(response.get('imageIds', []))
            
            if response.get('failures'):
                logger.warning(f"Failed to delete some images: {response['failures']}")
    
    result = {
        'repository': repo_name,
        'total_images': len(images),
        'protected': protected_count,
        'candidates': len(to_delete),
        'deleted': deleted_count if not dry_run else 0,
        'dry_run': dry_run
    }
    
    logger.info(f"Cleanup result: {result}")
    return result

def lambda_handler(event, context):
    dry_run = event.get('dry_run', False)
    repos = get_all_repositories()
    
    results = []
    for repo in repos:
        repo_name = repo['repositoryName']
        # dev/stg向けリポジトリのみ積極的にクリーンアップ
        if any(env in repo_name for env in ['/dev', '/stg', '/ci']):
            result = clean_old_dev_images(repo_name, keep_days=7, dry_run=dry_run)
        else:
            result = clean_old_dev_images(repo_name, keep_days=30, dry_run=dry_run)
        results.append(result)
    
    total_deleted = sum(r['deleted'] for r in results)
    logger.info(f"Total images deleted: {total_deleted}")
    
    return {'results': results, 'total_deleted': total_deleted}

このスクリプトで特に重要なのがshould_protect_imageの部分だ。prodタグが付いたイメージは絶対に消さないガードを入れている。最初これが甘くて、本番デプロイのロールバック用イメージが消えてインシデントになりかけた経験がある。ここだけは防衛的に書くに越したことはない。

セキュリティ設計全体についてはSOC2対応2026年版|AI検証とZTA実装で完全コンプライアンスとの組み合わせが効いている。ECRスキャン結果をSecurity Hubに集約して、SOC2の証跡として残す設計を同時に組んだ。

Security HubとEventBridgeを使ったアラート設計

スキャン結果をSlackに流す設計は多くのチームがやっていると思うが、ここでの失点がひとつある。全件流すとノイズが多すぎてアラート疲れが起きる。週500件を超えたあたりから誰もSlackの通知を見なくなってきたのが正直なところで、それを受けてEventBridgeのルールを絞り込んだ。

// EventBridgeルール: Critical + 本番系リポジトリのみ通知
{
  "source": ["aws.inspector2"],
  "detail-type": ["Inspector2 Finding"],
  "detail": {
    "severity": ["CRITICAL"],
    "status": ["ACTIVE"],
    "resources": {
      "details": {
        "awsEcrContainerImage": {
          "repositoryName": [{"prefix": "myapp/"}]
        }
      }
    }
  }
}

Criticalかつ本番系リポジトリのみに絞ったEventBridgeルールを作った。High以下はSecurity Hubのダッシュボードで週次確認する運用にしている。これで通知量が週500件→15件まで落ちた。個人的にはこの2段構えが一番現実的な落とし所だと思っている。

アラートの抑制にはInspector v2のSuppression Ruleも使える。

# 特定のCVEをサプレス(FalsePositiveや対応済みを明示)
aws inspector2 create-filter \
  --name "suppress-accepted-risk" \
  --action SUPPRESS \
  --filter-criteria '{
    "findingStatus": [{"comparison": "EQUALS", "value": "ACTIVE"}],
    "vulnerabilityId": [{"comparison": "EQUALS", "value": "CVE-2023-XXXXX"}]
  }' \
  --reason "Accepted risk: not exploitable in our deployment context. Reviewed 2026-06-15"

このサプレスルールの運用がかなり重要で、Reasonを入れておかないとSOC2監査で「なんでこれ無視してるの?」と突っ込まれる。AWS Inspector v2、本番導入6ヶ月で見えてきた運用の現実にも書いたが、サプレスの根拠管理は最初から仕組み化しておくのが本当に大事だ。後から整理しようとすると絶対に後回しになる。

まとめ

1年やって分かったことを一言にまとめると「スキャンを入れるだけでは意味がない、ゲートの設計が9割」という感じだ。具体的な要点は5つ。

  1. 拡張スキャン(Inspector v2)にEPSSを組み合わせる:CVSSだけだと誤検知が多く運用不能になる。EPSS閾値はチームのリスク許容度に合わせて3ヶ月かけて調整する覚悟が必要
  2. ライフサイクルポリシーはprodタグ保護を最優先に:ルール優先度の番号が小さいほど先評価という罠は必ずハマる。最初にdry-runでシミュレーションしてから本番適用を
  3. Promotion Gateでdev→prodの昇格を制御:スキャンPassしたイメージだけprodリポジトリに昇格させる仕組みが核心。CodePipelineのApproval stepとの組み合わせが効果的
  4. アラートはEventBridgeでCriticalのみに絞る:全件Slackに流すとアラート疲れで誰も見なくなる。Security HubダッシュボードをHighの確認窓口にする2段構えが現実的
  5. サプレスルールには必ずReasonと日付を記録:SOC2監査でサプレスルールの根拠説明は必須。最初から運用手順に組み込むこと

次のアクション:まずは拡張スキャンの有効化から始めて、検出件数を見てから閾値設計に入るのが現実的な進め方だ。いきなりCIゲートを厳しくすると最初のpushで全員ブロックされてチームの反発が起きるので、最初の1ヶ月はobserve-onlyで動かすことを強くお勧めする。

皆さんのチームはECRスキャンをどう運用してますか?特にEPSSの閾値設計は答えが出ていないので、知見があれば聞いてみたいところ。

U

Untanbaby

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

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

関連記事