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 Inspector | AWSの公式ツール | CVEベース |
| Trivy | オープンソース(高精度) | OS + アプリライブラリ |
| Snyk | SaaS(エコシステム対応) | 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による本番障害はゼロ。それが全てだ。