GuardDuty Malware Protection for S3 でスキャンと通知を組んでみる(Terraform) 〜 1/1000にコストを抑える 〜

はじめに

こんにちは。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" でフィルタすることで、脅威検出時のみ通知といった制御が可能です

Slack通知例

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にファイルをおくだけでスキャンできるようになったため、アプリケーション側の制約が外れることにもなり、今後の開発環境改善に大きく影響すると考えています

皆さんもぜひお試しください!