🗽

GitHub の 監査ログを Amazon Athena でクエリできるようにした

2024/02/05に公開

GitHub Enterprise Cloud の 監査ログ(Audit log) は過去6ヶ月間までしか保持されず、その中でも Git イベントは 7 日間しか保持されないため、長期保管を実現するために S3 などの外部のストレージにストリーミングしている方も多いと思います。

監査ログには、Enterprise に影響するアクティビティによってトリガーされるイベントの一覧が表示されます (現在の月内および過去 6 か月間まで)。 監査ログには、Git イベントが 7 日間保持されます。

https://docs.github.com/ja/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/about-the-audit-log-for-your-enterprise

ただ、保管だけではなく必要な時にクエリできる状態にはしておきたいですよね。

そこで今回は S3 に保管している GitHub Enterprise Cloud の Audit log を Athena でクエリできるようにしたので、そちらの方法について書いていきたいと思います。

記事が解決する問題

  • GitHub Enterprise Cloud の Audit log を Amazon Athena でクエリできる状態にしたい
  • クエリできる状態にはしておきたいけど、明確なユースケースまでは決まっていない
    • ※とにかく必要な時にログを検索できる状態にしておきたいんです!

結論

以下のようなテーブル・ビューを Amazon Athena で作成することで、 課題を解決しました。

Athena テーブル

CREATE EXTERNAL TABLE IF NOT EXISTS `<TABLE NAME>` (
  `json_objects` string
)
PARTITIONED BY (
  `date` string
)
ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe'
LOCATION 's3://<YOUR S3 PATH>/'
TBLPROPERTIES (
  'projection.enabled' = 'true',
  'projection.date.type' = 'date',
  'projection.date.format' = 'yyyy/MM/dd/HH',
  'projection.date.range' = '2023/01/01/01,NOW',
  'projection.date.interval' = '1',
  'projection.date.interval.unit' = 'HOURS',
  'storage.location.template' = 's3://<YOUR_S3_PATH>/${date}/' # ${date}は置き換えずにそのままお使いください
);

Athena ビュー

CREATE VIEW `<VIEW_NAME>` AS
SELECT
  date,
  json_extract_scalar(json_objects, '$.action') AS action,
  json_extract_scalar(json_objects, '$["@timestamp"]') AS "timestamp",
  json_objects
FROM `<TABLE NAME>`
;

これらを使って以下のステップを実行することで、必要な時に必要なログを検索できる状態にするというユースケースを解決することができます。

  1. ビューにクエリを投げ、調査したいイベントログのJSONスキーマを明らかにする
  2. 明らかになったJSONスキーマから、調査したいイベントに特化したテーブルを作成する
  3. 作成したテーブルに対してクエリを実行する

上記ステップの詳細な内容と、それぞれの解説を引き続き書いていきたいと思います。

Amazon Athena で監査ログをクエリする方法

例えば、hogeリポジトリをいつ誰が消したのかを知りたい」 というシンプルなケースを想定して、各ステップを詳しく見ていきます。

1. 調査したいイベントログのJSONスキーマを明らかにする

Athena ビューに以下のクエリを投げて、リポジトリを削除した際に記録されるイベントログのJSONスキーマを明らかにします。

SELECT * FROM <VIEW_NAME>
WHERE
  action = 'repo.destroy' AND
  date BETWEEN '2023/01/01' AND '2023/02/01'

クエリを実行すると、以下のような結果が返ってきます。
※値は適当にマスキングしています。

{
	"action":"repo.destroy",
	"actor":"<actor name>",
	"actor_id":<actor id>,
	"actor_ip":"<actor ip>",
	"user_agent":"<user agent>",
	"visibility":"<repo visibility>",
	"repo":"<repo name>",
	"repo_id":<repo id>,
	"public_repo":<is public>,
	"org":"<org name>",
	"org_id":<org id>,
	"_document_id":"<document id>",
	"@timestamp":<timestamp>,
	"created_at":<timestamp>,
	"operation_type":"<operation_type>",
	"business":"<business name>",
	"business_id":<business id>,
	"actor_location":{"country_code":"JP"}
}

これでrepo.destroyのイベントログはこのようなJSONスキーマであることが分かりました。

2. イベントの構造に特化したテーブルを作成する

先ほど判明したJSONスキーマを使って、今回の調査に必要なカラムを持つテーブルを作成していきます。今回のケースはシンプルなので action, actor, repo, @timestampくらいがあれば十分です。

以下のようなクエリを実行します。

CREATE EXTERNAL TABLE IF NOT EXISTS `temp` (
  `action` string,
  `actor` string,
  `repo` string
  `@timestamp` string,
)
PARTITIONED BY (
  `date` string
)
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
WITH SERDEPROPERTIES (
  'ignore.malformed.json' = 'FALSE',
  'dots.in.keys' = 'FALSE',
  'case.insensitive' = 'TRUE',
  'mapping' = 'TRUE'
)
LOCATION 's3://<YOUR_S3_PATH>/'
TBLPROPERTIES (
  'projection.enabled' = 'true',
  'projection.date.type' = 'date',
  'projection.date.format' = 'yyyy/MM/dd/HH',
  'projection.date.range' = '2023/01/01/01,NOW',
  'projection.date.interval' = '1',
  'projection.date.interval.unit' = 'HOURS',
  'storage.location.template' = 's3://<YOUR_S3_PATH>/${date}/', # ${date}は置き換えずにそのままお使いください
  'classification' = 'json'
);

これで temp という名前の Athena Table が新たに作られました。

3. 作成したテーブルに対してクエリを実行する

作成した tempテーブルに対してクエリを実行し、目的のログを手に入れます。
以下のクエリで、いつ誰が hogeリポジトリを削除したかを判明させられます。

SELECT * FROM temp
WHERE
  action = 'repo.destroy' AND
  repo = 'hoge' AND
  date BETWEEN '2024/01/01' AND '2024/02/01'

結果

[repo.destory, userA, hoge, 1705267800000, 2024/01/15/06]

どうやら userA2024/01/15日 06:30:00hoge リポジトリを削除した ようです。

今回のユースケースが再度必要になりそうであれば、tempテーブルを残しておくことで次の機会で1,2のステップを飛ばすことができます。

以上がクエリの流れになります。

解説編・Tips

ここからは、このようなステップになった理由や各ステップの詳細を書いていきたいと思います。

1. このようなステップになった理由

ひとことで言うと、汎用的なログ検索を実現するためです。

まず、Amazon Athena を使って S3 オブジェクトのデータをクエリするには、テーブル定義を事前に行う必要があります。

Athena では、クエリするデータセットごとに、クエリ結果を取得して返すために Athena が使用する基盤となるテーブルが必要です。このため、データをクエリする前に、Athena にテーブルを登録しておく必要があります。
https://docs.aws.amazon.com/ja_jp/athena/latest/ug/understanding-tables-databases-and-the-data-catalog.html

しかし、以下の2つの事情により テーブル定義を事前に行うのが困難でした。

  1. GitHub のログはイベントごとに JSON スキーマが異なる
  2. ログ検索のユースケースが明確ではなかった

実は GitHub のログはイベントごとに JSON スキーマが異なるという特徴があります。
例えば、今回調べたrepo.destroypublic_key.createの JSON スキーマは、共通しているキーもあるものの異なる形を持っています。

repo.destroy と public_key.create のJSONスキーマの違い
repo.destroy
{
    "action":"repo.destroy",
    "actor":"<actor name>",
    "actor_id":<actor id>,
    "actor_ip":"<actor ip>",
    "user_agent":"<user agent>",
    "visibility":"<repo visibility>",
    "repo":"<repo name>",
    "repo_id":<repo id>,
    "public_repo":<is public>,
    "org":"<org name>",
    "org_id":<org id>,
    "_document_id":"<document id>",
    "@timestamp":<timestamp>,
    "created_at":<timestamp>,
    "operation_type":"<operation_type>",
    "business":"<business name>",
    "business_id":<business id>,
    "actor_location":{"country_code":"JP"}
}
public_key.create
{
    "action": "public_key.create",
    "actor": "<actor>",
    "actor_id": <actor_id>,
    "actor_ip": "<actor_ip>",
    "user_agent": "<user_agent>",
    "external_identity_nameid": "<external_identity_nameid>",
    "external_identity_username": "<external_identity_username>",
    "title": "<title>",
    "key": "<key>",
    "fingerprint": "<fingerprint>",
    "read_only": "<true>",
    "org": "<org>",
    "org_id": <org_id>,
    "repo": "<repo>",
    "repo_id": <repo_id>,
    "public_repo": <public_repo>,
    "_document_id": "<_document_id>",
    "@timestamp": <@timestamp>,
    "created_at": <timestamp>,
    "operation_type": "<operation_type>",
    "business": "<business>",
    "business_id": <business_id>,
    "actor_location": {"country_code": "JP"}
}

イベントログによってJSONスキーマが異なる以上、すべてのイベントログに対して汎用的に使える Athena テーブルを事前定義するのは困難です。(※イベントをいつ誰が起こしたか、くらいであれば可能)

となるとそれぞれのイベントに特化した Athena テーブルを事前に定義する必要がありますが、今回は検索ユースケースが定まっていません。

そこで、

  1. 一度JSONスキーマを調べる
  2. それから Athena テーブルを作る
  3. クエリする

という 3 つのステップを行うことで、どんなユースケースでも対応できる汎用的なログ検索を実現しました。

2. テーブルとビューが存在する理由

これはイベントのJSONスキーマを調べるにあたり必要だったから、というのが答えになります。

ログ検索のためにはまずイベントの JSON スキーマを知る必要がありますが、GitHub のドキュメントを見てもイベントの JSON スキーマを定義したページは見当たりませんでした。

ただし、こちらの GitHub ドキュメントからイベント名を知ることは可能で、幸いなことに、S3に保存されているどのイベントログの JSON スキーマにも action は含まれるため、イベント名を条件にしてJSONスキーマを取り出すことができる Athena テーブルを事前定義すれば良さそうだと考えました。

action in event_log event_log
repo.destroy {"action":"repo.destroy", ...}

2つのカラムが存在し、1つはイベントログにあるactionを取り出したもの、もう1つはイベントログそのまま、というテーブルです。

このテーブルを実現するために、以下の方法でテーブルとビューの両方を作成することにしました。

  1. イベントログのJSONをそのまま持つ Athena テーブルを作成する
  2. そこから json_extract_scalar を用いて action を抜き出しつつ、JSONそのままも使う Athena ビューを作成する

これがテーブルとビューの両方が存在する理由です。

3. ROW FORMAT SERDEの使い分け

この記事では以下2種類の ROW FORMAT SERDEが登場しています。

  • ROW FORMAT SERDE org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe
  • ROW FORMAT SERDE org.openx.data.jsonserde.JsonSerDe

ROW FORMAT SERDE は Athena が S3 にある データをデシリアライズする際の形式を指定するオプションです。

前者の LazySimpleSerDe は S3 にあるデータを1行ずつそのまま読み取ります。
後者の JsonSerDe は S3 にあるデータを JSON として読み取ります。

GitHub の Audit log ファイルには 1 個以上のイベント (つまり JSON )が存在しており、1行 1JSONとなっています。

example.log.gz
{"key":"value", ...}
{"key":"value", ...}
...

JSONスキーマを調べるための Athena テーブルには JSON が丸ごと欲しいため、LazySimpleSerDe を指定して文字列として行を読み取りました。

JSONの中の個別のキーを扱いたいテーブルには、JsonSerDeを指定しました。

詳しくは公式ドキュメントをご参照ください。
https://docs.aws.amazon.com/ja_jp/athena/latest/ug/json-serde.html

4. パーティションでコストを下げる

テーブル定義を見るとTBLPROPERTIESにたくさんのプロパティが指定されていますが、これらのほとんどはパーティションに関するオプションです。

Athena でクエリを実行すると、デフォルトではS3に対してフルスキャンが走ってしまい、時間と料金の両方のコストがかかってしまいます。

そこで、パーティションを利用して検索対象を狭めます。Athena のパーティションは S3オブジェクトのパス に対して設定することが可能です。

GitHub Enterprise Cloud の監査ログは yyyy/MM/dd/HH/mm/<uuid>.json.gz形式で出力されるので、日付でパーティションを区切ると良さそうだと思いました。

ただ、 Athena のパーティション機能はロードが行われた時点で存在するパスにしか効かないため、定期的なロードが必要になります。どういうことかというと、2024/01/25/10 時点でロードを行ったとして、その後に当時存在しなかった2024/01/26/03 を WHERE 句で指定した場合には、パーティションにメタデータが存在しないと判断されてフルスキャンが走ってしまう、ということです。

これを避けるためには定期的なパーティション情報のロード作業が必要です。

ただし、パーティション分割されたテーブルをクエリする前に、パーティション情報で AWS Glue データカタログを更新する必要があります
https://docs.aws.amazon.com/ja_jp/athena/latest/ug/tables-location-format.html#table-location-and-partitions

ロードを定期的に行うソリューションも存在しますが、今回はそちらではなく パーティション射影 という機能を使うことにしました。

https://docs.aws.amazon.com/ja_jp/athena/latest/ug/partition-projection.html

パーティション射影とは、予め指示した情報(パーティションと見なしたいS3パスの位置、型、範囲など)に従って Athena がクエリ実行時にパーティションを決定してくれる仕組みです。

ファイルのS3パスが一定の規則に従っているなら、パーティション射影を使うことができます。

先ほど、監査ログはs3://<S3_BUCKET_NAME>/yyyy/MM/dd/hh/mm/<uuid>.json.gzのファイル形式で出力されることが分かっているため、 yyyy/MM/dd/HH の年〜時間までの部分をパーティションとして認識してくれるように設定を行いました。

DDLの一部
TBLPROPERTIES (
  'projection.enabled' = 'true',
  'projection.date.type' = 'date',
  'projection.date.format' = 'yyyy/MM/dd/HH',
  'projection.date.range' = '2023/01/01/01,NOW',
  'projection.date.interval' = '1',
  'projection.date.interval.unit' = 'HOURS',
  'storage.location.template' = 's3://<YOUR_S3_PATH>/${date}/'
    # ${date}は置き換えずにそのままお使いください
)

5. クエリ請求金額に保険をかける

パーティション射影によってクエリ範囲を狭めることができますが、それはあくまでパーティションがうまく決定されるようなクエリを書いた場合です。dateをWHERE句で指定するのを忘れると、あっという間にフルスキャンになります。

そのため、顔が溶けないようにクエリ請求金額に保険をかけておくと良いです。Amazon Athena にはスキャンされるデータ量に制限をかけることができます。

https://docs.aws.amazon.com/ja_jp/athena/latest/ug/workgroups-setting-control-limits-cloudwatch.html

終わりに

GitHub Enterprise Cloud の監査ログを Amazon Athena でクエリする方法を書きました。

S3 で長期保管している監査ログに対し、クエリ方法は準備しておきたいけど明確なユースケースまではない、というときに使える(しかもランニングコストがかからない)ので、参考になればと思います。

最後までお読みいただきありがとうございました。

サイボウズ 生産性向上チーム 💪

Discussion