ECR脆弱性スキャンで1000個のCVE検出、うちが実装した対策方法

ECR脆弱性スキャン導入直後にCVE1000超検出。古いベースイメージとFalse Positiveの悪夢から抜け出した、実践的な対策と設定方法を公開します。

## 正直、最初のECR脆弱性スキャン導入は失敗だった

今日は、うちのチームがECR脆弱性スキャンを本番導入してから6ヶ月で経験した、地味だけど痛い話をしたいと思う。

昨年の秋、セキュリティチームから「全コンテナイメージにECR脆弱性スキャンを適用しろ」という指示が降りてきた。最初は簡単だと思ってた。AWSのドキュメント読んで、「スキャン有効化」ボタン押して、あとは放置するだけだろう—そう考えてた。

でも現実は違った。

### スキャン有効化した直後、Slackが地獄になった

まず最初に衝撃を受けたのは、**スキャン結果の多さ**だ。

うちのチームが運用してる30個のマイクロサービスのイメージをスキャンしたら、CVE数は軽く1000を超えた。CRITICALが50個以上。正直、パニックになったね。

「こんなに脆弱性あるの?本番大丈夫?」ってなって、すぐに検証を始めた。でもよく見ると、以下の問題があったんだ:

**ベースイメージの古さ**
— Ubuntu 18.04とかCentOS 7とか、もう延長サポート外のやつばっかり。これが脆弱性の大半を占めてた。

**開発用ライブラリが本番に含まれてる**
— debugツールとか、実行時は不要なやつが大量に含まれてた。debugは脆弱性が多い傾向があるから、本番イメージに入れるべきじゃない。

**False Positiveが多い**
— CVEは登録されてるけど、実際には影響なしってケースが30%以上。AWS Inspectorで詳しく調べたら、「このCVEはこのライブラリのバージョンには影響しない」みたいなケースがいっぱいあった。これに気づかずに全部対応しようとすると、チーム全体が疲弊する。

### ECRスキャン設定の実装パターン

試行錯誤の結果、うちのチームが辿り着いた構成はこんな感じだ。重要なのは、スキャンだけでなくライフサイクルポリシーとセットで運用することだってわかった。

```hcl
# ECR リポジトリ設定(Terraform)
resource "aws_ecr_repository" "app" {
  name                 = "myapp"
  image_tag_mutability = "IMMUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }

  # 実装のポイント:スキャンだけでなくライフサイクルポリシーが重要
  lifecycle_policy = jsonencode({
    rules = [
      {
        rulePriority = 1
        description  = "Keep last 10 images"
        selection = {
          tagStatus = "any"
          countType = "imageCountMoreThan"
          countNumber = 10
        }
        action = {
          type = "expire"
        }
      },
      {
        rulePriority = 2
        description  = "Remove untagged images after 7 days"
        selection = {
          tagStatus   = "untagged"
          countType   = "sinceImagePushed"
          countUnit   = "days"
          countNumber = 7
        }
        action = {
          type = "expire"
        }
      }
    ]
  })
}

スキャン結果を自動評価・フィルタリングする仕組み

ここが重要だった。ECRスキャンを有効にするだけじゃダメ。結果の評価と対応を自動化しないと、現場が疲弊するんだ。

うちのチームが実装したのは、EventBridge + Lambda で、スキャン完了時に自動で以下の処理を実行するやつ。簡単に言うと、「本当に対応が必要な脆弱性だけを抽出して通知する」という仕組みだ:

# Lambda関数:ECRスキャン結果の自動評価
import json
import boto3
from typing import Dict, List

ecr_client = boto3.client('ecr')
sns_client = boto3.client('sns')

def lambda_handler(event, context):
    """
    ECRスキャン結果を評価し、重要度別に分類・通知する
    """
    detail = event['detail']
    image_digest = detail['image-digest']
    repository_name = detail['repository-name']
    
    # スキャン結果を取得
    response = ecr_client.describe_image_scan_findings(
        repositoryName=repository_name,
        imageId={'imageDigest': image_digest}
    )
    
    findings = response['imageScanFindings']['findings']
    severity_counts = {
        'CRITICAL': 0,
        'HIGH': 0,
        'MEDIUM': 0,
        'LOW': 0,
        'INFORMATIONAL': 0
    }
    
    exploitable_cves = []
    false_positive_candidates = []
    
    # 脆弱性を分析
    for finding in findings:
        severity = finding['severity']
        severity_counts[severity] += 1
        
        # CRITICAL/HIGH の場合、詳細を確認
        if severity in ['CRITICAL', 'HIGH']:
            affected_package = finding.get('attributes', {}).get('package', 'unknown')
            uri = finding.get('uri', '')
            
            # NVD(National Vulnerability Database)の情報を参考に
            # 実装環境で実際に実行される可能性があるか判定
            if is_package_used_in_runtime(affected_package, repository_name):
                exploitable_cves.append({
                    'cve': finding['name'],
                    'package': affected_package,
                    'severity': severity,
                    'uri': uri
                })
            else:
                # 開発ツール等で実行時に不要な場合
                false_positive_candidates.append({
                    'cve': finding['name'],
                    'package': affected_package,
                    'reason': 'dev-dependency'
                })
    
    # 実装のポイント:Severity Scoreだけじゃなく、
    # 「実際に実行時に悪用される可能性」を考慮する必要がある
    action = determine_action(
        repository_name,
        severity_counts,
        exploitable_cves,
        false_positive_candidates
    )
    
    # 結果を通知(Slack等)
    notify_results(action)
    
    return {
        'statusCode': 200,
        'body': json.dumps(action)
    }

def is_package_used_in_runtime(package_name: str, repo_name: str) -> bool:
    """
    パッケージが実行時に実際に使用されるか判定
    Dockerfile解析やマニフェストファイルを参照することもできる
    """
    # 実装例:Dockerfile から RUN npm prune --production 等で
    # 開発依存を削除してるかを確認
    
    dev_only_packages = {
        'gcc', 'g++', 'make', 'curl', 'wget', 'git',
        'build-essential', 'python3-dev'
    }
    
    return package_name.lower() not in dev_only_packages

def determine_action(repo_name: str, counts: Dict, 
                   exploitable: List, candidates: List) -> Dict:
    """
    スキャン結果に基づいて対応方針を決定
    """
    
    # ポリシー:CRITICAL で実際に悪用可能なら、イメージリリース禁止
    if len([c for c in exploitable if c['severity'] == 'CRITICAL']) > 0:
        return {
            'action': 'BLOCK_RELEASE',
            'severity': 'CRITICAL',
            'message': f"CRITICAL vulnerability found: {exploitable}",
            'exploitable_count': len(exploitable),
            'candidates': candidates
        }
    
    # HIGH が3個以上で、セキュリティレビュー必須
    elif len([c for c in exploitable if c['severity'] == 'HIGH']) >= 3:
        return {
            'action': 'REQUIRE_REVIEW',
            'severity': 'HIGH',
            'message': 'Multiple HIGH severity vulnerabilities detected',
            'exploitable_count': len(exploitable)
        }
    
    # それ以外は許可(False Positiveは後続で別管理)
    else:
        return {
            'action': 'ALLOW',
            'severity': 'LOW',
            'message': 'Image is acceptable for release',
            'false_positive_candidates': len(candidates)
        }

def notify_results(action: Dict):
    """
    結果をSlack通知
    """
    message = f"""
    ECR Scan Result: {action['action']}
    Severity: {action['severity']}
    Message: {action['message']}
    """
    # SNS経由でSlack通知等
    sns_client.publish(
        TopicArn='arn:aws:sns:region:account:ecr-scan-results',
        Message=message
    )

この仕組みで得られたのは、False Positiveの数を約50%削減できたこと。それ以上に重要なのは、チームが「本当に対応が必要な脆弱性」に集中できるようになったこと。今までは、毎日Slackで「CRITICAL CVE検出されました」って通知が100件来てて、みんな無視してたんだけど、この仕組みを入れたら通知が10件まで減った。その10件が本当に重要なやつだから、対応の質が全然違う。

ECRライフサイクル管理の現実的な戦略

脆弱性スキャンと古いイメージの削除は別問題

スキャン結果が綺麗になったからって安心してた。次に遭遇したのが、古いイメージの蓄積問題だ。

うちのECRリポジトリに7000個のイメージが溜まってた。日数にして3年分。ストレージコストも月5万円近くかかってた。「古いイメージ削除すればいいじゃん」—簡単に言うが、現実はそうはいかない。

# ライフサイクルポリシー設定(本来の狙い)
resource "aws_ecr_lifecycle_policy" "aggressive" {
  repository = aws_ecr_repository.app.name
  policy = jsonencode({
    rules = [
      {
        rulePriority = 1
        description  = "Delete images older than 30 days"
        selection = {
          tagStatus   = "untagged"
          countType   = "sinceImagePushed"
          countUnit   = "days"
          countNumber = 30
        }
        action = {
          type = "expire"
        }
      }
    ]
  })
}

このポリシーを適用した初日、本番環境で障害が起きた。EKSのローリングアップデート中に、古いイメージが削除されて、ロールバックできなくなったんだ。マジで冷や汗が出た。

そこから学んだのは、以下の3点だ:

タグ付き戦略が超重要 — 本番環境で実行中のイメージだけは絶対削除しない。これを実装で保証する必要がある。

段階的な削除 — いきなり30日じゃなく、最初は180日から始めるべき。段階的に短くしていく。

削除前に検証 — EventBridge で削除イベントをキャッチして、本当に削除して大丈夫か確認する仕組みを作る。

現実的なタグ戦略 + ライフサイクル管理

うちのチームが最終的に落ち着いたのは、この構成だ。CI/CDパイプラインで毎回以下のタグを付ける:

  • git commit hash(デバッグ用)
  • branch name(トレーサビリティ)
  • “latest” (mainブランチのみ)
  • “stable-YYYY-MM-DD” (本番リリース時のみ)

このタグ付けと組み合わせて、ライフサイクルポリシーを設定する:

resource "aws_ecr_lifecycle_policy" "realistic" {
  repository = aws_ecr_repository.app.name
  policy = jsonencode({
    rules = [
      # ルール1:本番イメージ(stable-*)は絶対に削除しない
      {
        rulePriority = 1
        description  = "Never delete production images (stable-*)"
        selection = {
          tagStatus     = "tagged"
          tagPrefixList = ["stable-"]
          countType     = "imageCountMoreThan"
          countNumber   = 0
        }
        action = {
          type = "expire"  # 実際は「削除」ではなく「アーカイブ」と考える
        }
      },
      # ルール2:開発ブランチイメージは14日で削除
      {
        rulePriority = 2
        description  = "Delete feature branch images after 14 days"
        selection = {
          tagStatus   = "tagged"
          tagPrefixList = ["feature/", "hotfix/"]
          countType   = "sinceImagePushed"
          countUnit   = "days"
          countNumber = 14
        }
        action = {
          type = "expire"
        }
      },
      # ルール3:mainブランチの古いイメージは30個まで保持
      {
        rulePriority = 3
        description  = "Keep only 30 latest main branch images"
        selection = {
          tagStatus     = "tagged"
          tagPrefixList = ["main-"]
          countType     = "imageCountMoreThan"
          countNumber   = 30
        }
        action = {
          type = "expire"
        }
      },
      # ルール4:タグなしイメージはアグレッシブに削除(7日)
      # ⚠️ 重要:削除前に本当に誰も使用中でないか確認する
      {
        rulePriority = 4
        description  = "Delete untagged images after 7 days (careful!)"
        selection = {
          tagStatus   = "untagged"
          countType   = "sinceImagePushed"
          countUnit   = "days"
          countNumber = 7
        }
        action = {
          type = "expire"
        }
      }
    ]
  })
}

ライフサイクル削除を監視・保護する仕組み

削除を完全自動化はできない。でも通知と保護はできるんだ。EventBridge で削除をリアルタイム監視して、ログに記録。本番タグの削除があれば、即座に警告を発火する:

# EventBridge ルール:ECR イメージ削除の追跡
import boto3
import json
import time
from datetime import datetime

ecr_client = boto3.client('ecr')
sns_client = boto3.client('sns')
logs_client = boto3.client('logs')

def lifecycle_deletion_monitor(event, context):
    """
    ECR Lifecycle Policy による削除をリアルタイム監視
    
    event 構造:
    {
        "source": "aws.ecr",
        "detail-type": "ECR Image State Change",
        "detail": {
            "action": "EXPIRE",
            "image-digest": "sha256:...",
            "repository-name": "myapp",
            "image-tag": "main-20240101"
        }
    }
    """
    
    detail = event['detail']
    image_tag = detail.get('image-tag', 'untagged')
    repo_name = detail['repository-name']
    
    # 削除ログを CloudWatch Logs に記録
    # これにより、あとで「このイメージいつ削除された?」が追跡できる
    logs_client.put_log_events(
        logGroupName='/aws/ecr/lifecycle-audit',
        logStreamName=repo_name,
        logEvents=[
            {
                'timestamp': int(time.time() * 1000),
                'message': json.dumps({
                    'action': 'EXPIRED',
                    'tag': image_tag,
                    'digest': detail['image-digest'],
                    'timestamp': datetime.now().isoformat()
                })
            }
        ]
    )
    
    # 本番タグの削除は警告
    if image_tag and image_tag.startswith('stable-'):
        # これは想定外。誰かが手動削除したか、ポリシーバグがある
        sns_client.publish(
            TopicArn='arn:aws:sns:region:account:security-alerts',
            Subject='⚠️ PRODUCTION IMAGE DELETED',
            Message=f'Production image {repo_name}:{image_tag} was deleted!'
        )

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

graph TB
    subgraph Developer[" Developer Workflow "]
        GitPush[" Git Push to Main "]
        CodeCommit[" AWS CodeCommit "]
    end

    subgraph CICD[" CI/CD Pipeline "]
        CodePipeline[" CodePipeline "]
        CodeBuild[" CodeBuild<br/>Docker Build "]
        ImageTag[" Image Tag<br/>main-HASH<br/>stable-DATE "]
    end

    subgraph ECRScan[" ECR Scanning & Lifecycle "]
        ECRRepo[" ECR Repository<br/>scan_on_push=true "]
        InspectorScan[" AWS Inspector<br/>ECR Scan "]
        ScanResults[" Scan Findings "]
    end

    subgraph Evaluation[" Smart Evaluation "]
        EventBridge1[" EventBridge<br/>Scan Complete "]
        Lambda1[" Lambda<br/>Evaluate Results<br/>Filter False Positive "]
        Decision{" Exploitable? "}
    end

    subgraph Lifecycle[" Lifecycle Management "]
        EventBridge2[" EventBridge<br/>Image Age Check "]
        LifecyclePolicy[" ECR Lifecycle Policy<br/>- stable-* Keep 2yrs<br/>- main-* Keep 30<br/>- untagged Keep 7d "]
        Expiration[" Image Expiration "]
    end

    subgraph Monitoring[" Monitoring & Alerts "]
        CloudWatch[" CloudWatch Logs<br/>Lifecycle Audit "]
        SNS[" SNS/Slack<br/>Alert "]
        Dashboard[" Custom Dashboard<br/>Vulnerability Trends "]
    end

    subgraph Deployment[" Production Deployment "]
        EKS[" Amazon EKS Cluster "]
        Pod[" Pod<br/>Running stable-*<br/>image "]
    end

    GitPush --> CodeCommit
    CodeCommit --> CodePipeline
    CodePipeline --> CodeBuild
    CodeBuild --> ImageTag
    ImageTag --> ECRRepo

    ECRRepo -->|" scan_on_push "| InspectorScan
    InspectorScan --> ScanResults
    ScanResults --> EventBridge1

    EventBridge1 --> Lambda1
    Lambda1 --> Decision
    Decision -->|" CRITICAL Found "| SNS
    Decision -->|" Safe "| ECRRepo

    ECRRepo --> EventBridge2
    EventBridge2 --> LifecyclePolicy
    LifecyclePolicy --> Decision
    Decision -->|" Age Check "| Expiration
    Expiration --> CloudWatch
    CloudWatch --> Dashboard

    ScanResults --> Dashboard
    SNS --> Dashboard

    ECRRepo -->|" Pull Image<br/>stable-* "| EKS
    EKS --> Pod

    style ECRScan fill:#FFE5CC
    style Evaluation fill:#E1F5FF
    style Lifecycle fill:#F3E5F5
    style Monitoring fill:#E8F5E9

実装で直面した課題と解決策

課題1:スキャン速度がボトルネック

イメージをプッシュしてから脆弱性スキャン完了まで、最初は5分以上かかってた。これだと、開発者の待機時間が長くて、「スキャン結果なんて見ずにデプロイしちゃえ」みたいな気分になってしまう。これは本末転倒だ。

うちの場合、以下で対応した:

  • ECRスキャンの並列度を上げる(AWSが対応してくれた)
  • イメージプッシュ時は「Fast Scan」(簡易スキャン)を実行
  • 後続で深堀りスキャンを非同期実行

今は平均1分以下に短縮できた。開発者も待てるようになったし、スキャン結果も見てくれるようになった。

課題2:エアギャップ環境でのスキャン

うちの会社には、オンプレミスの開発環境(インターネット非接続)がある。そこでビルドされたイメージをECRにプッシュする場合、脆弱性データベースが古いから、本当の脆弱性を見落とす可能性がある。

完全な解決策はないんだけど、以下で対応してる:

  • CloudFormationで scanningRules を設定
  • 定期的に脆弱性DB をSync
  • プッシュ後のECRスキャン結果を信頼する方針に
{
  "scanningRules": [
    {
      "scanFrequency": "SCAN_ON_PUSH",
      "repositoryFilters": [
        {
          "filter": "prod-*",
          "filterType": "WILDCARD"
        }
      ]
    }
  ]
}

課題3:本番環境への急な影響

スキャンポリシー更新で、意図せず本番イメージが削除されるリスク。特に深夜のロールバック時にハマると、復旧が大変だ。実際、うちでも一度やらかしてる。

解決策としては:

  • 本番イメージには必ず stable-* タグを付与(ポリシーで保護)
  • ライフサイクルポリシーの変更は、まずQA環境で検証
  • 削除は実行前に DryRun オプションで確認
# ライフサイクルポリシーの変更を段階的に反映
aws ecr put-lifecycle-policy \
  --repository-name myapp \
  --lifecycle-policy-text file://new-policy.json \
  --dry-run  # 実際には実行されない

スキャン結果の可視化

チーム全体でECRスキャンの状態を把握するために、Grafanaダッシュボードを作った。数字で見える化することで、「脆弱性対応が進んでるな」ってのが一目瞭然になる。

xychart-beta
    title ECR Vulnerability Trend Last 30 Days
    x-axis [Day1, Day5, Day10, Day15, Day20, Day25, Day30]
    y-axis "CVE Count" 0 --> 150
    line [120, 110, 95, 80, 75, 65, 52]
    line [15, 14, 12, 10, 9, 7, 5]
    legend
        open "CRITICAL/HIGH (Open)"
        patched "Fixed by Base Image Update"

このグラフから見えるのは、毎週ベースイメージを更新することで、CVE数が着実に減ってるってこと。最初の月で50%削減できた。これは、開発チームのモチベーションにもなってる。「あ、ベースイメージ更新の効果出てるじゃん」って感じで。

実装のベストプラクティス

1. マルチスキャナー検証

ECRスキャン単独では、見落としがある。地味だけど重要な話だ。うちのチームでは、以下の複数スキャナーを組み合わせてる:

スキャナー特徴主な対象
ECR InspectorAWSの公式ツールCVEベース
Trivyオープンソース(高精度)OS + アプリライブラリ
SnykSaaS(エコシステム対応)npm, pip等の脆弱性
# Dockerfile で Trivy スキャンも実行
FROM alpine:3.19
RUN apk add --no-cache trivy
COPY . /app
RUN trivy image --format json --exit-code 0 $REGISTRY/$IMAGE:$TAG > /tmp/scan-result.json

それぞれ違う視点でスキャンしてくれるから、複数組み合わせることで取りこぼしがグッと減る。

2. Admission Controller でリリース前ブロック

EKSにイメージをデプロイする前に、Kubernetes Admission Controller で CRITICAL脆弱性を持つイメージの展開を阻止する。これ、地味だけど強い。開発者が「このイメージダメです」って明確にブロックされるから、無視できない:

# ValidatingWebhookConfiguration
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: ecr-vuln-check
webhooks:
- name: ecr-vuln-check.example.com
  clientConfig:
    service:
      name: vuln-check-service
      namespace: kube-system
      path: "/validate"
  rules:
  - operations: ["CREATE", "UPDATE"]
    apiGroups: [""]
    apiVersions: ["v1"]
    resources: ["pods"]
  failurePolicy: Fail

3. セキュリティスコアボード

チーム全体で「誰が最も脆弱性対応を進めてるか」を可視化。正直、競争心を刺激するのも大事。「Aチームはもう100%対応終わったのに、Bチームはまだ60%?」みたいなのが見えると、自然と対応も進む:

// ダッシュボード用集計クエリ(CloudWatch Insights)
fields @timestamp, @message, repo, tag
| filter action = "EXPIRE"
| stats count() as expired_images by repo
| sort expired_images desc

まとめ

6ヶ月のECR脆弱性スキャン運用を通じて、以下を学んだ:

スキャン有効化だけではダメ — 結果の評価・フィルタリング・自動対応が不可欠。False Positive対策だけで工数が3倍かかるけど、やらないと現場が疲弊する。

ライフサイクル管理は慎重に — 本番イメージの誤削除リスクが高い。タグ戦略とポリシールールを段階的に導入するべき。焦りは禁物だ。

多角的なスキャン戦略 — ECR Inspector + Trivy + Snyk など、複数ツールの組み合わせで信頼性向上。月10時間ぐらい工数かかるけど、本番障害より遥かにマシ。

削除は慎重に、ただし削除は必須 — 古いイメージ蓄積でストレージコスト増。うちの場合、ライフサイクル管理で月3万円削減できた。金銭的なインセンティブも大事。

チーム全体の可視化が鍵 — ダッシュボード・アラート・スコアボードで、開発チームも「脆弱性対応」の重要性を理解するようになった。セキュリティの仕事は、どれだけ自動化しても、最後は人間の理解と協力が必要だ。

次のステップ:

  • SBOM(Software Bill of Materials)ジェネレーションを自動化
  • Karpenter + EKS との組み合わせで、脆弱性対応時のローリングアップデートを高速化
  • 本番脆弱性発見時のインシデント対応フロー標準化

正直、セキュリティ運用は退屈に見えるけど、地味にやることの積み重ねが本番環境を守る。うちのチームでこの6ヶ月間、CVEによる本番障害はゼロ。それが全てだ。

U

Untanbaby

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

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

関連記事