AWS CDK : 特定のAPIコールを監視してみた (CloudTrail,CloudWatch Logs)

# Terraform版はZennにあげてます
https://zenn.dev/wakaka23/articles/cab6da21d5ea0e

# 概要
CloudTrailを用いて特定のAPIコールを監視する方法として下記の2パターンがあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2746031/63af7343-8b20-d988-e04b-d100cf912651.png)

今回は「2. CloudTrailログのCloudWatch Logsへの格納及びメトリクスフィルタ・アラームの指定」のような「CloudTrailを用いて管理されたAPIコールログをCloudWatch Logsに転送し、メトリクスフィルタでフィルタリング、特定のAPIが呼び出された時にSNS通知を行う」という構成をCDKで検証してまとめてみました。「1. EventBridgeのイベントソースとしてAPIを指定」の構成は以下の記事をご覧ください。

https://qiita.com/Ak_ki/items/461991c1a67a1a0e4e2c

# 構成図
以下に示したようなAPIコールを監視する設計としています。
- IAM Policyの作成・削除、アタッチ・デタッチ
- Rootユーザのコンソールサインイン
- Access Keyの新規作成
- SGルールの作成・削除
- NACLの作成・削除、NACLルールの作成・削除、NACLのサブネットへの関連付け
- CloudTrailのロギング停止、証跡削除、証跡の設定変更

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2746031/69c0ff44-8e0c-5a1a-081a-6f6b123a522c.png)

# コンソール画面

### メトリクスフィルタ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2746031/6c94b934-6e38-778f-54b1-702de2a57856.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2746031/ccba9696-fe14-5381-0b70-dc17e4beef91.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2746031/3a31b0c9-f4d8-2396-d319-f707803f3315.png)

### CloudWatch Alarm
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2746031/d5f2b7db-473b-d9b7-b576-5f9827cba80c.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2746031/4e769e29-d3aa-caaf-b4c9-730d6fdc563d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2746031/10ea4b50-257f-d80b-29b9-b00b3e4cf4e9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2746031/2f642bfb-369f-bf6a-20c6-615fe801dc3f.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2746031/ebed63a6-d27e-6730-1aca-472e63ad9840.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2746031/c0c3d654-2c4b-34ac-76cd-bb4c3592df0e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2746031/8ea1776f-a8a1-b0ab-afda-cd44496d3415.png)


# ディレクトリ構造
本記事ではbin, lib配下のファイルを紹介します。
```
├── bin
│   └── aws_cloud_trail_sample2.ts
├── lib
│   └── Stack
│       ├── aws_cloud_trail_sample2-stack.ts
│   └── Construct
│       ├── cloudtrail.ts
│       ├── cloudwatch.ts
│       └── sns.ts
├── node_modules
├── test
├── cdk.json
├── jest.config.js
├── package-lock.json
├── package.json
├── README.md
└── tsconfig.json
```

# CDK実装コード

## bin

### aws_cloud_trail_sample2.ts

```typescript
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { AwsCloudTrailSample2Stack } from '../lib/Stack/aws_cloud_trail_sample2-stack';

const app = new cdk.App();
new AwsCloudTrailSample2Stack(app, 'AwsCloudTrailSample2Stack', {});
```

## lib/Stack
- aws_cloud_trail_sample2-stack.ts : CloudTrail証跡、Logsロググループ、Alarmを定義

今回は「ap-northeast-1に存在する単一のロググループに全てのリージョンのAPIコールを格納し、メトリクスフィルタを適用することでメトリクスを吐き出す」という構成になっているため、[「CloudTrail→EventBridge→SNS」の構成](https://qiita.com/Ak_ki/items/461991c1a67a1a0e4e2c)とは異なり、ap-northeast-1のみにStackをデプロイするだけで十分となります。

### tokyo-stack.ts

```typescript
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { TrailConstruct } from '../Construct/cloudtrail';
import { SnsConstruct } from '../Construct/sns';
import { CwConstruct } from '../Construct/cloudwatch';

export class AwsCloudTrailSample2Stack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const FirstTrail = new TrailConstruct(this, "CloudTrail", {}).createLogsGroup()

    const TopicForAlarm = new SnsConstruct(this, "SNSTopic", {
      notifyEmail: "xxxxxxx@gmail.com"
    }).createTopic();

    const LogsAndAlarm = new CwConstruct(this, "CloudWatch", {
      TrailLogGroup: FirstTrail,
      AlarmTopic: TopicForAlarm,
    })

  }
}
```

## lib/Construct

### cloudtrail.ts

証跡ログを格納するS3バケット、配信先のCloudWatch Logsロググループ、CloudTrail証跡を定義しています。監視することを目的としているため、ロググループの保持期間は1週間と短めに設計しています。

```typescript
import { Construct } from "constructs";
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as trail from 'aws-cdk-lib/aws-cloudtrail';

export interface TrailProps{}

export class TrailConstruct extends Construct{
    private readonly trailLogGroup_att: logs.LogGroup;

    constructor(scope: Construct, id: string, props: TrailProps) {
        super(scope, id);

        // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3.Bucket.html
        const TrailBucket = new s3.Bucket(this, "TrailBucket", {
            blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
            encryption: s3.BucketEncryption.S3_MANAGED,
        });

        // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_logs-readme.html
        const cloudTrailLogGroup = new logs.LogGroup(this, 'CloudTrailLogGroup', {
            retention: logs.RetentionDays.ONE_WEEK,
        });
        this.trailLogGroup_att = cloudTrailLogGroup;

        // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudtrail.Trail.html
        new trail.Trail(this, "FirstTrail", {
            bucket: TrailBucket,
            enableFileValidation: true,
            cloudWatchLogGroup: cloudTrailLogGroup,
            isMultiRegionTrail: true,
            sendToCloudWatchLogs: true,
        });
    }

    public createLogsGroup(): logs.LogGroup {
        return this.trailLogGroup_att;
    }

}
```

### cloudwatch.ts

メトリクスフィルタとCloudWatch Alarmを定義しています。今回もBLEAテンプレートをベースにフィルタやAlarmを設計しています。

```typescript
import { Construct } from "constructs";
import * as cdk from 'aws-cdk-lib';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as sns from "aws-cdk-lib/aws-sns";
import * as cw from 'aws-cdk-lib/aws-cloudwatch';
import * as cwa from "aws-cdk-lib/aws-cloudwatch-actions";

export interface CloudwatchProps {
    TrailLogGroup: logs.LogGroup;
    AlarmTopic: sns.ITopic;
}

export class CwConstruct extends Construct {

    constructor(scope: Construct, id: string, props: CloudwatchProps) {
        super(scope, id);

        // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_logs.MetricFilter.html
        const RootLoginMetricsFilter = new logs.MetricFilter(this, "RootLoginMetricsFilter", {
            logGroup: props.TrailLogGroup,
            filterPattern: {
                logPatternString:
                    '{($.userIdentity.type = "Root")&&($.eventName = "ConsoleLogin")}',
            },
            filterName: "Metrics Filter to detect console login by root",
            metricNamespace: "CWLogs",
            metricName: "console-login-by-root",
            metricValue: "1",
        });

        const IAMChangeMetricsFilter = new logs.MetricFilter(this, "IAMChangeMetricsFilter", {
            logGroup: props.TrailLogGroup,
            filterPattern: {
                logPatternString:
                    '{($.eventName = DeleteGroupPolicy) || ($.eventName = DeleteRolePolicy) || ($.eventName = DeleteUserPolicy) || \
                        ($.eventName = PutGroupPolicy) || ($.eventName = PutRolePolicy) || ($.eventName = PutUserPolicy) || \
                        ($.eventName = CreatePolicy) || ($.eventName = DeletePolicy) || ($.eventName = CreatePolicyVersion) || \
                        ($.eventName = DeletePolicyVersion) || ($.eventName = AttachRolePolicy) || ($.eventName = DetachRolePolicy) || \
                        ($.eventName = AttachUserPolicy) || ($.eventName = DetachUserPolicy) || ($.eventName = AttachGroupPolicy) || \
                        ($.eventName = DetachGroupPolicy)}'
            },
            filterName: "Metrics Filter to detect changes in IAM policy",
            metricNamespace: "CWLogs",
            metricName: "changes-in-IAM-policy",
            metricValue: "1",
        });

        const CreateAccessKeyMetricsFilter = new logs.MetricFilter(this, "CreateAccessKeyMetricsFilter", {
            logGroup: props.TrailLogGroup,
            filterPattern: {
                logPatternString:
                    '{$.eventName = "CreateAccessKey"}',
            },
            filterName: "Metrics Filter to detect creation of access keys",
            metricNamespace: "CWLogs",
            metricName: "creation-of-access-key",
            metricValue: "1",
        });

        const SGChangeMetricsFilter = new logs.MetricFilter(this, "SGChangeMetricsFilter", {
            logGroup: props.TrailLogGroup,
            filterPattern: {
                logPatternString:
                    '{($.eventName = "AuthorizeSecurityGroupIngress") || ($.eventName = "AuthorizeSecurityGroupEgress") || \
                        ($.eventName = "RevokeSecurityGroupIngress") || ($.eventName = "RevokeSecurityGroupEgress")}',
            },
            filterName: "Metrics Filter to detect changes in SG rules",
            metricNamespace: "CWLogs",
            metricName: "changes-in-SG-rules",
            metricValue: "1",
        });

        const NACLChangeMetricsFilter = new logs.MetricFilter(this, "NACLChangeMetricsFilter", {
            logGroup: props.TrailLogGroup,
            filterPattern: {
                logPatternString:
                    '{($.eventName = "CreateNetworkAcl") || ($.eventName = "CreateNetworkAclEntry") || \
                        ($.eventName = "DeleteNetworkAcl") || ($.eventName = "DeleteNetworkAclEntry") || \
                        ($.eventName = "ReplaceNetworkAclEntry") || ($.eventName = "ReplaceNetworkAclAssociation")}'
            },
            filterName: "Metrics Filter to detect changes in NACL",
            metricNamespace: "CWLogs",
            metricName: "changes-in-NACL",
            metricValue: "1",
        });

        const TrailChangeMetricsFilter = new logs.MetricFilter(this, "TrailChangeMetricsFilter", {
            logGroup: props.TrailLogGroup,
            filterPattern: {
                logPatternString:
                    '{($.eventName = "StopLogging") || ($.eventName = "DeleteTrail") || ($.eventName = "UpdateTrail")}'
            },
            filterName: "Metrics Filter to detect changes in CloudTrail",
            metricNamespace: "CWLogs",
            metricName: "changes-in-CloudTrail",
            metricValue: "1",
        });

        // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudwatch.Alarm.html
        new cw.Alarm(this, "RootLoginAlarm", {
            metric: RootLoginMetricsFilter.metric({
                statistic: cw.Stats.SUM,
                period: cdk.Duration.seconds(60)
            }),
            comparisonOperator: cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
            threshold: 1,
            evaluationPeriods: 1,
            datapointsToAlarm: 1,
            treatMissingData: cw.TreatMissingData.NOT_BREACHING,
            alarmName: "RootLoginAlarm",
        }).addAlarmAction(new cwa.SnsAction(props.AlarmTopic));

        new cw.Alarm(this, "IAMChangeAlarm", {
            metric: IAMChangeMetricsFilter.metric({
                statistic: cw.Stats.SUM,
                period: cdk.Duration.seconds(60)
            }),
            comparisonOperator: cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
            threshold: 1,
            evaluationPeriods: 1,
            datapointsToAlarm: 1,
            treatMissingData: cw.TreatMissingData.NOT_BREACHING,
            alarmName: "IAMChangeAlarm",
        }).addAlarmAction(new cwa.SnsAction(props.AlarmTopic));

        new cw.Alarm(this, "CreateAccessKeyAlarm", {
            metric: CreateAccessKeyMetricsFilter.metric({
                statistic: cw.Stats.SUM,
                period: cdk.Duration.seconds(60)
            }),
            comparisonOperator: cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
            threshold: 1,
            evaluationPeriods: 1,
            datapointsToAlarm: 1,
            treatMissingData: cw.TreatMissingData.NOT_BREACHING,
            alarmName: "CreateAccessKeyAlarm",
        }).addAlarmAction(new cwa.SnsAction(props.AlarmTopic));

        new cw.Alarm(this, "SGChangeAlarm", {
            metric: SGChangeMetricsFilter.metric({
                statistic: cw.Stats.SUM,
                period: cdk.Duration.seconds(60)
            }),
            comparisonOperator: cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
            threshold: 1,
            evaluationPeriods: 1,
            datapointsToAlarm: 1,
            treatMissingData: cw.TreatMissingData.NOT_BREACHING,
            alarmName: "SGChangeAlarm",
        }).addAlarmAction(new cwa.SnsAction(props.AlarmTopic));

        new cw.Alarm(this, "NACLChangeAlarm", {
            metric: NACLChangeMetricsFilter.metric({
                statistic: cw.Stats.SUM,
                period: cdk.Duration.seconds(60)
            }),
            comparisonOperator: cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
            threshold: 1,
            evaluationPeriods: 1,
            datapointsToAlarm: 1,
            treatMissingData: cw.TreatMissingData.NOT_BREACHING,
            alarmName: "NACLChangeAlarm",
        }).addAlarmAction(new cwa.SnsAction(props.AlarmTopic));

        new cw.Alarm(this, "TrailChangeAlarm", {
            metric: TrailChangeMetricsFilter.metric({
                statistic: cw.Stats.SUM,
                period: cdk.Duration.seconds(60)
            }),
            comparisonOperator: cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
            threshold: 1,
            evaluationPeriods: 1,
            datapointsToAlarm: 1,
            treatMissingData: cw.TreatMissingData.NOT_BREACHING,
            alarmName: "TrailChangeAlarm",
        }).addAlarmAction(new cwa.SnsAction(props.AlarmTopic));
    }

}
```

### sns.ts

```typescript
import { Construct } from "constructs";
import * as sns from "aws-cdk-lib/aws-sns";

export interface SnsProps {
    notifyEmail: string;
}

export class SnsConstruct extends Construct {
    private readonly topic: sns.ITopic

    constructor(scope: Construct, id: string, props: SnsProps) {
        super(scope, id);

        // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_sns-readme.html
        const topic = new sns.Topic(this, "TopicForAlarm",{});

        new sns.Subscription(this, "MonitorAlarmEmail", {
            endpoint: props.notifyEmail,
            protocol: sns.SubscriptionProtocol.EMAIL,
            topic: topic,
        });
        this.topic = topic;
    }

    public createTopic(): sns.ITopic {
        return this.topic;
    }
}
```

# 最後に
今回は一回でもAPIが呼び出されたら通知するような設計としましたが、Alarmを調整することで単位時間あたりに複数回APIが呼び出されたら通知するみたいな設計にもできるので、「1. EventBridgeのイベントソースとしてAPIを指定」のパターンよりこっちの構成の方が個人的には好みですね。IAMのAPIコールのためにus-east-1にEventBridgeやSNSトピックを作成する必要がなく、単一のリージョンで完結させることができますし。