RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

AWS 特権 ID の使用を Slack に通知する

こんにちは。 Infrastructure (インフラチーム) 所属の小門です。

RevComm (レブコム) では、電話営業や顧客対応を可視化する音声解析 AI 搭載型のクラウド IP 電話 MiiTel (ミーテル) を提供しています。

miitel.revcomm.co.jp

はじめに MiiTel を含め RevComm ではクラウドプラットフォームに AWS を利用しています。 環境や用途に応じて複数の AWS アカウントを保有しています。

AWS アカウントの特権ID (※) を使用できるのは、業務上必要とするごく少数のメンバーのみに制限しており、必要が生じたメンバーには一時的に権限付与する運用をしています。
※特権ID…ルートユーザーや AdministratorAccess IAMポリシーがアタッチされたユーザーID (= Admin ロール)

特権IDをやみくもに使用することはセキュリティガバナンス面でリスクが高いため、原則必要十分な権限を持つユーザーIDで操作するルールとしています。 例えば、環境の確認をする場合には ReadOnlyAccess、リリース作業をする場合には PowerUserAccess を使用するというイメージです。

今回は、やみくもな特権IDの使用に対するけん制やアカウント流出のリスクに備えるために特権IDの使用を検知する仕組みを構築しました。
また特権ID使用時には用途や作業内容を記載する運用ルールとし、監査証跡として残せるようにしています。

全体構成

  • ステップ1. ログイン検知 (メール通知)
  • ステップ2. 個別メンション付きリプライ

大きく2段階に構成になっています。

ステップ1. ログイン検知 (メール通知)

ルートユーザー

AWS ルートユーザーがログインしたことを通知するための CloudFormation (以下、CFn) テンプレートが公開されており、これを参考にしました。
CloudTrail を介してルートユーザーのログインを検知し EventBridge、SNS 経由でメールが送信されます。 参考: ルートユーザーアカウントが使用されたことを通知する EventBridge イベントルールを作成する

ルートユーザーがログインした CloudTrail イベントレコードは下記のようになり、これが上記 CFn テンプレートの EventPattern に対応しています。また IAM はグローバルリージョンサービスのため CloudTrail イベントデータが us-east-1 リージョンに集約される点に注意します。

  • CloudTrail イベントレコード (抜粋)
{
    "eventVersion": "1.08",
    "userIdentity": {
        "type": "Root",
        ...
    },
    "awsRegion": "us-east-1",
    "eventType": "AwsConsoleSignIn",
    ...
}
  • CFn テンプレート (抜粋)
Resources:
  ...
  EventsRule:
    Type: AWS::Events::Rule
    Properties:
      Description: Events rule for monitoring root AWS Console Sign In activity
      EventPattern:
        detail-type:
        - AWS Console Sign In via CloudTrail
        detail:
          userIdentity:
            type:
            - Root
  ...

Admin ロール

上記を参考にして特定の IAM ロールが使用されたことを検知するイベントパターンも作成しました。

RevComm では、Google Workspace を用いた AWS SSO によって開発者が IAM ロールを使用するルールになっています。
Google アカウント認証後に IAM ロールを利用すると、裏側で AssumeRoleWithSAML というアクションが実行され CloudTrail で検知できます。

  1. コンソールログイン
  2. AWS CLI などによる認証情報取得 (aws sts assume-role-with-saml ...)

の両操作をカバーできます。

  • CloudTrail イベントレコード(抜粋)
{
    "eventVersion": "1.08",
    "userIdentity": {
        "type": "SAMLUser",
        "userName": "xxx@revcomm.co.jp",
        ...
    },
    "eventSource": "sts.amazonaws.com",
    "eventName": "AssumeRoleWithSAML",
    "awsRegion": "us-east-1",
    "requestParameters": {
        ...
        "roleArn": "arn:aws:iam::012345678910:role/AdministratorRole",
        ...
    },
    ...
}
  • CFn テンプレート(抜粋)
Mappings:
  AccountAdminRoleNameMapping:
    AdminRoleArn:
      # account-1
      "012345678910": arn:aws:iam::012345678910:role/AdministratorRole
      # ...
Resources:
  ...
  AssumeRoleWithSAMLEventsRule:
    Type: AWS::Events::Rule
    Properties:
      EventPattern:
        detail-type:
          - "AWS API Call via CloudTrail"
        detail:
          eventSource:
            - "sts.amazonaws.com"
          eventName:
            - AssumeRoleWithSAML
          requestParameters:
            roleArn:
              - !FindInMap [AccountAdminRoleNameMapping, AdminRoleArn, !Ref "AWS::AccountId"]
  ...

通知結果

正しく設定できると、下記のようにメール送信することが確認できます (Slack) 。
また、EventBridge の「入力トランスフォーマー」を使用して各種情報を抜粋しています。

  • ルートユーザーのログイン通知

  • Admin ロールのログイン通知

ステップ2. 個別メンション付きリプライ

ログイン検知から通知までの仕組みができましたが、一歩踏み込んで個別メンションするようにしました。

ステップ1 で Slack に送信されたメールをトリガーにした Slack App (ボット) を作成し、AWS Lambda で動作させています。
ボットの実装には Slack が公式に公開しているフレームワークである Bolt for Python を使用しました。

Lambda ランタイムは Python 3.9、ライブラリは下記のバージョンを使用しています。

  • slack-bolt==1.11.2
  • slack-sdk==3.13.0

Slack App 作成

Slack App コンソールから「Create New App」を選び、新規アプリを作成します。

Slack App がメッセージの受信をハンドルできるよう「Event Subscriptions」をオプトインします。※Slack App 自体の詳細な手順は本記事では割愛させて頂きます。

Lambda 関数で利用するトークン情報の取得方法のみ記載します。

  • SLACK_BOT_TOKEN
    • OAuth & Permissions > OAuth Tokens for Your Workspace > Bot User OAuth Token
  • SLACK_BOT_SIGNING_SECRET
    • Settings > App Credentials > Signing Secret

メンション用のユーザーID取得

ボットから送信するメッセージでメンションを付けるには単に@xxxとするのではなく、<@[user-id]>とする必要があります。 [user-id] は Slack 内部で管理されている固有値です("W012A3CDE"のような形式)。

個別メンションのためにユーザーIDをメールアドレスから取得する必要があります。

from typing import Optional
from slack_sdk import WebClient
 
SLACK_BOT_TOKEN = 'xxx'
 
def get_user_id_by_email_address(email_address: str) -> Optional[str]:
    client = WebClient(token=SLACK_BOT_TOKEN)
    response = client.users_lookupByEmail(email=email_address)
    if not response.data.get["ok"]:
        return
    if response.data.get("error") == "users_not_found":
        return
    return response.data["user"]["id"]

参考: users.lookupByEmail method | Slack

ボットのコア処理

Bolt App を初期化すると @app.event のようにデコレーターで Slack 上のイベントをリッスンして処理を実装できます。

またエントリーポイントとなる lambda_handler もフレームワークのメソッドが用意されているため簡潔に実装することができます。

import json
import re
from typing import Union
 
from slack_bolt import App
from slack_bolt.adapter.aws_lambda import SlackRequestHandler
from slack_sdk import WebClient
 
SLACK_BOT_TOKEN = 'xxx'
SLACK_BOT_SIGNING_SECRET = 'xxx'
 
app = App(
    process_before_response=True,
    token=SLACK_BOT_TOKEN,
    signing_secret=SLACK_BOT_SIGNING_SECRET
)
 
@app.event({
    "type": "message",
    "subtype": "file_share",
})
def reply_to_sns_email(body, logger):
    """Amazon SNS からのメール通知に対してメンション付きスレッド返信を行う"""
    attachment = body["event"]["files"][0]
    # メール以外のイベントはスキップする
    if attachment["filetype"] != "email":
        return {
            'statusCode': '200',
            'body': json.dumps({'message': 'This message is not email.'})
        }
    # ...
    email_body = attachment["plain_text"]
    # " -- " 以降の改行を含む全ての文字を除去する
    login_info_text = re.sub("--.*", "", email_body, flags=re.DOTALL).replace("\r\n", "")
    login_info = json.loads(login_info_text)
    arn = login_info["RoleArn"]
    if arn.endswith("root"):
        """root ユーザーが使用された場合
        e.g. arn:aws:iam::012345678910:root
        """
        user_id = None
        message_abstract = "rootユーザーによるコンソールログインが行われました。"
    else:
        """Assumed Role Arn からログイン者のメールアドレスを取得する"""
        email_address = login_info["UserName"]
        message_abstract = "権限の強いアカウントが使用されました。"
        # メールアドレスから Slack ユーザID を紐づける
        user_id = get_user_id_by_email_address(email_address)
 
    if user_id is None:
        mention = "<!here>"
    else:
        mention = f"<@{user_id}>"
 
    message_text = \
        mention + "\n" + message_abstract + "\n" \
        "- 作業目的や内容\n" \
        "- タスクチケットURL\n" \
        "などを記入してください。"
 
    client.chat_postMessage(
        channel=body["event"]["channel"],
        thread_ts=body["event"]["event_ts"],
        text=message_text
    )
    return {
        'statusCode': '200',
        'body': json.dumps({'message': 'ok'})
    }
 
 
def lambda_handler(event, context):
    slack_handler = SlackRequestHandler(app=app)
    return slack_handler.handle(event, context)

デプロイ

上記の Lambda 関数をデプロイし、API Gateway の配下に配置します。
API Gateway は「REST API」ではなく「HTTP API」で作成する必要があるので注意します。

デプロイ後、API Gateway の URL を Slack App のリクエスト URL に登録します。
※Features > Event Subscriptions > Request URL

動作確認

ここまでの手順が正しく設定できていれば、ルートユーザー/Admin ロールのログインを契機に一連処理を通してボットによる個別メンション付きリプライが届きます。

まとめ

特権IDの使用を通知し、セキュリティガバナンスを改善する取り組みについてご紹介しました。
本記事の取り組みでは下記のメリットを実現しました。

  • 特権IDがやみくもに使用されることをけん制しつつ、使用状況をモニタリングする
  • 特権IDを用いる作業内容の監査証跡を残す

RevComm ではお客様に安心して製品をご利用頂けるよう、引き続きセキュリティの向上に力を入れてまいります。

Infrastructure では、プロダクト横断で考慮するセキュリティの管理や IaC 、また一部オンプレ環境の管理・運用を行っています。ご尽力頂けるエンジニアを探しています。
インフラ以外にも全方位でエンジニアを積極的に採用中ですのでぜひ採用サイトをご覧ください!

www.revcomm.co.jp