はじめに
こんにちは。CCI のtadaです
AWS S3のファイルにマルウェアスキャンをかけられるGuardDuty Malware Protection for S3を試してみました
混同しそうな機能として GuardDuty S3 Protection がありますが、あちらはCloudTrailのログからアクティビティの脅威を検出する機能であるのに対しGuardDuty Malware Protection for S3 はS3に置かれたファイル自体をスキャンする機能です
Terraformで設定してみたのですが運用上必要になりそうな「スキャン結果のロギング」「脅威検出時のSNS通知」もセットで組んでいます
Terraformのコードサンプルも置いておくのでどなたかの参考になれば幸いです
背景
ファイルアップロード機能を実装する際、ユーザーがアップロードしたファイルに対してマルウェアスキャンをかけたいというシーンがあると思います
私の携わるプロダクトでもエージェント形式のマルウェアスキャンソフトをEC2サーバにインストールして運用していましたが課題もありました
- 従量課金ではなく年間契約の固定料金
- スキャン量は少ないのに高い・・・
- 契約期間の途中で不要になっても支払いが続く
- サーバ台数でのライセンス
- スケールアウトができない
- 障害でサーバを入れ替えるとライセンスの付け替えが必要になり自動化が難しい
- スキャン時にCPU負荷が高まるためスキャンのためだけにインスタンスタイプを上げていた
- コンテナに対応していない
もっと良いやり方があるんじゃないかと思っていたところGuardDuty Malware Protection for S3 が昨年リリースされました
このサービスを使うとアプリケーションはS3にファイルを置くだけでよくなり、前述した課題がすべて解決されるため実際に使ってみました
GuardDuty Malware Protection for S3 の概要
引用元: docs.aws.amazon.com
Malware Protection for S3 は、選択した Amazon Simple Storage Service (Amazon S3) バケットに新しくアップロードされたオブジェクトをスキャンすることで、マルウェアが存在する可能性を検出できます。 選択したバケットに S3 オブジェクトまたは既存の S3 オブジェクトの新しいバージョンがアップロードされると、GuardDuty は自動的にマルウェアスキャンを開始します。
仕組みを箇条書きにすると下記の流れになります
- GuardDuty Malware Protection for S3 の設定でS3バケットに対してスキャンを有効化しておく
- S3にputObjectされたタイミングでスキャン開始
- スキャン結果はEventBridgeのイベントや S3オブジェクトへのタグ付けとして確認できる
なおスキャンエンジンは 「AWS内部で構築および管理されるスキャンエンジン」と「Bitdefender」が組み合わせて使用されるようです
GuardDuty マルウェア検出のスキャンエンジン - Amazon GuardDuty
スキャン結果のタグ
スキャン結果を S3 オブジェクトへタグ付けする機能がオプションで提供されています
タグ付けオプションを有効化すると、バケットポリシーで「脅威検出なし(NO_THREATS_FOUND
)」のタグがついた場合だけGetObject可能、といった制限(TBAC=タグベース アクセスコントロール)ができるようになるため、有効化することをお勧めします
下記5種類のいずれかがタグ付けされNO_THREATS_FOUND
なら安全です
S3オブジェクトタグ |
説明 |
---|---|
NO_THREATS_FOUND | GuardDuty は、スキャンされたオブジェクトに関連付けられた潜在する脅威を検出しませんでした。 |
THREATS_FOUND | GuardDuty は、スキャンされたオブジェクトに関連付けられた潜在する脅威を検出しました。 |
UNSUPPORTED | Malware Protection for S3 がスキャンをスキップするにはいくつかの理由があります。考えられる理由には、ファイルがパスワードで保護されている、Malware Protection for S3 にクォータが設定されている、Amazon S3 機能の中にサポートを利用できないものがあるといったことがあります。詳細については、「Malware Protection for S3 の機能」を参照してください。 |
ACCESS_DENIED | GuardDuty はこのオブジェクトにアクセスできずスキャンできません。このバケットに関連付けられている IAM ロールアクセス許可を確認してください。詳細については、「IAM ロールポリシーの作成または更新」を参照してください。スキャン後の S3 オブジェクトのタグ付けを有効にしている場合は、「S3 オブジェクトスキャン後のタグ失敗のトラブルシューティング」を参照してください。 |
FAILED | GuardDuty は、内部エラーのためにこのオブジェクトに対するマルウェアスキャンを実行できません。 |
上記表の説明はこちらから引用
引用元: Malware Protection for S3 での S3 オブジェクトスキャンのモニタリング - Amazon GuardDuty
主なクォータ
スキャンできるオブジェクトの最大サイズは 5GB となっています
またアーカイブファイルは展開後のファイル数が1000 、アーカイブのネストが5階層まで と別途考慮事項があります
「リージョンあたりでスキャンを有効化できるバケットが 25個 まで」という制限もあり、
アカウントあたりのバケット数がデフォルトで 10,000 個 作れるのに対し、少ない制限値になっています
そのため スキャン用バケット という形で1つを用意しprefixで用途を分けるような形が良いのかなと感じました
参考:Malware Protection for S3 のクォータ - Amazon GuardDuty
ロギング・通知について
スキャン結果は EventBridge の GuardDuty Malware Protection Object Scan Result
というイベントで得られます
- EventBridge -> CloudWatch Logging の形式にすることでスキャン結果をログに残せます
- EventBridge -> SNS の形式にすることでスキャン結果を通知できます
"detail"."scanResultDetails"."scanResultStatus"
でフィルタすることで、脅威検出時のみ通知といった制御が可能です
Amazon EventBridge による S3 オブジェクトスキャンのモニタリング - Amazon GuardDuty
Terraformコードサンプル
今回利用したコードです。policyやevent_patternはTerraformを使っていない方でも参考にしていただけると思います
長いので折りたたみにしています
AWSアカウント情報を取得
data "aws_caller_identity" "current" {} data "aws_region" "current" {}
IAMロールを用意
resource "aws_iam_role" "guardduty_malware_protection" { name = "guardduty_malware_protection" assume_role_policy = jsonencode( { "Version" : "2012-10-17", "Statement" : [ { "Effect" : "Allow", "Principal" : { "Service" : "malware-protection-plan.guardduty.amazonaws.com" }, "Action" : "sts:AssumeRole" } ] } ) } resource "aws_iam_role_policy_attachments_exclusive" "guardduty_malware_protection" { role_name = aws_iam_role.guardduty_malware_protection.name policy_arns = [aws_iam_policy.guardduty_malware_protection.arn] } # ドキュメント例 # https://docs.aws.amazon.com/ja_jp/guardduty/latest/ug/malware-protection-s3-iam-policy-prerequisite.html#attach-iam-policy-s3-malware-protection resource "aws_iam_policy" "guardduty_malware_protection" { name = "guardduty_malware_protection" policy = jsonencode( { "Version" : "2012-10-17", "Statement" : [ { "Sid" : "AllowManagedRuleToSendS3EventsToGuardDuty", "Effect" : "Allow", "Action" : [ "events:PutRule", "events:DeleteRule", "events:PutTargets", "events:RemoveTargets" ], "Resource" : [ "arn:aws:events:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:rule/DO-NOT-DELETE-AmazonGuardDutyMalwareProtectionS3*" ], "Condition" : { "StringLike" : { "events:ManagedBy" : "malware-protection-plan.guardduty.amazonaws.com" } } }, { "Sid" : "AllowGuardDutyToMonitorEventBridgeManagedRule", "Effect" : "Allow", "Action" : [ "events:DescribeRule", "events:ListTargetsByRule" ], "Resource" : [ "arn:aws:events:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:rule/DO-NOT-DELETE-AmazonGuardDutyMalwareProtectionS3*" ] }, { "Sid" : "AllowPostScanTag", "Effect" : "Allow", "Action" : [ "s3:PutObjectTagging", "s3:GetObjectTagging", "s3:PutObjectVersionTagging", "s3:GetObjectVersionTagging" ], "Resource" : [ "${aws_s3_bucket.sample.arn}/*" ] }, { "Sid" : "AllowEnableS3EventBridgeEvents", "Effect" : "Allow", "Action" : [ "s3:PutBucketNotification", "s3:GetBucketNotification" ], "Resource" : [ "${aws_s3_bucket.sample.arn}" ] }, { "Sid" : "AllowPutValidationObject", "Effect" : "Allow", "Action" : [ "s3:PutObject" ], "Resource" : [ "${aws_s3_bucket.sample.arn}/malware-protection-resource-validation-object" ] }, { "Sid" : "AllowCheckBucketOwnership", "Effect" : "Allow", "Action" : [ "s3:ListBucket" ], "Resource" : [ "${aws_s3_bucket.sample.arn}" ] }, { "Sid" : "AllowMalwareScan", "Effect" : "Allow", "Action" : [ "s3:GetObject", "s3:GetObjectVersion" ], "Resource" : [ "${aws_s3_bucket.sample.arn}/*" ] } ] } ) }
s3を準備
resource "random_uuid" "bucket_name" {} resource "aws_s3_bucket" "sample" { bucket = "sample-bucket-${random_uuid.bucket_name.result}" } resource "aws_s3_bucket_notification" "sample" { bucket = aws_s3_bucket.sample.id eventbridge = true } # ドキュメント参照 # https://docs.aws.amazon.com/ja_jp/guardduty/latest/ug/tag-based-access-s3-malware-protection.html#apply-tbac-s3-malware-protection resource "aws_s3_bucket_policy" "sample" { bucket = aws_s3_bucket.sample.id policy = jsonencode( { "Version" : "2012-10-17", "Statement" : [{ "Sid" : "NoReadUnlessClean", "Effect" : "Deny", "NotPrincipal" : { "AWS" : [ "arn:aws:sts::${data.aws_caller_identity.current.account_id}:assumed-role/${aws_iam_role.guardduty_malware_protection.name}/GuardDutyMalwareProtection", "${aws_iam_role.guardduty_malware_protection.arn}" ] }, "Action" : [ "s3:GetObject", "s3:GetObjectVersion" ], "Resource" : "${aws_s3_bucket.sample.arn}/*", "Condition" : { "StringNotEquals" : { "s3:ExistingObjectTag/GuardDutyMalwareScanStatus" : "NO_THREATS_FOUND" } } }, { "Sid" : "OnlyGuardDutyCanTagScanStatus", "Effect" : "Deny", "NotPrincipal" : { "AWS" : [ "arn:aws:sts::${data.aws_caller_identity.current.account_id}:assumed-role/${aws_iam_role.guardduty_malware_protection.name}/GuardDutyMalwareProtection", "${aws_iam_role.guardduty_malware_protection.arn}" ] }, "Action" : "s3:PutObjectTagging", "Resource" : "${aws_s3_bucket.sample.arn}/*", "Condition" : { "ForAnyValue:StringEquals" : { "s3:RequestObjectTagKeys" : "GuardDutyMalwareScanStatus" } } } ] } ) }
GuardDuty Malware Protection for S3
resource "aws_guardduty_malware_protection_plan" "sample" { role = aws_iam_role.guardduty_malware_protection.arn protected_resource { s3_bucket { bucket_name = aws_s3_bucket.sample.id } } actions { tagging { status = "ENABLED" } } }
以下、スキャン結果のロギングと通知例です
スキャン結果のロギング
resource "aws_cloudwatch_event_rule" "scan_result" { name = "guardduty_malware_scan_result" event_pattern = jsonencode({ "detail-type" = ["GuardDuty Malware Protection Object Scan Result"] "source" = ["aws.guardduty"] "resources" = [aws_guardduty_malware_protection_plan.sample.arn] }) } resource "aws_cloudwatch_event_target" "scan_result" { rule = aws_cloudwatch_event_rule.scan_result.name arn = aws_cloudwatch_log_group.scan_result.arn } resource "aws_cloudwatch_log_group" "scan_result" { name = "/aws/events/${aws_cloudwatch_event_rule.scan_result.name}" retention_in_days = 30 } resource "aws_cloudwatch_log_resource_policy" "eventbridge" { policy_name = "TrustEventsToStoreLogEvents" policy_document = jsonencode({ "Statement" : [ { "Action" : [ "logs:CreateLogStream", "logs:PutLogEvents" ], "Effect" : "Allow", "Principal" : { "Service" : ["events.amazonaws.com", "delivery.logs.amazonaws.com"] }, "Resource" : "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/events/*:*", "Sid" : "TrustEventsToStoreLogEvent" } ], "Version" : "2012-10-17" }) }
脅威検出時のSNS通知
resource "aws_iam_role" "eventbridge" { name = "eventbridge" assume_role_policy = jsonencode( { "Version" : "2012-10-17", "Statement" : [ { "Sid" : "", "Effect" : "Allow", "Principal" : { "Service" : "events.amazonaws.com" }, "Action" : "sts:AssumeRole" } ] } ) } resource "aws_iam_policy" "eventbridge" { name = "eventbridge" policy = jsonencode( { "Version" : "2012-10-17", "Statement" : [ { "Sid" : "Alert", "Effect" : "Allow", "Action" : [ "sns:Publish" ], "Resource" : ["*"] }, ] } ) } resource "aws_iam_role_policy_attachments_exclusive" "eventbridge" { role_name = aws_iam_role.eventbridge.name policy_arns = [aws_iam_policy.eventbridge.arn] }
resource "aws_cloudwatch_event_rule" "alert" { name = "guardduty_malware_scan_alert" event_pattern = jsonencode({ "detail-type" = ["GuardDuty Malware Protection Object Scan Result"] "source" = ["aws.guardduty"] "resources" = [aws_guardduty_malware_protection_plan.sample.arn] "detail.scanResultDetails.scanResultStatus" = ["THREATS_FOUND"] }) } resource "aws_cloudwatch_event_target" "alert" { rule = aws_cloudwatch_event_rule.alert.name arn = aws_sns_topic.alert.arn role_arn = aws_iam_role.eventbridge.arn } # 通知先をサブスクリプションに設定する必要あり resource "aws_sns_topic" "alert" { name = "alert" }
最後に
GuardDuty Malware Protection for S3を試してみて、policyは長いものの仕組みは簡単で、欲しい機能も満たされていると感じました
出始めのころは気持ちスキャン料金が高く、悲観的に試算するとエージェント形式より高くなっちゃうかも、な印象だったのですが今年に入ってスキャン料金が 85% 削減され、これから利用が増えていくサービスと感じています
Amazon GuardDuty Malware Protection for S3 announces price reduction - AWS
執筆時点(2025/6)で東京リージョンではスキャン料金が $0.1185/GB
になっており、冒頭のプロダクトでも月額 $0.1 程度で利用できています(93ファイル/0.7GBスキャン)
エージェント形式のソフトを月額にならすと$100ほどかかっていたので大きくコストダウンできています
なによりS3にファイルをおくだけでスキャンできるようになったため、アプリケーション側の制約が外れることにもなり、今後の開発環境改善に大きく影響すると考えています
皆さんもぜひお試しください!