AWSにサインインされたことをSlackに通知する


まえおき

  • AWSへのサインインはIP制限ができないので、イベント検知したかった。(IAMユーザー含む)
    ※ AWSリソースに対するIP制限はできるが、サインインそのものにはIP制限できないとのこと。(2018年5月15日時点のAWSサポートからの回答より)
      

  • すべてのアカウントにMFAが適用されているかどうかを確認したかった。

  • 想定外の場所からサインインされていないかを確認したかった。
     

  • Amazon SNSのメール通知で試したところ、JSON形式のままメール本文に記載されていたため、読みづらかったので、Slack通知に変えて見やすくしたかった。

  • サインインの失敗や想定外のアクセス元IPアドレスなどを、Slackのメンションを使って気付きやすくしたかった。

全体の流れ

1.AWS CloudTrail
※Amazon Simple Storage Service (S3)

2.Amazon CloudWatch Events

3.Amazon Simple Notification Service (SNS)

4.AWS Lambda
※AWS Key Management Service (KMS)も併用。

5.Slack

▼ログイン成功時のSlack通知(AWSアカウントの場合)

※モザイク部分はアクセス元のIPアドレスです。

▼ログイン失敗時のSlack通知(IAMユーザーの場合)

※モザイク部分はIAMユーザー名とアクセス元のIPアドレスです。

設定

処理の流れに沿って書いていますが、実際は逆順から設定する必要があります。

指定したリージョン

米国東部(バージニア北部)

サインインイベントは、このリージョンに対して記録されているため。
(理由は調査中。違う事例もあるのかも)

1.AWS CloudTrail

AWS アカウント内で行われた操作のイベントログを記録する。ここでサインインイベントが記録される。

  • 証跡を作成する
  • 作成した証跡のストレージの場所として、Amazon S3を使用

2.Amazon CloudWatch Events

ルールに一致したイベントを 1 つ以上のターゲット関数またはストリームに振り分ける。ここでサインインイベントを拾って、Amazon SNSに投げる。

  • ルールを作成する

※ 先にトピックを作っておく必要がある(後述の3を参照)

3.Amazon Simple Notification Service (SNS)

サブスクライブしているエンドポイントまたはクライアントへのメッセージを配信する。ここで受けたメッセージをAWS Lambdaに配信する。

  • トピックを作成する

  • 作成したトピックにて、サブスクリプションを作成する
    • プロトコルに「AWS Lambda」を指定
    • エンドポイントに関数を指定

※ 先に関数を作っておく必要がある(後述の4を参照)

4.AWS Lambda

サーバーレスでコードを実行する。ここでAmazon SNSから受けたメッセージを元に、Slackにメッセージを送る。

  • 関数を作成する

  • [基本的な情報]は任意入力
  • [SNS トリガー]は下記の通り


  • [Lambda 関数のコード]

    • ランタイム
      • processEvent関数の部分を編集する
function processEvent(event, callback) {
    const message = JSON.parse(event.Records[0].Sns.Message);

    const eventName = message.detail.eventName
    if (eventName !== 'ConsoleLogin') {
        return;
    }

    const eventTime = message.detail.eventTime;
    const sourceIPAddress = message.detail.sourceIPAddress;
    const region = message.region;
    const awsRegion = message.detail.awsRegion;

    const userType = message.detail.userIdentity.type;
    let loginUser;
    if (userType == 'Root') {
        loginUser = userType;
    } else if (userType == 'IAMUser') {
        let userName = message.detail.userIdentity.userName;
        loginUser = `${userType}:${userName}`;
    }

    const ConsoleLogin = message.detail.responseElements.ConsoleLogin;
    let color;
    let mention = '';
    switch (ConsoleLogin) {
        case 'Success':
            color = 'good';
            break;
        case 'Failure':
            color = 'warning';
            mention = '<!here|here>';
            break;
        default:
            color = 'danger';
            mention = '<!channel|channel>';
            break;
    }

    const MFAUsed = message.detail.additionalEventData.MFAUsed;
    let MFALog;
    if (MFAUsed == 'Yes') {
        MFALog = 'MFAUsed';
    } else {
        MFALog = 'Not MFA';
    }

    const slackMessage = {
        "channel": slackChannel,
        "username": `AWS通知(${eventName})`,
        "text": `${mention}`,
        "attachments": [
            {
                "color": color,
                "fields": [
                    {
                        "title": "eventTime",
                        "value": `${eventTime}`,
                        "short": true
                    },
                    {
                        "title": "ConsoleLoginResult(MFA)",
                        "value": `${ConsoleLogin}(${MFALog})`,
                        "short": true
                    },
                    {
                        "title": "loginUser(sourceIPAddress)",
                        "value": `${loginUser} (${sourceIPAddress})`,
                        "short": true
                    },
                    {
                        "title": "region:awsRegion",
                        "value": `${region}:${awsRegion}`,
                        "short": true
                    }
                ]
            }
        ]
    };

    postMessage(slackMessage, (response) => {
        if (response.statusCode < 400) {
            console.info('Message posted successfully');
            callback(null);
        } else if (response.statusCode < 500) {
            console.error(`Error posting message to Slack API: ${response.statusCode} - ${response.statusMessage}`);
            callback(null);  // Don't retry because the error is due to a problem with the request
        } else {
            // Let Lambda retry
            callback(`Server error when processing message: ${response.statusCode} - ${response.statusMessage}`);
        }
    });
}

↓↓↓↓↓

Slackに出力しているメッセージ内容は下記となります。

項目名 内容
eventTime サインインした日時
ConsoleLoginResult(MFA) ログイン結果とMFAの使用有無
loginUser(sourceIPAddress) ログインユーザーとアクセス元IPアドレス
region:awsRegion リージョン

(補足説明)
* サインイン成功時に緑色の縦線をメッセージに表示する。
* サインイン失敗時に黄色または赤色の縦線をメッセージに表示する。
* サインイン失敗時に@hereまたは@channelメンションを付ける。


  • 暗号化の設定

    • ヘルパーの有効化にチェック
    • KMSキーを選択
  • 環境変数

    • 「slackChannel」には送信先のチャンネル名を指定
    • 「kmsEncryptedHookUrl」には、SlackのWebhook URLの【https://】除いた部分を設定
    • 暗号化をクリック

※ 先にSlackのWebhook URLを用意しておく必要がある(後述の5を参照)
※ 先にKMSキーを作っておく必要がある(本記事では「cw2slack」という名前で事前に作成、暗号化しない場合は不要)

5.Slack

  • Incoming WebHooksを追加して、「Webhook URL」を取得する。

↓↓↓↓↓

あとがき

  • 見積もり上では月額数ドル程度の費用だけれども、実際にどうなるかは経過監視後。
  • アクセス元IPアドレスによる異常検知も組み込みたいけど、IPをハードコーディングすると柔軟性に欠けるので実現方法を考え中。

追記

 2018年9月23日追記:本件に生じたAWS費用は、約2.2ドルでした。(3カ月ほったらかし)

内訳から見ても、KMSとS3の費用だけだったので、Lambdaの実行回数が無料枠内(1,000,000件リクエスト)におさまる限りは、これより増えることはないようです。