🔏

Goでセキュアにロギングするzlog

2021/11/01に公開

TL; DR
Goで秘匿値をログに出力しないようにする zlog というロガーを作りました。
https://github.com/m-mizutani/zlog

以下、経緯や使い方の説明です。

背景:サーバーサイドにおけるロギングと秘匿値の問題

Webサービスを含む多くのサーバーサイドのサービスでは、サービスの挙動に関するログを出力・記録しておくのが一般的です。継続的にログを出力しておくことで、トラブルシューティングやデバッグ、セキュリティインシデントの対応や監査、性能改善の手がかりなどに活用することができます。ログに含まれる情報が多いほど問題を解決するための手がかりが増えるため、(限度はあるものの)なるべく多くの情報を掲載する、あるいは設定によって情報量を増やせるようにしておくと便利です。

しかし一方で、サーバーサイドで出力するのは望ましくない情報もあります。

  • 認証に利用される情報:パスワード、APIトークン、セッショントークンなど、それを使うことで別のユーザの権限を取得できるような情報です。よくあるミスとしてデバッグのために設定関連情報を出力した際、別サービスを利用するための認証情報も一緒に出力されるというケースや、サービスへのリクエストをそのままログに出力したらAPIのトークンが混ざっていた、というようなケースがあります。
  • 個人関連情報: 例えばサービスを利用するユーザの氏名や電話番号、メールアドレスといった情報が該当します。ユーザ情報を含むオブジェクトをログに出力しようとした際に、誤ってそういった個人情報が含まれるというケースがよくあるかと思います。

上記を説明の都合上、まとめて "秘匿値" と呼びます。CLIツールなどでも秘匿値が出力されるのもあまり良くはないですが、サーバーサイドだとログ管理・運用の観点でよりつらい状況が発生します。

  • 閲覧者が増える: サーバーサイドのログはトラブル対応などのために開発・運用をするメンバーの多くが閲覧できるようにしてあると便利です。ゆえに権限を広めに設定していることが多く、その場合はチームで運用していると複数人が閲覧できることになります。別のユーザになりすましができる認証情報を複数人が自由に閲覧できてしまうのはまずいですし、個人情報についても多くの法令や規制で「業務として個人情報を取り扱う必要最低限の人物のみが閲覧できるようにする」と制限されており、それを満たしていないとみなされる恐れがあります。
  • 長期間保存される: またログは監査やセキュリティ対応の観点から、長期間保持される傾向があります。例えばクレジットカード取扱事業者が遵守するPCI DSSでは監査証跡の履歴を1年以上保持するよう求めており、SANSのSIEM運用ガイドでも1年以上の保持が望ましいとしています[1]。CLI上で出力されたログと異なり長期間保持されてしまうため、誤って出力するようにしてしまうと、秘匿値が大量に蓄積していくことになります。
  • 部分的に削除するのが困難: もし誤って秘匿値がログに出力されていると気づけても、ログの管理運用の観点で、「秘匿値を含むログだけを削除する」というオペレーションがあまり想定されていない、またはできないようになっているケースも多いです。例えばAWSだと大量のログをS3にオブジェクトとして保管するのがベストプラクティスとされており、秘匿値がまざったものだけを部分的に除外するというのが困難になりがちです。また、監査の観点からあえて容易に変更や削除ができないようになっている(仮にできたとしてもどこかに履歴が残ってしまう)といった仕組みになっているログ保管・運用サービスやプロダクトも多い印象です。

ということでサーバーサイドで秘匿値をログに出力してしまうと様々な消耗が発生することになってしまうため、そもそも出力しないに越したことはありません。しかし前述したとおり、うっかりミスによって出力されてしまうというのは決して少なくないと思います(自分もまれによくやる)。特に構造体内でネストされたフィールドに何が含まれるのかを都度意識するのは困難であり、いちいち考えたくありません。

この課題に取り組んでいるツールの1つにblousonというOSSがあります。これはRuby on Railsにおいて secure_ prefixがついたフィールド名の値や、Exceptionに含まれるSQL文から該当する値を隠蔽する機能があります。自分は最近もっぱらGo言語で開発することが多いので、似たような機能をもったツールがGo言語でも使いたいなと考えていました。

zlogの実装

というわけで実装したのが zlog です。

Go言語は標準で log というロギング用のパッケージおよびロガーのインターフェースが用意されていますが、今回はそれとは全く別に実装しています。理由としてログレベルの概念が希薄、構造化ロギングに対応していないなどが挙げられます。特にサーバーサイドでの出力を考えると、例えばAWSのCloudWatch LogsやGCPのCloud LoggingなどではJSON形式でのログに対応しており、検索などもJSONのスキーマを利用できるため、なるべく構造化されたログを出力するのが望ましいです。

構造化ロギングに対応した既存のライブラリだと有名なものとしてzaplogruszerologなどが挙げられますが、いずれもこのような秘匿値を隠蔽するような機能は実装されていません[2]。その他ざっと探した感じだとあまり良さそうな実装も見当たらなかったため[3]、ないなら作るかの精神でzlogを実装しました。

荒削りではありますがzlogも他の構造化ロギングツールと同じような機能は実装しつつ、秘匿値を隠蔽できるような機能を盛り込んだ形になります。

秘匿値の隠し方

基本的には、ログに出力したい変数(構造体も可)を With() というchain methodに入力すると、Filters という配列にあるFilterが秘匿値が含まれているかどうかをチェックし、含まれていると判定されたフィールドは隠す、という動作になります。具体的には以下の5つのユースケースを想定しています。

特定の値を指定する

そのアプリケーション自身が外部サービスを呼び出すために使うAPIトークンなど、限定的かつ事前に決まっている秘匿値を隠すことを想定した機能です。これによって特定のフィールドを隠すだけでなく、fmt.Sprintf などの文字列操作によって入り込んだ値もある程度対応できることが期待されます。

const issuedToken = "abcd1234"
authHeader := "Authorization: Bearer " + issuedToken

logger := newExampleLogger()
logger.Filters = []zlog.Filter{
    filter.Value(issuedToken),
}
logger.With("auth", authHeader).Info("send header")
// Output:  [info] send header
// "auth" => "Authorization: Bearer [filtered]"

特定のフィールド名を指定する

指定した構造体のフィールド名に合致した場合に秘匿値を隠蔽します。完全一致だけでなく、 filter.FieldPrefix という前方一致のフィルタも用意しています。これによって blouson と同じようにフィールド名が Secure というprefixをもつ場合のみ隠蔽する、というような動作も実現可能です。

type myRecord struct {
	ID    string
	EMail string
}
record := myRecord{
	ID:    "m-mizutani",
	EMail: "mizutani@hey.com",
}

logger.Filters = []zlog.Filter{
	filter.Field("EMail"),
}
logger.With("record", record).Info("Got record")
// Output:  [info] Got record
// "record" => zlog_test.myRecord{
//   ID:    "m-mizutani",
//   EMail: "[filtered]",
// }

カスタムの定義型を指定する

Go言語は既存の型から独自の定義型を作ることができ、それを既存の方と使い方を区別するために利用することがあります。(例: contextパッケージのValueのキー)この仕組を利用するため秘匿したい型を定義し、それをFilterで指定することで表示されないようにできます。この方法のメリットは独自の型から元の型に値をコピーするにはキャストが必要になり、開発者が意図しないコピーに気づきやすくなるという点です。(もちろんキャストすればコピーできてしまうので万全ではないです)

この方法は複数の構造体間で秘匿値を使い回さないといけない、というようなユースケースで便利だと考えています。

type password string
type myRecord struct {
	ID    string
	EMail password
}
record := myRecord{
	ID:    "m-mizutani",
	EMail: "abcd1234",
}

logger.Filters = []zlog.Filter{
	filter.Type(password("")),
}
logger.With("record", record).Info("Got record")
// Output:  [info] Got record
// "record" => zlog_test.myRecord{
//   ID:    "m-mizutani",
//   EMail: "[filtered]",
// }

構造体のタグで指定する

特定のタグが指定された構造体のフィールドを隠蔽することができます。タグのキー名は zlog 固定で隠蔽する値は複数指定可能です。何も指定しないとデフォルトで secret が対象となります。

現状の実装を変えずに値を隠蔽したいが、対象となるフィールド名の管理をzlog側に集約させたくない、という場合に便利です。

type myRecord struct {
	ID    string
	EMail string `zlog:"secret"`
}
record := myRecord{
	ID:    "m-mizutani",
	EMail: "mizutani@hey.com",
}

logger.Filters = []zlog.Filter{
	filter.Tag(),
}
logger.With("record", record).Info("Got record")
// Output:  [info] Got record
// "record" => zlog_test.myRecord{
//   ID:    "m-mizutani",
//   EMail: "[filtered]",
// }

個人情報(らしきもの)を指定する

これは実験的な取り組みであまり確実な手法ではありませんが、一定の価値はあるかもしれません。多くのDLP (Data Leakage Protection) ソリューションのようにあらかじめ定義されたパターンに基づいて[4]出力するべきではない個人情報を検出・隠蔽する方法です。

以下の例では試しに日本の電話番号を検出するように記述してみたフィルタを利用しています。中身はただの正規表現[5]です。この方法については既存のDLPソリューションのように用意されたパターンが豊富なわけではなく、またパターンも十分に正確とは言えませんが、必要に応じて今後拡充させたいとは考えています。

type myRecord struct {
	ID    string
	Phone string
}
record := myRecord{
	ID:    "m-mizutani",
	Phone: "090-0000-0000",
}

logger.Filters = []zlog.Filter{
	filter.PhoneNumber(),
}
logger.With("record", record).Info("Got record")
// Output:  [info] Got record
// "record" => zlog_test.myRecord{
//   ID:    "m-mizutani",
//   Phone: "[filtered]",
// }

独自のフィルタを作成する

zlog.Filter インターフェースに従って実装したメソッドを使って、独自のフィルタを作成できます。必要なメソッドは以下の2つです。

  • ReplaceString(s string) string: 検査する値が string 型の場合にこのメソッドが呼び出されます。引数は検査する値、返り値は置き換えられる値になります。何もする必要がない場合は引数をそのまま返します。文字列の中から部分的に隠蔽したい、というケースを想定しています。
  • ShouldMask(fieldName string, value interface{}, tag string) bool: 検査する値のフィールド名(マップ型の場合はキー名)、値そのもの、そして構造体に zlog のタグがついていればその情報が引数として渡されます。返り値が false の場合は何もせず、 true だった場合はそのフィールドが全て隠蔽されます。隠蔽された値は元の型が string 型なら [filtered] という値に置き換えられ、それ以外の場合は空の値が入るようになります。

他の実装などとの比較

最後に他の実装との比較をして締めたいと思います。もし利用したいと思った方がいたら参考になれば幸いです。

Advantage

  • 様々な手法で値を隠蔽できるため、ユースケースに合わせた実装が可能になっています。
  • 例えば github.com/gyozatech/noodlog でも同様の機能を提供していますが、これはJSONに変換するのが前提となっており出力する段階で型の情報などが失われてしまいます。zlogは基本的に型を保ったまま隠蔽処理をするため、最終的な出力の形式も自由に選択できます。
  • 出力されたあとに検知するDLPソリューションと比較しても、保管されているログから秘匿値を削除するという手間が発生しないという利点があります。

Disadvantage

  • 表示する変数や構造体のデータを改変しても良いようにdeep copyするため、計算およびメモリの使用量は他のロガーに比べて劣ります。
    • そのためロギングが直接性能に影響を及ぼすようなサービスには不向きです
    • また巨大なデータを表示しようとすると大きな負荷をかけるおそれがあります
  • 実装がまだ枯れておらず、また現状で近代的なロガーの機能が完全に実装されているわけではありません。
脚注
  1. 企業における情報システムのログ管理に関する実態調査, 2016年, 独立行政法人情報処理推進機構
    技術本部 セキュリティセンター https://www.ipa.go.jp/files/000052999.pdf ↩︎

  2. logrusの場合、https://github.com/sirupsen/logrus/issues/1007 でzlogのような機能が提案されているがauthorによって棄却されています ↩︎

  3. 例えば https://github.com/gyozatech/noodlog などは志を同じくするツールだとは思ったのですが、JSON形式での出力が前提、対象がフィールド名に依存する(値が別の場所にコピーされると漏洩する)、隠蔽の処理が不十分などの観点から長期的に活用するのは難しそうと思い見送りました ↩︎

  4. AWS Macieのルール例Google WorkspaceのDLPでは対象となるデータの正規表現などが検知に利用されている ↩︎

  5. 電話番号に関する知識が浅いため、これで本当にすべての日本の電話番号が過不足なく網羅できている保証はありません ↩︎

Discussion