EventBridge経由で収集したGuardDutyのイベントログをGlue CrawlerでクロールしてAthenaで見れるようにしてみた

Glue Crawlerを利用することで実データから簡単にスキーマを作成できるので、これを応用して少し裏技的に都合のいいGuardDutyイベントログをクエリするテーブルを作成します。
2022.03.31

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、臼田です。

みなさん、ログ分析してますか?(挨拶

今回はEventBridge経由で収集したGuardDutyのイベントログをAthenaでクエリできるようにしてみます。以下前回のブログの続きです。

まえがき

前回書いていますが、GuardDutyのイベントログを直接S3に保存せず、EventBridge経由で保存する場合の、Athena活用までの道のりです。同じ利用の仕方の方だけ参考になるかも。

AWS GlueのCrawlerを利用すると保存されているデータを基にテーブルを作ってくれるので、これを利用しながらAthenaで使えるようにしていきます。以下が参考になります。

GuardDutyのデータ構造とクエリについてはユーザーガイドにもありますが、残念ながらほしいstruct中身が書かれていないので、実際のログからリバースしていく必要があります。こちらにももう少しヒントがあります。特に検知している対象リソースや関連リソース毎の内容や、アクションタイプ毎の内容は多岐にわたるので、実際のログを見ていただくとわかりますがリバースは大変です。

なのでGlue Crawlerが非常に役に立ちます。

そして私はGlue初心者なので、もっと良い使い方を知っている方がいたら教えて下さい。

やってみた

まずすでに該当ログが保存されたS3バケットを用意します。前回のブログを参考にしてください。

Glue Crawlerの作成

今回は常にクロールするのではなく、テーブルを作るために情報を得る手段としてGlue Crawlerを利用します。(コスパがいいのと、そのままだとAthenaでパーティションエラーが出て使えなかったので)

Glueのマネジメントコンソールにアクセスし、クローラの追加を開始します。適当な名前を入れて次へ。

ソースはData stores、リピート時のクロール方法は一旦すべてにしました。そんなに実行しないので全てでもいいかなーという感覚で選んでいますが、正解はわかりません。

続いてデータストアとしてインクルードパスでログが保存されているS3バケットを選択します。

別のデータストアはいらないのでそのまま「いいえ」で次へ。

IAMロールの作成は任せられるので適当にサフィックスを入れて次へ。

クローラを定期的に実行せずスポットで利用したいので「オンデマンドで実行」で次へ。

クローラの出力をするデータベースを選択します。S3データのグループ化で「S3パスごとに単一のスキーマを作成する」にチェックを入れます。これを入れないと複数のS3パスベースでテーブル作成されてしまったので、1つにまとめるために入れました。

合わせて設定オプションで「新規列のみを追加します。」を選択、これはオブジェクト毎に持っていないスキーマが存在するので追加のみとしています。「すべての新規および既存のパーティションをテーブルからのメタデータで更新します。」もチェックを入れていますがこれはエラー対応のために試して入れたので、最終的にはなくてもいいかも(アプローチを変えたので)。「変更を無視して、データカタログのテーブルを変更しません。」もオブジェクトでスキーマが違うため入れています。

以上で作成します。作成したらクローラの実行をしてテーブルを作成してもらいます。ログが少ないと1分かからず終わりました。

Athenaでテーブル作成

Glue Crawlerによってテーブルが作成されたらこのDDLをもらいます。これを応用して実際に使うテーブルを作成します。Crawlerで作成されたテーブルはエラーで実行できなかったのでこの手法を取りますが、パーティションをprojection(射影)を利用したものにアレンジしたかったこともあります。(多分既存のテーブルから派生させるとCrawlerと併用できそうな気もしていますが今回はそこまでできていません。)

Athenaの画面にアクセスし、Glue Crawlerにより作成されたテーブルの「…」から「テーブルDDLを生成」を選択し、DDLを取得します。

今回取得したものは以下のとおりです。

CREATE EXTERNAL TABLE `archive_guardduty_events_bucket_******`(
  `version` string COMMENT 'from deserializer', 
  `id` string COMMENT 'from deserializer', 
  `detail-type` string COMMENT 'from deserializer', 
  `source` string COMMENT 'from deserializer', 
  `account` string COMMENT 'from deserializer', 
  `time` string COMMENT 'from deserializer', 
  `region` string COMMENT 'from deserializer', 
  `resources` array<string> COMMENT 'from deserializer', 
  `detail` struct<schemaversion:string,accountid:string,region:string,partition:string,id:string,arn:string,type:string,resource:struct<resourcetype:string,accesskeydetails:struct<accesskeyid:string,principalid:string,usertype:string,username:string>,s3bucketdetails:array<struct<arn:string,name:string,defaultserversideencryption:string,createdat:double,tags:array<string>,owner:struct<id:string>,publicaccess:struct<permissionconfiguration:struct<bucketlevelpermissions:struct<accesscontrollist:struct<allowspublicreadaccess:boolean,allowspublicwriteaccess:boolean>,bucketpolicy:struct<allowspublicreadaccess:boolean,allowspublicwriteaccess:boolean>,blockpublicaccess:struct<ignorepublicacls:boolean,restrictpublicbuckets:boolean,blockpublicacls:boolean,blockpublicpolicy:boolean>>,accountlevelpermissions:struct<blockpublicaccess:struct<ignorepublicacls:boolean,restrictpublicbuckets:boolean,blockpublicacls:boolean,blockpublicpolicy:boolean>>>,effectivepermission:string>,type:string>>,instancedetails:struct<instanceid:string,instancetype:string,launchtime:string,platform:string,productcodes:array<string>,iaminstanceprofile:struct<arn:string,id:string>,networkinterfaces:array<struct<ipv6addresses:array<string>,networkinterfaceid:string,privatednsname:string,privateipaddress:string,privateipaddresses:array<struct<privatednsname:string,privateipaddress:string>>,subnetid:string,vpcid:string,securitygroups:array<struct<groupname:string,groupid:string>>>>,outpostarn:string,tags:array<struct<key:string,value:string>>,instancestate:string,availabilityzone:string,imageid:string,imagedescription:string>>,service:struct<servicename:string,detectorid:string,action:struct<actiontype:string,awsapicallaction:struct<api:string,servicename:string,callertype:string,remoteipdetails:struct<ipaddressv4:string,organization:struct<asn:string,asnorg:string,isp:string,org:string>,country:struct<countryname:string>,city:struct<cityname:string>,geolocation:struct<lat:double,lon:double>>,affectedresources:string>,networkconnectionaction:struct<connectiondirection:string,remoteipdetails:struct<ipaddressv4:string,organization:struct<asn:string,asnorg:string,isp:string,org:string>,country:struct<countryname:string>,city:struct<cityname:string>,geolocation:struct<lat:double,lon:double>>,remoteportdetails:struct<port:int,portname:string>,localportdetails:struct<port:int,portname:string>,protocol:string,blocked:boolean,localipdetails:struct<ipaddressv4:string>>,dnsrequestaction:struct<domain:string,protocol:string,blocked:boolean>>,resourcerole:string,additionalinfo:struct<value:string,type:string,threatlistname:string>,eventfirstseen:string,eventlastseen:string,archived:boolean,count:int,evidence:struct<threatintelligencedetails:array<struct<threatlistname:string,threatnames:array<string>>>>>,severity:int,createdat:string,updatedat:string,title:string,description:string> COMMENT 'from deserializer')
PARTITIONED BY ( 
  `partition_0` string, 
  `partition_1` string, 
  `partition_2` string, 
  `partition_3` string)
ROW FORMAT SERDE 
  'org.openx.data.jsonserde.JsonSerDe' 
WITH SERDEPROPERTIES ( 
  'paths'='account,detail,detail-type,id,region,resources,source,time,version') 
STORED AS INPUTFORMAT 
  'org.apache.hadoop.mapred.TextInputFormat' 
OUTPUTFORMAT 
  'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION
  's3://archive-guardduty-events-bucket-*******/'
TBLPROPERTIES (
  'CrawlerSchemaDeserializerVersion'='1.0', 
  'CrawlerSchemaSerializerVersion'='1.0', 
  'UPDATED_BY_CRAWLER'='guardduty-crawler', 
  'averageRecordSize'='2448', 
  'classification'='json', 
  'compressionType'='gzip', 
  'objectCount'='3', 
  'recordCount'='3', 
  'sizeKey'='3755', 
  'typeOfData'='file')

detailが横に長いですがあえてそのままにしておきました。構成を理解するためには見たほうがいいですが、その深堀りはまた別の機会に。

Crawlerが追加したパーティションはprojectionが使われていませんので、これを置き換えたものが以下になります。

CREATE EXTERNAL TABLE `guardduty_events`(
  `version` string COMMENT 'from deserializer', 
  `id` string COMMENT 'from deserializer', 
  `detail-type` string COMMENT 'from deserializer', 
  `source` string COMMENT 'from deserializer', 
  `account` string COMMENT 'from deserializer', 
  `time` string COMMENT 'from deserializer', 
  `region` string COMMENT 'from deserializer', 
  `resources` array<string> COMMENT 'from deserializer', 
  `detail` struct<schemaversion:string,accountid:string,region:string,partition:string,id:string,arn:string,type:string,resource:struct<resourcetype:string,accesskeydetails:struct<accesskeyid:string,principalid:string,usertype:string,username:string>,s3bucketdetails:array<struct<arn:string,name:string,defaultserversideencryption:string,createdat:double,tags:array<string>,owner:struct<id:string>,publicaccess:struct<permissionconfiguration:struct<bucketlevelpermissions:struct<accesscontrollist:struct<allowspublicreadaccess:boolean,allowspublicwriteaccess:boolean>,bucketpolicy:struct<allowspublicreadaccess:boolean,allowspublicwriteaccess:boolean>,blockpublicaccess:struct<ignorepublicacls:boolean,restrictpublicbuckets:boolean,blockpublicacls:boolean,blockpublicpolicy:boolean>>,accountlevelpermissions:struct<blockpublicaccess:struct<ignorepublicacls:boolean,restrictpublicbuckets:boolean,blockpublicacls:boolean,blockpublicpolicy:boolean>>>,effectivepermission:string>,type:string>>,instancedetails:struct<instanceid:string,instancetype:string,launchtime:string,platform:string,productcodes:array<string>,iaminstanceprofile:struct<arn:string,id:string>,networkinterfaces:array<struct<ipv6addresses:array<string>,networkinterfaceid:string,privatednsname:string,privateipaddress:string,privateipaddresses:array<struct<privatednsname:string,privateipaddress:string>>,subnetid:string,vpcid:string,securitygroups:array<struct<groupname:string,groupid:string>>>>,outpostarn:string,tags:array<struct<key:string,value:string>>,instancestate:string,availabilityzone:string,imageid:string,imagedescription:string>>,service:struct<servicename:string,detectorid:string,action:struct<actiontype:string,awsapicallaction:struct<api:string,servicename:string,callertype:string,remoteipdetails:struct<ipaddressv4:string,organization:struct<asn:string,asnorg:string,isp:string,org:string>,country:struct<countryname:string>,city:struct<cityname:string>,geolocation:struct<lat:double,lon:double>>,affectedresources:string>,networkconnectionaction:struct<connectiondirection:string,remoteipdetails:struct<ipaddressv4:string,organization:struct<asn:string,asnorg:string,isp:string,org:string>,country:struct<countryname:string>,city:struct<cityname:string>,geolocation:struct<lat:double,lon:double>>,remoteportdetails:struct<port:int,portname:string>,localportdetails:struct<port:int,portname:string>,protocol:string,blocked:boolean,localipdetails:struct<ipaddressv4:string>>,dnsrequestaction:struct<domain:string,protocol:string,blocked:boolean>>,resourcerole:string,additionalinfo:struct<value:string,type:string,threatlistname:string>,eventfirstseen:string,eventlastseen:string,archived:boolean,count:int,evidence:struct<threatintelligencedetails:array<struct<threatlistname:string,threatnames:array<string>>>>>,severity:int,createdat:string,updatedat:string,title:string,description:string> COMMENT 'from deserializer')
PARTITIONED BY (date string)
ROW FORMAT SERDE 
  'org.openx.data.jsonserde.JsonSerDe' 
WITH SERDEPROPERTIES ( 
  'paths'='account,detail,detail-type,id,region,resources,source,time,version') 
STORED AS INPUTFORMAT 
  'org.apache.hadoop.mapred.TextInputFormat' 
OUTPUTFORMAT 
  'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION
  's3://archive-guardduty-events-bucket-******/'
TBLPROPERTIES (
  'projection.enabled' = 'true',
  'projection.date.type' = 'date',
  'projection.date.range' = 'NOW-1YEARS,NOW',
  'projection.date.format' = 'yyyy/MM/dd',
  'projection.date.interval' = '1',
  'projection.date.interval.unit' = 'DAYS',
  'storage.location.template' = 's3://archive-guardduty-events-bucket-******/${date}',
  'classification'='json', 
  'compressionType'='gzip', 
  'typeOfData'='file')

変更箇所はハイライトしています。storage.location.template${date}を追加するのを忘れないようにしてください。

これにより、追加のパーティションを手動や自動化された仕組みで追加する必要はなく、S3のパス構成に合わせてパーティションが切られ、年月日を指定した条件で効率よくクエリが実行できます。厳密には保存されているオブジェクト内のイベント発生日時と保存時に決定するパスでは30分程度のズレはありますが、そこはクエリ実行時に考慮すれば十分でしょう。1日分多くクエリしてもそんなに影響は無いので。projectionの活用方法は以下を参考にしています。

クエリしてみる

実際にクエリしてみます。上記パーティションの設定どおりであれば、dateで年月日を入れると範囲を絞ることが可能です。以下のようにクエリできます。

SELECT date,time,detail.type FROM "default"."guardduty_events" WHERE date = '2022/03/28';

とりあえずできました。細かく見ていないので、通らないクエリがあるかもですが少なくともdetail.typeは問題なくスキーマを解決できています。

まとめ

Glue Crawlerを使ってスキーマを実データから生成してテーブルを作ってみました。そのまま利用しないので裏技的に設定した感があるので、恒久利用には工夫が必要ですが、とりあえず現状のデータに対するスキーマが必要であれば十分なやり方だと思います。クロール1回で済むのでコスパもいいです。

クエリできるようになったので、いずれは活用方法についてもう少し考えてみます。