概要
CloudTrailを用いて特定のAPIコールを監視する方法として下記の2パターンがあります。
今回は「2. CloudTrailログのCloudWatch Logsへの格納及びメトリクスフィルタ・アラームの指定」のような「CloudTrailを用いて管理されたAPIコールログをCloudWatch Logsに転送し、メトリクスフィルタでフィルタリング、特定のAPIが呼び出された時にSNS通知を行う」という構成をCDKで検証してまとめてみました。「1. EventBridgeのイベントソースとしてAPIを指定」の構成は以下の記事をご覧ください。
構成図
以下に示したようなAPIコールを監視する設計としています。
- IAM Policyの作成・削除、アタッチ・デタッチ
- Rootユーザのコンソールサインイン
- Access Keyの新規作成
- SGルールの作成・削除
- NACLの作成・削除、NACLルールの作成・削除、NACLのサブネットへの関連付け
- CloudTrailのロギング停止、証跡削除、証跡の設定変更
コンソール画面
メトリクスフィルタ
CloudWatch Alarm
ディレクトリ構造
本記事では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
#!/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」の構成とは異なり、ap-northeast-1のみにStackをデプロイするだけで十分となります。
tokyo-stack.ts
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週間と短めに設計しています。
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を設計しています。
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
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トピックを作成する必要がなく、単一のリージョンで完結させることができますし。