SESのバウンスメールをウェブフック


SESでバウンスレート由来のレビュー・配信停止を抑止するために、SNSのhttpsサブスクリプションでアプリケーションをフックするアーキ

ハードバウンスはSESがアカウントレベルのサプレッションリストに入れたあと、90日間の再送が抑止されるが、アプリケーション層でもレビュー対象として管理し、送信管理、ユーザへの修正依頼に利用する。 Amazon SESアカウントレベルのサプレッションリストの使用 - Amazon Simple Email Service

SES(ID) → ( バウンス/苦情フィードバック) → SNSトピック → (httpsウェブフック) → アプリケーション でフラグを立てる 


サブスクライブ認可

SNSトピックにhttpsプロトコルでサブスクリプションを作成すると、エンドポイントに次のhttpsリクエストが入るのでSubscribeURLを叩いて、承認を完了する。 エンドポイントで応答する実装を組んでもいいし、コピペしてブラウザUAからリクエストしても良い。

{
  "Type" : "SubscriptionConfirmation",
  "MessageId" : "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "Token" :"xxx...",
  "TopicArn" : "arn:aws:sns:ap-northeast-1:{AWSアカウントID}:{SNSトピック名}",
  "Message" : "You have chosen to subscribe to the topic arn:aws:sns:ap-northeast-1:{AWSアカウントID}:{SNSトピックID}.¥nTo confirm the subscription, visit the SubscribeURL included in this message.",
  // これ 
  "SubscribeURL" : "https://sns.ap-northeast-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:ap-northeast-1:{AWSアカウントID}:{SNSトピックID}&Token=xxx...",
  "Timestamp" : "2024-10-16T05:07:58.589Z",
  "SignatureVersion" : "1",
  "Signature" : "xxx...==",
  "SigningCertURL" : "https://sns.ap-northeast-1.amazonaws.com/SimpleNotificationService-xxx.pem"
}

認可完了のレスポンス

<ConfirmSubscriptionResponse>
  <ConfirmSubscriptionResult>
    <SubscriptionArn>
      arn:aws:sns:ap-northeast-1:{アカウントID}:{SNSトピック名}:{サブスクリプションID}
    </SubscriptionArn>
  </ConfirmSubscriptionResult>
  <ResponseMetadata>
    <RequestId>{レスポンスID}/RequestId>
  </ResponseMetadata>
</ConfirmSubscriptionResponse>

フィードバック

SESからSNSトピックにフィードバック通知を組む。


SESバウンストピックの受信

バウンスメールが発生したとき、エンドポイントに次のようなPOSTリクエストが入る。 Messageオブジェクトにバウンスまたは苦情メールのプロパティがjson文字列として入っている。

SESでバウンスなテストメールを発信する

{
  "Type" : "Notification",
  "MessageId" : "79a880a8-d9ea-538d-9f74-df218c1aa5f0",
  "TopicArn" : "arn:aws:sns:ap-northeast-1:{AWSアカウントID}:{SNSトピックID}",

  "Message" : "{¥"notificationType¥":¥"Bounce¥",¥"bounce¥":{¥"feedbackId¥":¥"0106019293deea0f-19d2444a-7a3a-4e40-a632-8b776bd2202d-000000¥",¥"bounceType¥":¥"Permanent¥",¥"bounceSubType¥":¥"General¥",¥"bouncedRecipients¥":[{¥"emailAddress¥":¥"bounce@simulator.amazonses.com¥",¥"action¥":¥"failed¥",¥"status¥":¥"5.1.1¥",¥"diagnosticCode¥":¥"smtp; 550 5.1.1 user unknown¥"}],¥"timestamp¥":¥"2024-10-16T05:48:32.000Z¥",¥"remoteMtaIp¥":¥"18.139.69.138¥",¥"reportingMTA¥":¥"dns; e234-11.smtp-out.ap-northeast-1.amazonses.com¥"},¥"mail¥":{¥"timestamp¥":¥"2024-10-16T05:48:32.056Z¥",¥"source¥":¥"ses@hoge.jp¥",¥"sourceArn¥":¥"arn:aws:ses:ap-northeast-1:{AWSアカウントID}:identity/hoge.jp¥",¥"sourceIp¥":¥"121.117.160.85¥",¥"callerIdentity¥":¥"ms-ohashi¥",¥"sendingAccountId¥":¥"{AWSアカウントID}¥",¥"messageId¥":¥"0106019293dee7b8-dc011c08-e485-4f71-bf91-e8b7d0542aa1-000000¥",¥"destination¥":[¥"bounce@simulator.amazonses.com¥"],¥"headersTruncated¥":false,¥"headers¥":[{¥"name¥":¥"From¥",¥"value¥":¥"ses@hoge.jp¥"},{¥"name¥":¥"To¥",¥"value¥":¥"bounce@simulator.amazonses.com¥"},{¥"name¥":¥"Subject¥",¥"value¥":¥"test bounce¥"},{¥"name¥":¥"MIME-Version¥",¥"value¥":¥"1.0¥"},{¥"name¥":¥"Content-Type¥",¥"value¥":¥"multipart/alternative;  boundary=¥¥¥"----=_Part_1489259_127934103.1729057712056¥¥¥"¥"}],¥"commonHeaders¥":{¥"from¥":[¥"ses@hoge.jp¥"],¥"to¥":[¥"bounce@simulator.amazonses.com¥"],¥"subject¥":¥"test bounce¥"}}}",

  "Timestamp" : "2024-10-16T05:48:32.836Z",
  "SignatureVersion" : "1",
  "Signature" : "xxx==",
  "SigningCertURL" : "https://sns.ap-northeast-1.amazonaws.com/SimpleNotificationService-xxx.pem",
  "UnsubscribeURL" : "https://sns.ap-northeast-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:ap-northeast-1:{AWSアカウントID}:{SNSトピックID}:xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

データボディの仕様 Amazon の Amazon SNS通知コンテンツ SES - Amazon Simple Email Service

ウェブサーバーへのIP直アクセスを制限しつつ、ヘルスチェックを受容する



IPアドレス直のサーバーアクセスを403制限しつつ、ロードバランサのヘルスチェックを通すapacheconfの設定


ポイント


  • 一番初めにローディングされるVirtualHostディレクティブがデフォルトサーバーとなるため、Include順を考慮したconfファイルを命名する
  • ファイル命名規則のInclude順でデフォルトサーバーは自明だが、明示的にServerName anyですべてのホスト名を捕まえている
  • デフォルトサーバーは:80:443ポートごとに設定が必要。 <VirtualHost *:*>だと、うまく動かない
  • デフォルトサーバーはhttpd -Sで確認する

httpd -S
VirtualHost configuration:
*:80                   is a NameVirtualHost
         default server any (/usr/local/httpd-2.4/conf/extra/httpd-vhosts.conf:1)
         port 80 namevhost any (/usr/local/httpd-2.4/conf/extra/httpd-vhosts.conf:1)
         port 80 namevhost webapp.com (/usr/local/httpd-2.4/conf/extra/httpd-vhosts.conf:28)
*:443                  is a NameVirtualHost
         default server any (/usr/local/httpd-2.4/conf/extra/httpd-vhosts.conf:15)
         port 443 namevhost any (/usr/local/httpd-2.4/conf/extra/httpd-vhosts.conf:15)
         port 443 namevhost webapp.com (/usr/local/httpd-2.4/conf/extra/httpd-vhosts.conf:45)

設定


  • :80:443それぞれのポートでデフォルトのバーチャルホストを定義する
  • ドキュメントルートを/var/www/_defaultに設定して、ロードバランサのヘルスチェックファイル/hb.html=>/var/www/_default/hb.htmlを作成
  • ヘルスチェック=hb.htmlはELB=サブネットワークにだけ公開したいので、リモートアドレスをRemoteIPTrustedProxyでX-Forwarded-For偽装をはじく。
  • リモートアドレスがtrustでなければ、X-Forwarded-Forを剥がしてクライアントの本来のリモートアドレスを採用する
  • :443のデフォルトサーバーはアクセス不要なので全拒否



<VirtualHost *:80>
    DocumentRoot "/var/www/_default/" 
    ServerName any
    <Directory "/var/www/_default/">
        Require all granted
    </Directory>
    # リモートアドレスがプロキシーのRemoteIPTrustedProxyアドレス帯ならば、X-Forwarded-Forを採用する。X-Forwarded-Forがなければリモートアドレスを採用する=ロードバランサである
    # リモートアドレスがプロキシーのRemoteIPTrustedProxyアドレス帯でなれけば、X-Forwarded-Forを剥がして、リモートアドレスを採用する=プロキシーを経由しないインターネットアクセス
    RemoteIPHeader X-Forwarded-For
    RemoteIPTrustedProxy 172.21.0.0/16

    # リモートアドレスがプロキシーのRemoteIPTrustedProxyアドレス帯ならば、X-Forwarded-Forを採用する。X-Forwarded-Forがなければリモート
アドレスを採用する=ロードバランサである
    # リモートアドレスがプロキシーのRemoteIPTrustedProxyアドレス帯でなれけば、X-Forwarded-Forを剥がして、リモートアドレスを採用する=プ
ロキシーを経由しないインターネットアクセス
    RemoteIPHeader X-Forwarded-For
    RemoteIPTrustedProxy 172.21.0.0/16

    # サブネットワーク直の侵入のみ許可する
    <Location />
        <RequireAll>
            Require ip 172.21.0.0/16
        </RequireAll>
    </Location>

</VirtualHost>

<VirtualHost *:443>
    ServerName any
    <Location />
        Require all denied
    </Location> 
</VirtualHost>

GuardDutyでセキュリティリスクを可視化する




Amazon GuardDutyは、AWSアカウントのイベントやネットワークトラフィックを監視して、不正アクセス、データ漏洩、マルウェアの兆候など不審な動作や脅威を自動的に検出するセキュリティサービス。検出結果とサマリーをGuardDutyコンソールに可視化してくれる。

具体的には次のような不審な動きを検出してくれる(と、AWS営業さんが熱心に語っていた)

  • ブルートフォース攻撃
  • 通常アクセスされない地域やIPからの接続試行
  • S3から外部への大規模データ転送
  • EC2の削除、IAM権限の変更
  • EC2がボットネットのC2サーバーと通信
  • EC2から外部への異常なトラフィック(例: マルウェアのダウンロード)
  • 高頻度でスキャンを行うポートスキャン
  • VPCから外部IPへの大量のデータ送信
  • ブラックリスト登録済みのIPアドレスとの通信
  • 既知のマルウェア感染ホストへの接続

データソースにはAWS CloudTrail、Amazon VPC フローログ、DNS ログをデータソースとして利用する。

Amazon GuardDuty は、AWS CloudTrail、VPC フローログ、AWS DNS ログから独立したデータストリームを直接取得します。Amazon S3 バケットポリシーを管理したり、ログを収集して保存する方法を変更したりする必要はありません。 Amazon GuardDutyを導入する前に知っておきたいこと | DevelopersIO

GuardDutyの有効化

GuardDutyを有効化するだけで、独立したデータストリームからAWS CloudTrail、Amazon VPC フローログ、DNS ログを継続的にモニタリングしてくれる。

検出の通知

【AWS】GuardDutyでイベント検知したらメール通知する仕組みを作る|hiroyu0510 【AWS】GuardDutyの通知メールをカスタマイズしてみる|hiroyu0510 GuardDutyからのイベント通知をちょっと見やすくして通知する | DevelopersIO


脅威を検出すると、==FindingをGuardDutyのコンソールにリストアップしてくれるが、SNS通知はCloudWatch Eventsで自力で構成する必要がある。

GuardDutyでFindingが発生すると、CloudWatch Eventsルールが発火します。 Amazon GuardDutyを導入する前に知っておきたいこと | DevelopersIO

Amazon EventBridge

EventBridgeでGuardDutyの脅威検出のイベントGuardDuty Findingに対して、SNSに連携するためのルールを作成する

イベントパターン

フックするイベントを定義する which

デフォルトのイベントパターン

イベントパターンについては、デフォルトの記載のままだと、検知したすべてを通知することになります。以下はすべての検知を通知する例です。 【AWS】GuardDutyでイベント検知したらメール通知する仕組みを作る|hiroyu0510

{
  "source": ["aws.guardduty"],
  "detail-type": ["GuardDuty Finding"]
}

脅威にはかなりのパターンがあるので、SNS通知は緊急度の高い脅威レベル(=severity)でフィルタリングして、定常的にGuardDutyコンソールを保守する運用でいく。

もし、通知をフィルタリングしたい場合、危険度Midium以上とか危険度HIGH以上に限定して通知するというような場合は、以下のようにすることもできます。例ですが、危険度4以上(Midium以上)の場合に通知する設定です。severityでフィルタリングをかけることができます。 【AWS】GuardDutyでイベント検知したらメール通知する仕組みを作る|hiroyu0510

{
  "source": ["aws.guardduty"],
  "detail-type": ["GuardDuty Finding"],
  "detail": {
    "severity": [{
      "numeric": [">=", 4]
    }]
  }
}

ターゲット(SNS)への入力

ルール > 編集 > ターゲットを選択 > ターゲットN > 追加設定 > 入力を設定 > ターゲット入力を設定から 入力トランスフォーマーを選択することで、ターゲットへの入力データをカスタマイズできる

入力パス what

使用したいGuardDuty FindingイベントのデータをJSONオブジェクトの任意キーにマッピングする 付録 > GuardDutyのサンプルイベント

{
    "Account_ID": "$.detail.accountId",
    "Finding_ID": "$.detail.id",
    "Finding_Type": "$.detail.type",
    "Finding_description": "$.detail.description",
    "eventFirstSeen": "$.detail.service.eventFirstSeen",
    "region": "$.region",
    "severity": "$.detail.severity"
}

テンプレート how

入力パスに定義したデータキーを使って、ターゲットに渡すテンプレートを作成する テキストでもjsonでも良い

"重要度 <severity> のGuardDutyイベントが発生しました。"
"アカウントID: <Account_ID>"
"発生日時: <eventFirstSeen>" 
"検出タイプ: <Finding_Type>"
"リージョン: <region>"
"タイプの説明: <Finding_description>"
"レポートURL: https://<region>.console.aws.amazon.com/guardduty/home?region=<region>#/findings?macros=current&fId=<Finding_ID>"

出力例 result

重要度 5 のGuardDutyイベントが発生しました。"
"アカウントID: 123456789012"
"発生日時: 2017-10-31T23:16:23Z" 
"検出タイプ: Canary:EC2/Stateless.IntegTest"
"リージョン: us-east-1"
"タイプの説明: Canary:EC2/Stateless.IntegTest"
"詳細はGuardDuty コンソール( https://us-east-1.console.aws.amazon.com/guardduty/home?region=us-east-1#/findings?macros=current&fId=16afba5c5c43e07c9e3e5e2e544e95df )を確認してください。

入力パス、テンプレート、および出力の例


動作確認

GuardDutyに対して、Backdoorテストイベントを発火してSNSトピックのサブスクライバーに届いたら完了

# ディテクタIDはアカウント/リージョンごとに割り当てられたGuardDutyのuuidで、GuardDuty > 設定から確認可能
aws guardduty create-sample-findings \
  --detector-id 96c0a1c32ad10a4bef87a28aad793f46 \
  --finding-types "Backdoor:EC2/DenialOfService.Dns"

脅威に対する公式シューティングガイド

脅威のシューティングはAWS公式ガイドを参考に対策する GuardDuty 検出結果タイプ - Amazon GuardDuty

マルウェアスキャン

[神アップデート]GuardDutyがEC2やECSのマルウェア検知時のスキャンに対応したので実際にスキャンさせてみた #reinforce | DevelopersIO

  • Backdoor:EC2/C&CActivity.BCryptoCurrency:EC2/BitcoinTool.B!DNSなどマルウェアの動作が疑われるFindings検知があると自動でマルウェアスキャンがトリガーされる。
  • オンデマンドのマルウェアスキャンもある

付録

GuardDutyのサンプルイベント

{
  "version": "0",
  "id": "c8c4daa7-a20c-2f03-0070-b7393dd542ad",
  "detail-type": "GuardDuty Finding",
  "source": "aws.guardduty",
  "account": "123456789012",
  "time": "1970-01-01T00:00:00Z",
  "region": "us-east-1",
  "resources": [],
  "detail": {
    "schemaVersion": "2.0",
    "accountId": "123456789012",
    "region": "us-east-1",
    "partition": "aws",
    "id": "16afba5c5c43e07c9e3e5e2e544e95df",
    "arn": "arn:aws:guardduty:us-east-1:123456789012:detector/123456789012/finding/16afba5c5c43e07c9e3e5e2e544e95df",
    "type": "Canary:EC2/Stateless.IntegTest",
    "resource": {
      "resourceType": "Instance",
      "instanceDetails": {
        "instanceId": "i-05746eb48123455e0",
        "instanceType": "t2.micro",
        "launchTime": 1492735675000,
        "productCodes": [],
        "networkInterfaces": [{
          "ipv6Addresses": [],
          "privateDnsName": "ip-0-0-0-0.us-east-1.compute.internal",
          "privateIpAddress": "0.0.0.0",
          "privateIpAddresses": [{
            "privateDnsName": "ip-0-0-0-0.us-east-1.compute.internal",
            "privateIpAddress": "0.0.0.0"
          }],
          "subnetId": "subnet-d58b7123",
          "vpcId": "vpc-34865123",
          "securityGroups": [{
            "groupName": "launch-wizard-1",
            "groupId": "sg-9918a123"
          }],
          "publicDnsName": "ec2-11-111-111-1.us-east-1.compute.amazonaws.com",
          "publicIp": "11.111.111.1"
        }],
        "tags": [{
          "key": "Name",
          "value": "ssh-22-open"
        }],
        "instanceState": "running",
        "availabilityZone": "us-east-1b",
        "imageId": "ami-4836a123",
        "imageDescription": "Amazon Linux AMI 2017.03.0.20170417 x86_64 HVM GP2"
      }
    },
    "service": {
      "serviceName": "guardduty",
      "detectorId": "3caf4e0aaa46ce4ccbcef949a8785353",
      "action": {
        "actionType": "NETWORK_CONNECTION",
        "networkConnectionAction": {
          "connectionDirection": "OUTBOUND",
          "remoteIpDetails": {
            "ipAddressV4": "0.0.0.0",
            "organization": {
              "asn": -1,
              "isp": "GeneratedFindingISP",
              "org": "GeneratedFindingORG"
            },
            "country": {
              "countryName": "United States"
            },
            "city": {
              "cityName": "GeneratedFindingCityName"
            },
            "geoLocation": {
              "lat": 0,
              "lon": 0
            }
          },
          "remotePortDetails": {
            "port": 22,
            "portName": "SSH"
          },
          "localPortDetails": {
            "port": 2000,
            "portName": "Unknown"
          },
          "protocol": "TCP",
          "blocked": false
        }
      },
      "resourceRole": "TARGET",
      "additionalInfo": {
        "unusualProtocol": "UDP",
        "threatListName": "GeneratedFindingCustomerListName",
        "unusual": 22
      },
      "eventFirstSeen": "2017-10-31T23:16:23Z",
      "eventLastSeen": "2017-10-31T23:16:23Z",
      "archived": false,
      "count": 1
    },
    "severity": 5,
    "createdAt": "2017-10-31T23:16:23.824Z",
    "updatedAt": "2017-10-31T23:16:23.824Z",
    "title": "Canary:EC2/Stateless.IntegTest",
    "description": "Canary:EC2/Stateless.IntegTest"
  }
}

MFA認証つきのIAMユーザでaws cliを利用するワンライナーコマンド


IAMユーザのARNとMFAデバイストークンを用意してワンタイムセッションを確立して作業する

eval `aws sts get-session-token --serial-number {IAMユーザのARN} --token-code {MFAトークン} | awk ' $1 == "\"AccessKeyId\":" { gsub(/\"/,""); gsub(/,/,""); print "export AWS_ACCESS_KEY_ID="$2 } $1 == "\"SecretAccessKey\":" { gsub(/\"/,""); gsub(/,/,""); print "export AWS_SECRET_ACCESS_KEY="$2} $1 == "\"SessionToken\":" { gsub(/\"/,""); gsub(/,/,""); print "export AWS_SESSION_TOKEN="$2 } '`

付録

  • AWS Security Token Service(STS)を使用して、MFAデバイスのトークンコードから一時的な認証情報(Temporary Security Credentials)を取得する
  • 有効期限はデフォルト12時間

aws sts get-session-token --serial-number <IAMユーザのARN> --token-code <MFAトークン>

# 結果
{
    "Credentials": {
        "SecretAccessKey": "secret-access-key",
        "SessionToken": "temporary-session-token",
        "Expiration": "expiration-date-time",
        "AccessKeyId": "access-key-id"
    }
}

次の環境変数にセット

export AWS_ACCESS_KEY_ID={AccessKeyId}
export AWS_SECRET_ACCESS_KEY={SecretAccessKey}
export AWS_SESSION_TOKEN={SessionToken}