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で、本番環境へのデプロイ時チェック追加
セキュリティ対応って「完璧を目指す」より「継続できる仕組み」が大事だと痛感しました。完全なセキュリティを求めるあまり、開発が止まっちゃったら本末転倒ですしね。