Rails のログで未入力の項目をフィルタリング対象外にする

この記事は [HR Tech Advent Calendar 2019](https://qiita.com/advent-calendar/2019/hrtech) 19日目のエントリです。

# TL;DR

- config.filter_parameters_logging には Proc も渡せる
- 未入力の項目が "[FILTERED]" にならないようにするには ActionDispatch::Http::ParameterFilter (Rails 6.x の場合は ActiveSupport::ParameterFilter) を上書く

# はじめに

HR 業界では、その性質上従業員の個人情報や思想、信条といった機微な情報を扱うことが多いです。
障害発生時には調査のため開発者が本番のログを閲覧することがありますが、そういった機微な情報をログからサルベージできないようにフィルタリングするのは割と一般的でしょう。

Rails では config.filter_parameters_logging がそれに当たります。

```ruby:config/initializers/filter_parameter_logging.rb
config.filter_parameters_logging += [ /password/, :some_attr_name ]
```

# 問題点

ある日、クライアントから「入力した覚えのないデータが登録されている」というお問い合わせがありました。
データベースを確認したものの、当該データは確かに登録されているという事実しかわからないので、ログを追ってみました。

すると、ログは残っていたのですが、肝心の入力値がフィルタリングされていて中身が確認できない・・・

```
Processing by XxxController#xxx as HTML
Parameters: {"some_attr1"=>"xxx", "some_attr2"=>"xxx", "some_attr3"=>"[FILTERED]"}
```

config.filter_parameters_logging は、項目に値が入力されていても、いなくても全部 "[FILTERED]" になってしまいます。
ユーザが何か入力したか否かは判別できません。

# 入力値が空の場合はフィルタリングしたくない

入力値が空("")の場合は "" のままログ出力しても良さそうなので [^1] 、実現方法を探しました。
このフィルタリングは ActionDispatch::Http::ParameterFilter で実装されています。
コードを見ると、config.filter_parameters_logging には文字列、正規表現の他に、任意の Proc も渡せることがわかったので、これで実現できるか試してみました。

```ruby:config/initializers/filter_parameter_logging.rb
config.filter_parameters_logging += [
  # Proc も設定できる
  lambda do |key, value|
    value.replace('[FILTERED]') if key == 'some_attr3' && value.present?
  end
]
```

[^1]: 項目の入力有無それ自体が機微な情報と判断されるケースでは、この実装は不適切です。セキュリティポリシーを確認しましょう。


String#replace で破壊的変更をしているのが若干気持ち悪いのですが、そういうものらしいです。

https://github.com/rails/rails/blob/39a8266a4c061d447b679f755dd1ed8485a9397f/actionpack/lib/action_dispatch/http/filter_parameters.rb#L11-L13

> If a block is given, each key and value of the params hash and all
> sub-hashes is passed to it, where the value or the key can be replaced using
> String#replace or similar method.

ログで確認してみます。

```
# 未入力のとき
Processing by XxxController#xxx as HTML
Parameters: {"some_attr1"=>"xxx", "some_attr2"=>"xxx", "some_attr3"=>""}

(snip)

# 入力したとき
Processing by XxxController#xxx as HTML
Parameters: {"some_attr1"=>"xxx", "some_attr2"=>"xxx", "some_attr3"=>"[FILTERED]"}
```

これで、入力したか否かは判別できるようになりました！

# が...駄目っ...!

このフィルタはモデルの inspect をしたときにも呼ばれます。
そのときに渡ってくる value は String 型とは限りません。

```
NoMethodError:
   undefined method `replace' for Wed, 11 Dec 2019 19:01:10 JST +09:00:Time
   Shared Example Group: "xxx" called from ./xxx/xxx/xxx.rb:37
```

つまり、value に対して一律で String#replace を適用できません。
型に応じて分岐・・・は、正直考えたくないので、仕方なく ParameterFilter の実装を上書くことにしました。

# 実装

結局、こんな感じで実装しました。
config.filter_parameters_logging は、これまでと変わらず文字列、正規表現だけを設定しています。

```ruby
# 参考)
# https://github.com/rails/rails/blob/5-2-stable/actionpack/lib/action_dispatch/http/parameter_filter.rb
class ActionDispatch::Http::ParameterFilter
  class CompiledFilter
    def call(original_params, parents = [])
      filtered_params = original_params.class.new

      original_params.each do |key, value|
        parents.push(key) if deep_regexps
        if value.blank?
          # [この分岐だけを追加]
          # マスクせずに、空であることをそのまま出力する。
        elsif regexps.any? { |r| key =~ r }
          value = FILTERED
        elsif deep_regexps && (joined = parents.join('.')) && deep_regexps.any? { |r| joined =~ r }
          value = FILTERED
        elsif value.is_a?(Hash)
          value = call(value, parents)
        elsif value.is_a?(Array)
          value = value.map { |v| v.is_a?(Hash) ? call(v, parents) : v }
        elsif blocks.any?
          key = key.dup if key.duplicable?
          value = value.dup if value.duplicable?
          blocks.each { |b| b.call(key, value) }
        end
        parents.pop if deep_regexps

        filtered_params[key] = value
      end

      filtered_params
    end
  end
end
```

# 補足
Rails 6.x から、ActionDispatch::Http::ParameterFilter は Deprecated になり ActiveSupport::ParameterFilter に移動しています。
今回上書いた箇所が value_for_key メソッドとして call から独立しているので、prepend を使えばもっと局所的な変更で済みそうです。

https://github.com/rails/rails/blob/6-0-stable/actionpack/lib/action_dispatch/http/parameter_filter.rb
https://github.com/rails/rails/blob/6-0-stable/activesupport/lib/active_support/parameter_filter.rb