giftee Tech Blog

ギフティの開発を支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信しています。

Go のログ出力から秘匿情報を守ろう

eyecatch

こんにちは、ギフティでエンジニアをやっている中屋(@nakaryo79)です!

はじめに

アプリケーションを運用する中で、ログ出力はトラブルシューティングや監視に欠かせない存在です。 しかし、ログに API キーや個人情報などの秘匿情報が含まれてしまうと、重大な情報漏洩のリスクを引き起こします。

そのため、秘匿情報はログ出力時に適切に除外するか、マスキング処理を施す必要があります。

秘匿情報をマスキングする手法

秘匿情報をマスキングする手法として、主に以下の3つのアプローチがあります。

  1. アプリケーションコードの各ログ出力箇所で個別に対応
    • ログ出力時に手動で文字列を差し替える、または秘匿情報を出力対象から除外
    • 愚直ではあるが故に実装者が毎回マスキング処理を意識する必要があり、うっかりミスのリスクが高い
  2. アプリケーションコードでマスキング用のデータ型を定義
    • 秘匿情報用の専用型を作成し、ログ出力時に自動的にマスキング
    • 型を適切に指定すれば漏れが出にくいが、型情報が消えてしまうような場面ではどうしても漏れてしまう
  3. ログ収集基盤側でマスキング処理を実装
    • AWS CloudWatch Logs のようなログ収集サービスの機能を利用
    • パターンマッチングベースだと定義が難しく漏れが出やすい一方、機械学習ベースだと誤検知(偽陽性)や見逃し(偽陰性)のリスクがある

どの方法にもメリット・デメリットがあるため、通常これらの手法を複数組み合わせて用いて対応することになります。

この記事では、Go のログ出力において、2 の手法を用いてマスキング用のデータ型を定義する方法を紹介します。

マスキングするためのデータ型を定義する

Go では、fmt パッケージを使用して出力を行う際に、Stringer InterfaceGoStringer Interface を実装することで、Print 系関数での出力時の文字列表現をカスタマイズすることができます。

fmt パッケージの説明には以下のように書いてあります。

  1. If an operand implements method String() string, that method will be invoked to convert the object to a string, which will then be formatted as required by the verb (if any).

使用イメージは以下のようになります。

type X string

func (x X) String() string { return strings.ToUpper(string(x)) }

func main() {
    fmt.Println(X("sample"))
}
// => SAMPLE

この仕組みを利用して、秘匿情報を扱うための専用の型を定義し、ログ出力時に自動的にマスキング処理を行うことができます。

package secret

// SecretInfo はログに残したくないセンシティブなデータ(個人情報など)にマスクをかけるための型です。
// fmt パッケージによる %s や %v での出力時に自動で値をマスキングします。
type SecretInfo string

const filteredText = "[filtered]"

func (SecretInfo) String() string {
    return filteredText
}

func (SecretInfo) GoString() string {
    return filteredText
}

func (i SecretInfo) Val() string {
    return string(i)
}

秘匿情報を扱う時は以下のように、SecretInfo 型のオブジェクトとして取り回します。

secretInfo := secret.SecretInfo("secret_value")

fmt.Println(secretInfo)
// => [filtered]

// 生の値を使いたい時は `Val()` メソッドを使います。
fmt.Println(secretInfo.Val())
// => secret_value

このように、SecretInfo 型でラップされた値は、fmt.Printf, fmt.Println などで出力すると自動的に [filtered] と表示され、元の文字列はログに残りません。

上記の例では fmt パッケージを直接使ってログ出力していますが、slogzap などのライブラリのロギング関数も内部的には fmt パッケージを使っているため、基本的には同様に機能します。

こうしておくことで、ログ出力時にいちいち秘匿情報をマスキングする処理を書く必要がなくなります。

構造体フィールドでの使い方

構造体のフィールドとして使う場合の注意点や応用もあります。

構造体の exported されていないフィールドは fmt による出力で無視される(型情報が参照できない)ため、マスキングを有効にするには export しておく必要があります。

When printing a struct, fmt cannot and therefore does not invoke formatting methods such as Error or String on unexported fields.

type User struct {
    PhoneNumber secret.SecretInfo // ← export しないとマスキングが効かない
}

user := User{
    PhoneNumber: secret.SecretInfo("090-1234-5678"),
}
fmt.Printf("%+v\n", user)
// => {PhoneNumber:[filtered]}

もし別の型名を使用したい場合は type alias を使って定義します。

type userPhoneNumber = secret.SecretInfo

type User struct {
    PhoneNumber userPhoneNumber
}

ただし、type alias では新たなメソッドを定義できないので、振る舞いを持たせたい場合は構造体でラップする必要があります。

type userPhoneNumber struct {
    Value secret.SecretInfo
}

func (n userPhoneNumber) trimHyphen() string {
    return strings.ReplaceAll(string(n.Value), "-", "")
}

JSON 形式でログ出力する際の注意

Go の標準ライブラリ slog を使っている場合、JSONHandler を使うと簡単に JSON フォーマットでログを出力することができます。

しかし、JSON 形式のログ出力を行う場合、文字出力のフォーマットよりも先に JSON マーシャルが実行され、そこで型情報が消えてしまいます(つまり、SecretInfo 型が機能しない)。

user := User{
  PhoneNumber: secret.SecretInfo("090-1234-5678"),
}

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("msg", "user", user)
//  => {"time":"2025-01-01T00:00:00.000000+09:00","level":"INFO","msg":"msg","user":{"PhoneNumber":"090-1234-5678"}}

それを避けるために、SecretInfo 型に Marshaler Interface を実装しておきます。

import "encoding/json"

func (SecretInfo) MarshalJSON() ([]byte, error) {
    return json.Marshal(filteredText)
}

これで、JSON マーシャル時に値の差し替えが行われ、JSON ログにおいても SecretInfo の値は "[filtered]" として出力されます。

ただし、ログ出力時以外のマーシャル時にもマスキングされてしまうため、他の箇所でマーシャルされる場合は注意が必要です。

この方法でもログ出力時の意識をゼロにできるわけではない

データを構造体として取り回しているうちは良いのですが、処理によっては構造体を完全な文字列に変換して扱いたい場合があります。

例えば、DB のクエリ実行時や、HTTP リクエスト/レスポンスボディの生成時などはデータを一度文字列あるいはバイト列に変換して扱うことが多いです。 このように、完全に文字列化された情報をログ出力する場合、当然ですがこの方法ではマスキングできません。

よって、そのようなデータをログを出力したい場合は秘匿情報が含まれていないか都度注意する必要があります。

よもやま

ここで紹介した手法はどのようなロガーを使っていても適用できます。

slog に限った話だと、以下のような手法もあるらしいです。

zenn.dev

終わりに

秘匿情報をログに出力しないための対策は、セキュリティ上の必須事項です。Go では、比較的簡単にマスキング用のカスタム型を定義し、安全なログ出力を実現できます。

実装の手間は多少ありますが、うっかりミスによる情報漏洩のリスクを大きく減らすことができるため、開発初期の段階で導入しておきましょう!