インフラエンジニアも、そうでないエンジニアも、程度の差こそあれAWS(パブリッククラウド)の知識は欠かせないものになりつつあります。しかしながら、食わず嫌いだけならまだしも、AWSにそこそこ取り組み続けているのに、しばしば嫌気がさすことすらあるのは、AWSの複雑さが原因の1つであるといっていいでしょう。
今回は、そんな複雑な仕組みの一つを、Terraform用にコード化することでココロのスキマ、お埋めします。
Infrastructure as Code の過程
Infrastructure as Code によって、手動管理をやめて自動化することのメリットは言うまでもないですが、今回は自動化する過程にこそ非常に大きなメリットがある、という視点にスポットを当てていきます。AWSは多機能で便利であるがゆえに、複雑です。そして、複雑であるがゆえに、お客さんに簡単に扱ってもらえるよう、操作が簡素化されています。それは自然な成り行きではありますが、1つの画面操作で S3 / SNS / CloudWatch / IAM / etc… と自動的に複数のリソースが作成されたその認識状態を、私はあまり好みません。
全てを正しく把握できていない、ふわふわした状況では、全てを削除したい時や、トラブルシューティング、リソース設計において、正確迅速に対処できないからです。
そのため、一度は管理画面で自動作成するものの、1つ1つのリソースを順に追って、丁寧にコード化することで全容を把握することが、AWSを正しく扱うための必須事項であると考えます。その、過程を追う流れを見ていただければ、と思います。
CloudTrail を設定しよう
今回は、EC2 や RDS といった大事なリソースに影響なく、しかし程よく複雑な CloudTrail を題材にしてみます。CloudTrail は、APIの利用履歴を保存してくれるサービスです。ただ保存するだけでも意味はなくもないですが、そのログを監視して特定の条件を検知し、通知する、といったことまでやるのがコイツの存在意義かと思います。
ではまず、管理画面のキャプチャでございます。
CloudTrail の作成
当初はリージョン毎の設定だったのですが、途中から1つのリージョンで設定するだけで全リージョンの情報を保存してくれるようになって、便利になりました。
ここで『作成』を押すと、CloudTrail と S3バケット が作成されます。
CloudWatch Logs を作成
作成後に設定を見に行くと、CloudWatch Logs の作成が下部に追加されています。最初の CloudTrail 作成時に出さなかったのは、一見の複雑さを抑えるためでしょうか、それとも CloudTrail 作成時のタイムラグの都合でしょうか、とにかく検知に必要なので設定します。これで CloudWatch 内のログ項目に、ロググループが作成されました。
IAM を作成
CloudWatch Logs に LogStream を作成したり、ログイベントを保存するために、IAM Role が必要となるため、作成することになります。これで、IAM Role と そのポリシー が作成されることになります。
メトリックスフィルタを作成
デフォ名だと CloudWatch Logs に「CloudTrail/DefaultLogGroup」というロググループができています。この中に、「ログストリーム」が自動的に作成されて、ログが溜まっていきます。このログに対して、一定条件のフィルタをするためには、ロググループに対して「メトリックスフィルタ」を作成することになります。
もう1ページあります。
これで、メトリックスフィルタが作成されます。このフィルタパターン
1 |
{ $.responseElements.ConsoleLogin = "Failure" } |
は、IAM User によるコンソールログインに失敗したログを検知します。ただし、なぜかRootユーザーだけはログイン失敗のログ自体が保存されないので、検知することができません。
他に、こんな感じのも追加したら、オフィスなどの特定外でのログイン成功を検知できます。
1 |
{ $.responseElements.ConsoleLogin = "Success" && $.sourceIPAddress != "1.2.3.*" } |
そして、ここにきて「メトリックス名前空間」という単語が出てきたりして、そろそろイヤになってくる頃合いでしょうか。私もキャプチャ取るのに飽きてきました。
アラームを作成
メトリックスフィルタを作成しただけだと、特に何もおきないので、アラームを作成します。フィルタに引っかかったら(>= 1)SNSに送る、という感じです。
ここでSNSを作成することも可能ですが、このキャプチャでは先にSNSでトピック作成を済ませています。
ここまでのリソースまとめ
ここまでで、特定のログがあったらSNSをキックする、というこれらの土台ができました。完全に飽きたところでしょうが、これだけだと何もアクションが無いので、コーヒーブレイクを挟んで紐解き作業に移っていきます。
CloudTrail+CloudWatch をTerraform化しよう
このままLambdaに突入するとワケワカメになるので、いったんここまでをTerraformコードにしてみましょう。さきほどまとめたリソース群を、丁寧にコーディングすることで、辟易した心を清めるのです。管理画面で作成したリソース1つ1つを順に追っていき、Terraformのドキュメントと照らしあわせてリソースの仕様を確認しつつ、細部を自分好みに仕上げていきます。
以下のコードは、執筆時点で最新の Terraform 0.6.16 として書いたものになります。
CloudTrail
Terraform のドキュメントページも貼っておきます。
1 2 3 4 5 6 7 8 9 10 11 |
resource "aws_cloudtrail" "default" { name = "default" s3_bucket_name = "${aws_s3_bucket.log.id}" cloud_watch_logs_role_arn = "${aws_iam_role.trail.arn}" cloud_watch_logs_group_arn = "${aws_cloudwatch_log_group.trail.arn}" include_global_service_events = true is_multi_region_trail = true enable_log_file_validation = true } |
これ自体はさほど難しいものではないですが、必要な関連リソースが3つもあるので、じゃんじゃんいきましょう。
S3
S3バケットが必要なので作成します。ログごときをずっと放置するともったいないばあさんに怒られるので、一定期間で GLACIER に移動したり削除しています。ポリシーは、自動作成の内容だとアカウントIDが入ったりして下記内容よりもう少しパスが深いのですが、複数のアカウントを管理しているため共通化できるよう、少々簡略化しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
resource "aws_s3_bucket" "log" { bucket = "${var.company_name}-${var.service_name}-log" lifecycle_rule { id = "log" prefix = "AWSLogs/" enabled = true transition { days = 30 storage_class = "GLACIER" } expiration { days = 365 } } policy = <<POLICY { "Version": "2012-10-17", "Statement": [ { "Sid": "AWSCloudTrailAclCheck", "Effect": "Allow", "Principal": { "Service": "cloudtrail.amazonaws.com" }, "Action": "s3:GetBucketAcl", "Resource": "arn:aws:s3:::${var.company_name}-${var.service_name}-log" }, { "Sid": "AWSCloudTrailWrite", "Effect": "Allow", "Principal": { "Service": "cloudtrail.amazonaws.com" }, "Action": "s3:PutObject", "Resource": "arn:aws:s3:::${var.company_name}-${var.service_name}-log/AWSLogs/*", "Condition": { "StringEquals": { "s3:x-amz-acl": "bucket-owner-full-control" } } } ] } POLICY } |
バケット名の変数は、variables.tf といったファイルに括りだすことで、別アカウントのコードにて編集するファイルを限定することと、世界ユニークな命名において確実に被らないようにすることを目的としています。
1 2 3 4 5 6 7 |
variable "company_name" { default = "mycompany" } variable "service_name" { default = "myservice" } |
IAM
TrailさんがCloudWatch Logsできるように、Roleを作ってあげます。こちらもS3同様、共通化のために一部簡略化しています。注意点として、IAMは新規作成と、それを利用するリソース作成を同時に行うと、IAM作成タイムラグのために、後の関連リソース作成でエラーになることがあります。そのため、二段階の実行に分けたうちの先に実行する運用ルールがベターです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
resource "aws_iam_role" "trail" { name = "trail" assume_role_policy = <<EOF { "Version": "2012-10-17", "Statement": [ { "Sid": "", "Effect": "Allow", "Principal": { "Service": "cloudtrail.amazonaws.com" }, "Action": "sts:AssumeRole" } ] } EOF } resource "aws_iam_role_policy" "trail" { name = "trail" role = "${aws_iam_role.trail.id}" policy = <<EOF { "Version": "2012-10-17", "Statement": [ { "Sid": "AWSCloudTrail", "Effect": "Allow", "Action": [ "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": [ "*" ] } ] } EOF } |
CloudWatch Logs LogGroup と Metric Filter
Trailさんが、この LogGroup に対して IAM Role を使って LogStream の作成と、ログの書き込みを行ってくれます。デフォ名はあまり好きじゃなかったので簡素なものに変更しています。Metric Filter は今回は直に条件を書き込んでますが、数が多い場合はハッシュ変数を駆使して小奇麗にした方がよさ気です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
resource "aws_cloudwatch_log_group" "trail" { name = "CloudTrailLogs" retention_in_days = 3 } resource "aws_cloudwatch_log_metric_filter" "trail-console-login-outside-of-office" { name = "TrailConsoleLoginOutsideOfOffice" pattern = <<PATTERN { $.responseElements.ConsoleLogin = "Success" && $.sourceIPAddress != "1.2.3.*" } PATTERN log_group_name = "${aws_cloudwatch_log_group.trail.name}" metric_transformation { name = "TrailConsoleLoginOutsideOfOffice" namespace = "LogMetrics" value = "1" } } resource "aws_cloudwatch_log_metric_filter" "trail-console-login-failure" { name = "TrailConsoleLoginFailure" pattern = <<PATTERN { $.responseElements.ConsoleLogin = "Failure" } PATTERN log_group_name = "${aws_cloudwatch_log_group.trail.name}" metric_transformation { name = "TrailConsoleLoginFailure" namespace = "LogMetrics" value = "1" } } |
CloudWatch Alarm
MetricFilter に引っかかったログを捉えてSNSをキックします。引っかかったかどうかは、値が 1以上 になることで判定しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
resource "aws_cloudwatch_metric_alarm" "trail-console-login-outside-of-office" { alarm_name = "${aws_cloudwatch_log_metric_filter.trail-console-login-outside-of-office.name}" metric_name = "${aws_cloudwatch_log_metric_filter.trail-console-login-outside-of-office.name}" namespace = "LogMetrics" comparison_operator = "GreaterThanOrEqualToThreshold" evaluation_periods = "1" period = "300" statistic = "Sum" threshold = "1" alarm_description = "Trail detect console login outside of office" alarm_actions = ["${aws_sns_topic.trail-detect-unexpected-usage.arn}"] } resource "aws_cloudwatch_metric_alarm" "trail-console-login-failure" { alarm_name = "${aws_cloudwatch_log_metric_filter.trail-console-login-failure.name}" metric_name = "${aws_cloudwatch_log_metric_filter.trail-console-login-failure.name}" namespace = "LogMetrics" comparison_operator = "GreaterThanOrEqualToThreshold" evaluation_periods = "1" period = "300" statistic = "Sum" threshold = "1" alarm_description = "Trail detect console login failure" alarm_actions = ["${aws_sns_topic.trail-detect-unexpected-usage.arn}"] } |
SNS
CloudWatch Alarm の受け皿としてSNS Topicを作成します。そしてそのアクションとして Subscription を作成するのですが・・・見ての通り今回は Lambda Function を起動するので、まだ続きます。。
1 2 3 4 5 6 7 8 9 10 |
resource "aws_sns_topic" "trail-detect-unexpected-usage" { name = "trail-detect-unexpected-usage" } resource "aws_sns_topic_subscription" "trail-detect-unexpected-usage" { topic_arn = "${aws_sns_topic.trail-detect-unexpected-usage.arn}" protocol = "lambda" endpoint = "${aws_lambda_function.trail-detect-unexpected-usage.arn}" endpoint_auto_confirms = false } |
Lambda を作成しよう
ここまでのコード化によって、今回の主な目的である『AWSの複雑な仕組みを紐解く』のは大枠完了しています。自動的にあっちこっち作られたリソースは何と何で、実はCloudWatchを分解するとこうなっている、というのが大体見えたはずだからです。ただ、これで終わると今回の CloudTrail を使う真の目的である、『予期せぬコンソールログインを検知する』を達成できていません。上記の SNS Subscription をもし E-Mail にした場合、MetricFilterで 1 を検知しましたよー という全く意味を成さない内容のメールが届くだけになるため、詳細な内容を可視化するために、どうしても Lambda に頼る必要があります。
Lambda Python
今回の Lambda Function は下記のようなもので、ポイントは
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
from datetime import datetime import boto3 import json import calendar LOG_PERIOD = 900 # It takes about 9 minutes to ingest log LOG_GROUP_NAME = 'CloudTrailLogs' TOPIC_NAME = 'mail-iam' SNS_SUBJECT = 'Trail detect unexpected IAM usage' def lambda_handler(event, context): logs = boto3.client('logs') sns = boto3.client('sns') iam = boto3.client('iam') # # Get Filter Patterns # res = logs.describe_metric_filters( logGroupName = LOG_GROUP_NAME, )['metricFilters'] if not res: return filters = {x['filterName']:x['filterPattern'] for x in res} # # Get Logs # res = logs.describe_log_streams( logGroupName = LOG_GROUP_NAME, orderBy = "LastEventTime", descending = True, limit = 1, )['logStreams'] if not res: return stream_name = res[0]['logStreamName'] end_time = calendar.timegm(datetime.utcnow().timetuple()) start_time = end_time - LOG_PERIOD number = 0 events = [] for title,pattern in filters.items(): res = logs.filter_log_events( logGroupName = LOG_GROUP_NAME, logStreamNames = [stream_name], startTime = start_time * 1000, filterPattern = pattern, )['events'] if not res: continue for event in res: number += 1 event.update({'number': number, 'title': title}) events.append(event) if not events: print "Not found filtering logs." return # # SNS # topics = sns.list_topics()['Topics'] if not topics: print "Not found topic list." return tmp = filter(lambda x: x['TopicArn'].split(':')[-1] == TOPIC_NAME, topics) if not tmp: print "Not found topic name." return topic_arn = tmp[0]['TopicArn'] account_alias = "NoName" list_account_aliases = iam.list_account_aliases()['AccountAliases'] if list_account_aliases: account_alias = list_account_aliases[0] subject = "[%s] %s (%d records)" % (account_alias, SNS_SUBJECT, len(events)) message = subject + "\n\n" for event in events: timestamp_format = datetime.fromtimestamp(event['timestamp'] / 1000) ingestion_format = datetime.fromtimestamp(event['ingestionTime'] / 1000) message += "#\n" message += "# %d\n" % event['number'] message += "#\n" message += "Timestamp : %s\n" % timestamp_format message += "ingestionTime: %s\n" % ingestion_format message += "Filter Name : %s\n" % event['title'] message += "%s\n\n" % filters[event['title']] message += json.dumps(json.loads(event['message']), sort_keys=True, indent=4) message += "\n\n" sns.publish( TopicArn = topic_arn, Subject = subject, Message = message, ) print "Send message to SNS." |
SNS
最終的な通知先として、Topicを作成し、作成後に管理画面からE-Mailを登録します。
1 2 3 |
resource "aws_sns_topic" "iam" { name = "mail-iam" } |
IAM
Lambda用のRoleを作成します。今回はVPC用の自動作成の内容を元に、最低限な内容にしましたが、いつもは他のLambdaでも使っているのでEC2とかRDSも入っています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
resource "aws_iam_role" "lambda_vpc" { name = "lambda_basic_vpc_execution" assume_role_policy = <<EOF { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "lambda.amazonaws.com" }, "Action": "sts:AssumeRole" } ] } EOF } resource "aws_iam_role_policy" "lambda_vpc" { name = "lambda_basic_vpc_execution_policy" role = "${aws_iam_role.lambda_vpc.id}" policy = <<EOT { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "arn:aws:logs:*:*:*" }, { "Effect": "Allow", "Action": [ "ec2:CreateNetworkInterface", "ec2:DescribeNetworkInterfaces", "ec2:DetachNetworkInterface", "ec2:DeleteNetworkInterface" ], "Resource": "*" }, { "Effect": "Allow", "Action": [ "lambda:*", "iam:List*", "sns:Publish", "sns:List*", "logs:Get*", "logs:Describe*", "logs:Filter*" ], "Resource": "*" }, { "Effect": "Allow", "Action": [ "cloudwatch:ListMetrics", "cloudwatch:GetMetricStatistics", "cloudwatch:Describe*" ], "Resource": "*" } ] } EOT } |
Lambda
そして、、ふぅ~~、最後にzipに固めて、Lambdaを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
resource "aws_lambda_function" "trail-detect-unexpected-usage" { filename = "lambda/trail-detect-unexpected-usage.zip" source_code_hash = "${base64sha256(file("lambda/trail-detect-unexpected-usage.zip"))}" function_name = "trail-detect-unexpected-usage" handler = "trail-detect-unexpected-usage.lambda_handler" memory_size = 128 timeout = 30 runtime = "python2.7" role = "${aws_iam_role.lambda_vpc.arn}" } resource "aws_lambda_permission" "trail-detect-unexpected-usage" { statement_id = "TrailDetectUnexpectedUsage" action = "lambda:InvokeFunction" principal = "sns.amazonaws.com" function_name = "${aws_lambda_function.trail-detect-unexpected-usage.arn}" source_arn = "${aws_sns_topic.trail-detect-unexpected-usage.arn}" } |
IAMエイリアス名
おっと、忘れてました。IAMダッシュボードの「IAM ユーザーのサインインリンク」の横にある、『カスタマイズ』で、アカウントを判別できる名前を登録しておきます。これはLambdaスクリプトで個人的必要性に応じて使っているだけなので、全然必須じゃないです。本当は、管理画面右上に表示される、アカウント名を使えればそれでよかったのですが、APIでの取得が見つからなかったので、これで代用した感じになります。
Terraformを実行する
これで、1回目はIAM関連のみを実行し、その後に残りを追加実行することで、CloudTrail+Lambda のコンソールログインログ検知システムのできあがりとなります。検知メールの確認
少し時間が経てば、S3 や CloudWatch Logs にログが保存されていくのを確認できます。そして、自前で仕込んだ検知システムの動作確認をするために、パスワード(コンソールログイン)を有効にした、適当な IAM User に対して、ワザとログイン失敗をしてみます。すると、十数分後には下記のようなメールが届くはずです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
[mycompany-myservice] Trail detect unexpected IAM usage (1 records) # # 1 # Timestamp : 2016-06-27 02:10:47 ingestionTime: 2016-06-27 02:16:34 Filter Name : TrailConsoleLoginFailure { $.responseElements.ConsoleLogin = "Failure" } { "additionalEventData": { "LoginTo": "https://console.aws.amazon.com/console/home?state=hashArgs%23&isauthcode=true", "MFAUsed": "No", "MobileVersion": "No" }, "awsRegion": "us-east-1", "errorMessage": "Failed authentication", "eventID": "6967d4da-2f65-3dfa-3c6d-0d5b2babef23", "eventName": "ConsoleLogin", "eventSource": "signin.amazonaws.com", "eventTime": "2016-06-27T02:10:47Z", "eventType": "AwsConsoleSignIn", "eventVersion": "1.02", "recipientAccountId": "123456789012", "requestParameters": null, "responseElements": { "ConsoleLogin": "Failure" }, "sourceIPAddress": "1.2.3.4", "userAgent": "Mozilla/5.0 ...", "userIdentity": { "accessKeyId": "", "accountId": "123456789012", "type": "IAMUser", "userName": "testuser" } } |
ログの内容は面倒くさい、というよりは選りすぐって減らす理由も特にないので、JSONをそのまま成形して出力しただけになります。
何もないよりは、こういう仕込みをしておくと、アカウントのセキュリティを向上させることができるので、Terraform+Lambdaコードを丸っとコピペして実行しておくだけでも、やっておくとよいのかな~と思います。
また、Config というリソース変化を記録するサービスもあるので、同様に有効にしておくとよいです。ただ、Config自体はTerraformが対応していないので、Config で利用する S3 や IAM を同じような手順でコード化して、最後にConfigを手動で有効にするだけにするとよいでしょう。
1つのアカウントしか管理しないならば、これらは不要かもしれませんが、複数アカウントならば最初からコード化してしまうほうが、ココロのスキマを作らずに済むはずです。
・・・まぁ、ぶっちゃけ、こういう記事は、書いてて全く楽しくないですね!Terraformのコード事例が世の中にそれほど多くないので、役に立つかなと思って書き始めたものの、完全に後悔して何度か放り投げようとしてしまいました。
また、Terraformのコード管理と実行は、GitLab CI で行っているのですが、そこまで図示しようとすると汚物にまみれたような記事になりそうでしたので、この辺で切り上げたいと思います。
Lambda のコーディングで心を癒やしつつ取り組むのが、長続きのコツです、多分!!