環境変数由来のシークレット値を、うっかりログに出力させてしまったことはないでしょうか。
type Config struct { Port int `env:"PORT" envDefault:"8080"` DatabaseURL string `env:"DATABASE_URL"` SMTPHost string `env:"SMTP_HOST"` SMTPPassword string `env:"SMTP_PASSWORD"` StripeAPIKey string `env:"STRIPE_API_KEY"` SessionSecret string `env:"SESSION_SECRET"` }
例えばこういう設定値をまとめた Config 構造体があったとして、アプリケーションの起動時にログを出して確認することはないでしょうか?
設定の構造体には、データベースの接続文字列や外部 API のキーのフィールドがあるかもしれません。
slog.Any() に渡してしまうと、そうしたフィールドも含めて出力されてしまいます。
slog.Info("config loaded", slog.Any("config", config)) // ※ 記事中に取り上げているシークレット値は全てダミーです // {"level":"INFO","msg":"config loaded","config":{"Port":8080,"DatabaseURL":"postgres://app:hunter2@db.internal:5432/app","SMTPHost":"smtp.example.com","SMTPPassword":"sup3r-s3cret","StripeAPIKey":"sk_live_51H8xQ2eZvKYlo2C","SessionSecret":"c2Vzc2lvbi1zZWNyZXQ"}}
「環境変数をログ出力しない」という規約で対処できるかもしれませんが、違反を検出することは難しいです。また、そうした場合でも環境変数由来ではないシークレット値 (例えば OAuth 2 のトークン等) には無防備です。
自家製のアプリケーションでこの種の問題を型で防ぐ仕組みを入れたので、その設計と落とし穴を紹介します。

シークレット値をラップする型
シークレット値を string と区別したいだけなら、Go では type Token string と書けば十分です。しかし、defined type では目的を達成できません。例えば fmt.Println(token) は平文を出力してしまうため、値を隠すのに使えません。
したがって、値を適切に隠すには、構造体で包んでフィールドを unexported にします。まずはファクトリー関数ともとの平文を返すメソッドを実装しておきます。
package sensitive type String struct { value string } func NewString(value string) String { return String{value: value} } // Plaintext はもとの平文を返す。 func (s String) Plaintext() string { return s.value }
平文を格納するフィールドは unexported なので、別パッケージから平文を取り出す方法は Plaintext() に限られます。
追記: ちなみに、文字列の代わりにクロージャを持つ設計*1もありえそうです。
同僚の
id:walnuts1018 に教えてもらいました。ありがとうございます。
type StringAlt struct { value func() string } func NewStringAlt(value string) StringAlt { return StringAlt{ value: func() string { return value }, } }
fmt.Stringer を実装する
これだけでシークレット値が不用意に出力される事故を防ぐことはできているのですが、この構造体が print されるときにはマスクされていることを伝えたいです。まずは最も簡単な String() を実装します。
func (s String) String() string { return "[REDACTED]" } var _ fmt.Stringer = new(String)
これでシンプルに print する分には [REDACTED] が表示されます。
fmt.GoStringer を実装する
GoString() メソッドを実装すると、fmt の書式指定子で %#v を使用しても [REDACTED] が表示されるようにできます。
func (s String) GoString() string { return "[REDACTED]" } var _ fmt.GoStringer = new(String)
fmt.Formatter を実装する
ここまで実装しても露出してしまうケースがありました。fmt で誤った書式指定子を使われたときです。
fmt が String() を使用するのは %v や %s などの書式指定子です。一方で数値を期待する %d を指定すると、次のような形で平文が出てしまいます*2。
password := sensitive.NewString("SUPER-SECRET-PASSWORD") fmt.Printf("%d\n", password) // {%!d(string=SUPER-SECRET-PASSWORD)}
誤った書式指定子は go vet や staticcheck が検出できます。ただし、引数が any であったり書式指定子が定数ではなかったりする場合は、原理的に検出できません。
そこで、fmt.Formatter を実装します。fmt.Formatter は fmt において最も優先されるため*3、書式指定子をオーバライドすることができます。
func (s String) Format(f fmt.State, verb rune) { fmt.Fprint(f, "[REDACTED]") } var _ fmt.Formatter = new(String)
これで %d のような誤った書式指定子でも無事に [REDACTED] が表示されます。
slog.LogValuer を実装する
当初はこれで十分だと思っていたのですが、JSON ハンドラを使う slog ロガーに sensitive.String を渡したところ、次のような結果になりました。
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) password := sensitive.NewString("SUPER-SECRET-PASSWORD") logger.Info("hoge", slog.Any("password", password)) // {"level":"INFO","msg":"hoge","password":{}}
平文こそ出力されていませんが、空オブジェクトが出力され、期待した [REDACTED] が表示されませんでした。
JSON ハンドラの実装を見てみると、slog.Any() に渡された値の出力は次のようになっていました*4。
slog.LogValuerであればLogValue()を呼び出すjson.MarshalerであればMarshalJSON()を呼び出すerrorであればError()を呼び出す- それ以外は
encoding/jsonでエンコードする
というわけで、fmt.Stringer は使用されず、encoding/json は unexported なフィールドを無視するので、この構造体は {} になります。
したがって、LogValue() を実装します。
func (s String) LogValue() slog.Value { return slog.StringValue("[REDACTED]") } var _ slog.LogValuer = new(String)
これで {"level":"INFO","msg":"hoge","password":"[REDACTED]"} が出力されるようになりました。
ちなみに、LogValue() を実装せずとも Text ハンドラでは [REDACTED] を出力できていました。
*5
json.Marshaler を実装する
ここまでやってもまだ未解決のケースがあります。sensitive.String をフィールドに持つ構造体をまるごと slog.Any() に渡した場合です。
type User struct { Password sensitive.String } logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) user := &User{ Password: sensitive.NewString("SUPER-SECRET-PASSWORD"), } logger.Info("hoge", slog.Any("user", user)) // {"level":"INFO","msg":"hoge","user":{"Password":{}}}
先ほどの出力ロジックを思い出してもらうと、どれにも該当しない場合は encoding/json でエンコードされるようになっていました。リフレクションで辿られてきたときに sensitive.String がエンコードされるべき JSON 表現を決めてあげるために、json.Marshaler を実装してあげる必要があります。
func (s String) MarshalJSON() ([]byte, error) { return json.Marshal("[REDACTED]") } var _ json.Marshaler = new(String)
これで {"level":"INFO","msg":"hoge","user":{"Password":"[REDACTED]"}} が出力されるようになりました。
encoding.TextUnmarshaler を実装する
私は caarlos0/env を設定値の読み込みによく使っています。このライブラリで環境変数を構造体に流し込むには encoding.TextUnmarshaler の実装が必要です*6。
つまり、こういうメソッドを追加します。
func (s *String) UnmarshalText(text []byte) error { s.value = string(text) return nil } var _ encoding.TextUnmarshaler = new(String)
逆に、UnmarshalJSON() は実装していません。MarshalJSON() が [REDACTED] を返す以上、もし両者を往復させるともとの平文が失われるので、あえて非対称性を残しています。
冒頭の Config 構造体はこういったログが出力されるようになり、シークレット値だけ選択的にマスクされています。
{"level":"INFO","msg":"config loaded","config":{"Port":8080,"DatabaseURL":"[REDACTED]","SMTPHost":"smtp.example.com","SMTPPassword":"[REDACTED]","StripeAPIKey":"[REDACTED]","SessionSecret":"[REDACTED]"}}
それでも型で保証できないケース
ここまでやっても型で予防できないケースもあります。
ラッパー型が保証するのは、自分で型を上書きできるものだけです。よって、HTTP のヘッダ (http.Header) はどうしようもありません。
そもそも、こういった値を丸ごとログに出すのは避けた方がよさそうです。どうしてもデバッグに使用したい場合は、allow list 方式で User-Agent など無難な値だけを取り出すとよいでしょう。
まとめ
まとめると、結果はこういうことになります。
| 実装 | %s |
%#v |
%d (誤り) |
json.Marshal |
slog Text | slog JSON (値) | slog JSON (構造体のフィールド) |
|---|---|---|---|---|---|---|---|
素の string |
❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| ラップ型のみ | ❌ | ❌ | ❌ | {} |
❌ | {} |
{} |
+ fmt.Stringer |
✅ | ❌ | ❌ | {} |
✅ | {} |
{} |
+ fmt.GoStringer |
✅ | ✅ | ❌ | {} |
✅ | {} |
{} |
+ fmt.Formatter |
✅ | ✅ | ✅ | {} |
✅ | {} |
{} |
+ slog.LogValuer |
✅ | ✅ | ✅ | {} |
✅ | ✅ | {} |
+ json.Marshaler |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
- ✅:
[REDACTED]にマスクされる {}:空オブジェクトになる (平文は出ないが、意図した表示ではない)- ❌:平文が露出する
これで思いつく限りは sensitive.String の実装は完全体になりました。

このようにシークレット値をログに出さないという規約を、ラッパー型の仕組みにすることで実効性を持たせることができるようになりました。
*1:これであれば、reflect 経由で覗かれても関数のポインタのアドレスしか出現しないので、前述の文字列を持つ設計よりもセキュアです。一方で、構造体自体が comparable ではなくなるので、== による比較ができなくなったり map のキーに使えなくなったりなどのトレードオフもあります。また、マスクされたことを明示するためには引き続き各種 interface の実装が必要です。
*2: https://pkg.go.dev/fmt#hdr-Format_errors
*3:それでもなお、構造体の実体を %p でフォーマットする際のエラー表示として、reflect による構造体のダンプ表示で漏洩する場合があります。この場合は Format() が呼ばれず短絡しているようでした。これに対処するには構造体がシークレット文字列の間接参照を保持する設計が考えられます。これであればシークレット値へのポインタが漏洩するに限ります。
*4: Go 1.27.1: log/slog/json_handler.go
*5: Go 1.27.1: log/slog/text_handler.go。%+v で出力するため、fmt.Formatter ないし fmt.Stringer が使用されます。




