Railsのログ出力をフィルタする config.filter_parameters の仕組み
ログはとても重要ですが、パスワードやクレカ番号などの機密情報をログに出力しないように気をつけないといけません。 Railsでは config.filter_parameters とActiveSupport::ParameterFilter を用いてリクエストパラメータやカラムの値をフィルタリングしています。 この記事では、具体的にRailsがどのようにフィルタをしているか紹介します。(Ruby on Rails v7.0.5 で確認)
Railsのconfig.filter_parametersとは
Railsの config.filter_parameters
とは、パスワード、アクセストークン、クレジットカード番号など、ログに出力したくないパラメータやActiveRecordの値をフィルタで除外するために利用します。
デフォルトの設定値はconfig/initializers/filter_parameter_logging.rb
ファイル内で次のように設定されています。
# Be sure to restart your server when you modify this file.
# Configure parameters to be filtered from the log file. Use this to limit dissemination of
# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported
# notations and behaviors.
Rails.application.config.filter_parameters += [
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
]
フィルタ対象の判定は、正規表現の大文字小文字を区別しない部分一致によって行われます。
そして、マッチした値を[FILTERED]
という文字列に変換してログに出力します。
Railsのconfig.filter_parametersの挙動を確認する
フィルターの処理自体は、ActiveSupport::ParameterFilter
で実装されています。
以下のサンプルコードのように、文字列、シンボル、正規表現、Procを渡すことができます。
また、完全一致やネストした値に対してもフィルターをすることができます。
# /password/i にマッチする値を "[FILTERED]" に置き換える
# ※iは正規表現で大文字小文字を区別しないオプション
parameter_filter = ActiveSupport::ParameterFilter.new([:password])
parameter_filter.filter({ email: 'john@example.com', password: 'password '})
#=> {:email=>"john@example.com", :password=>"[FILTERED]"}
# /foo|bar/i にマッチする値を "[FILTERED]" に置き換える
parameter_filter = ActiveSupport::ParameterFilter.new([:foo, "bar"])
parameter_filter.filter({ foo: 'foo', bar: 'bar', baz: 'baz', foooo: 'foooo' })
#=> {:foo=>"[FILTERED]", :bar=>"[FILTERED]", :baz=>"baz", :foooo=>"[FILTERED]"}
# "pin"に完全一致と"pin_"で始まる文字列を"[FILTERED]"に置き換える
# \Aと\zは文字列の先頭と末尾を表す(=完全一致)
parameter_filter = ActiveSupport::ParameterFilter.new([/\Apin\z/, /\Apin_/])
parameter_filter.filter({ pin: '1234', pin_number: '1234', shipping_id: '1234' })
#=> {:pin=>"[FILTERED]", :pin_number=>"[FILTERED]", :shipping_id=>"1234"}
# "credit_card"にネストされた"code"を"[FILTERED]"に置き換える。他の"code"は置き換えない。
parameter_filter = ActiveSupport::ParameterFilter.new(["credit_card.code"])
parameter_filter.filter({ credit_card: { code: "xxxx" }, file: { code: "xxxx" }, code: "xxxx" })
# => {:credit_card=>{:code=>"[FILTERED]"}, :file=>{:code=>"xxxx"}, :code=>"xxxx"}
# /secret/i にマッチする値を逆順に置き換える
parameter_filter = ActiveSupport::ParameterFilter.new([-> (k, v) do
v.reverse! if /secret/i.match?(k)
end])
parameter_filter.filter({ secret: 'secret' })
#=> {:secret=>"terces"}
より詳細を知りたい場合は、ActiveSupport::ParameterFilterのドキュメントを確認ください。
特定のgemをいれるとRailsのconfig.filter_parametersがうまく反映されない
特定のGemをいれると、ActiveRecordの#inspect
時にconfig.filter_parameters
がうまく反映されないケースがあります。
この原因の1つとして、Gem側でActiveRecordのhookを利用せずに直接呼び出してしまうことで、Rails初期化時のconfig.filter_parameters
に値を設定するより前に、ActiveRecord
の#filter_parameters=
に空の値が設定されてしまうためです。
Rails起動時の本来の流れ
Rails.application.config.filter_parameters
の値をActiveRecordに設定するhookが登録される。該当コードconfig/initalizers/filter_parameter_logging.rb
が読み込まれ、Rails.application.config.filter_parameters
が設定される。ActiveRecord::Base
が読み込まれ、ActiveSupport.run_load_hooks(:active_record, Base)
が実行される。該当コード- 1で登録していたhookの処理が走り、
Rails.application.config.filter_parameters
がfilter_parameters=
に設定される。 ActiveRecord
のfilter_parameters
の値が入っているので、うまくフィルターされる。
gemが悪さをしているときの流れ
Rails.application.config.filter_parameters
の値をActiveRecordに設定するhookが登録される。該当コード- gemが読み込まれ、gem内から
ActiveRecord::Base
がロードされることで、ActiveSupport.run_load_hooks(:active_record, Base)
が実行される。 - 1で登録していたhookの処理が走り、空の
Rails.application.config.filter_parameters
がfilter_parameters=
に設定される。 config/initalizers/filter_parameter_logging.rb
が読み込まれ、Rails.application.config.filter_parameters
が設定される。ActiveRecord
のfilter_parameters
の値は空になってしまうので、うまくフィルターされない。
理想的な対応としては、gem側で直接ActiveRecord::Base
を呼ぶのではなく、以下のようにフックを登録するとよいです。それが難しい場合は、暫定対応でActiveRecord::Base#filter_attributes=
を任意のタイミングで呼び出すとよいかもしれません。
# Before: gemロード時にActiveRecordのhookの処理が走ってしまう
ActiveRecord::Base.include(SomeModule)
# After: Railsの初期化後にActiveRecordのhookの処理が走るようになる
ActiveSupport.on_load(:active_record) do
ActiveRecord::Base.include(SomeModule)
end
補足:Railsの初期化順序の参考記事: https://blog.freedom-man.com/rails-initializers
Railsのconfig.filter_parametersの実装をコードリーディングする
Railsのconfig.filter_parametersのより理解を深めるために実装コードを見てみます。
config.filter_parametersの値が使われている箇所は主に3箇所です。
- ActionDispatchのリクエストパラメータのフィルタ
- ActiveRecordのカラムの値をフィルタ
- ActionCableのリクエストのフィルタ(今回は説明を割愛)
まず、フィルター設定はconfig/initializers/filter_parameter_logging.rb
で Rails.application.config.filter_parameters
という変数にRailsの初期化時に設定されます。
# Be sure to restart your server when you modify this file.
# Configure parameters to be filtered from the log file. Use this to limit dissemination of
# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported
# notations and behaviors.
Rails.application.config.filter_parameters += [
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
]
ActionDispatchのリクエストパラメータをフィルタする実装のコードリーディング
ActionDispatch::Http::FilterParameters
内でActiveSupport::ParameterFilter
を利用してリクエストパラメータ(parameters
)をフィルターしています。
ActionDispatch::Http::FilterParameters
モジュールはActionDispatch::Request
クラスに利用しているのでrequest
はrequest.parameters
(フィルターされていないリクエストパラメータ)とrequest.filtered_parameters
(フィルターされたリクエストパラメータ)の両方を使い分けることができます。
# https://github.com/rails/rails/blob/v7.0.5/actionpack/lib/action_dispatch/http/filter_parameters.rb
module ActionDispatch
module Http
module FilterParameters
ENV_MATCH = [/RAW_POST_DATA/, "rack.request.form_vars"] # :nodoc:
NULL_PARAM_FILTER = ActiveSupport::ParameterFilter.new # :nodoc:
NULL_ENV_FILTER = ActiveSupport::ParameterFilter.new ENV_MATCH # :nodoc:
...
# Returns a hash of parameters with all sensitive data replaced.
def filtered_parameters
# 注釈: parametersにはリクエストパラメータが入っている
@filtered_parameters ||= parameter_filter.filter(parameters)
rescue ActionDispatch::Http::Parameters::ParseError
@filtered_parameters = {}
end
private
def parameter_filter # :doc:
# 注釈: env["action_dispatch.parameter_filter"] は [:foo, "bar"] のような値が入っている
parameter_filter_for fetch_header("action_dispatch.parameter_filter") {
return NULL_PARAM_FILTER
}
end
def parameter_filter_for(filters) # :doc:
ActiveSupport::ParameterFilter.new(filters)
end
...
enddd
ActionController::Instrumentation
のprocess_action(*)
内でrequest.filtered_parameters
を利用して ActiveSupport::Notifications.instrument("start_processing.action_controller", raw_payload)
を呼び出してイベントを作成しています。
# https://github.com/rails/rails/blob/v7.0.5/actionpack/lib/action_controller/metal/instrumentation.rb
def process_action(*)
ActiveSupport::ExecutionContext[:controller] = self
raw_payload = {
controller: self.class.name,
action: action_name,
request: request,
# NOTE: raw_payloadにフィルターしたパラメータを格納
params: request.filtered_parameters,
headers: request.headers,
format: request.format.ref,
method: request.request_method,
path: request.fullpath
}
# NOTE: raw_payloadを利用して"start_processing.action_controller"のイベントを作成
ActiveSupport::Notifications.instrument("start_processing.action_controller", raw_payload)
ActiveSupport::Notifications.instrument("process_action.action_controller", raw_payload) do |payload|
result = super
payload[:response] = response
payload[:status] = response.status
result
rescue => error
payload[:status] = ActionDispatch::ExceptionWrapper.status_code_for_exception(error.class.name)
raise
ensure
append_info_to_payload(payload)
end
end
そして、Actioncontroller::LogSubscriber#start_processing
でログを出力しています。
# https://github.com/rails/rails/blob/v7.0.5/actionpack/lib/action_controller/log_subscriber.rb
def start_processing(event)
return unless logger.info?
payload = event.payload
# NOTE: フィルターされたパラメータを利用
# INTERNAL_PARAMS = %w(controller action format _method only_path)
params = payload[:params].except(*INTERNAL_PARAMS)
format = payload[:format]
format = format.to_s.upcase if format.is_a?(Symbol)
format = "*/*" if format.nil?
info "Processing by #{payload[:controller]}##{payload[:action]} as #{format}"
info " Parameters: #{params.inspect}" unless params.empty?
end
ActiveRecordのカラムの値をフィルタする実装をコードリーディング
まず、ActiveRecord::Base
クラスが読み込まれたときに、Rails.application.config.filter_parameters
をfilter_attributes=
に設定してます。
# https://github.com/rails/rails/blob/v7.0.5/activerecord/lib/active_record/railtie.rb#L318-L322
initializer "active_record.set_filter_attributes" do
ActiveSupport.on_load(:active_record) do
self.filter_attributes += Rails.application.config.filter_parameters
end
end
filter_attributes=
の実装をみると、inspect_filter
を初期化しつつ、filter_attributes
に値をコピーしています。
filter_attributes
はinspection_filter
で利用されており、中身はActiveSupport::ParameterFilter
のインスタンスになっています。
# https://github.com/rails/rails/blob/v7.0.5/activerecord/lib/active_record/core.rb#L381-L396
# Specifies columns which shouldn't be exposed while calling +#inspect+.
def filter_attributes=(filter_attributes)
@inspection_filter = nil
@filter_attributes = filter_attributes
end
def inspection_filter # :nodoc:
if defined?(@filter_attributes)
@inspection_filter ||= begin
mask = InspectionMask.new(ActiveSupport::ParameterFilter::FILTERED)
# filter_attributesを利用して、ActiveSupport::ParameterFilterインスタンスを作成している
ActiveSupport::ParameterFilter.new(@filter_attributes, mask: mask)
end
else
superclass.inspection_filter
end
end
その状態で、ActiveRecord
で#inspect
が呼ばれると、各フィールドごとにattribute_for_inspect
メソッドを呼び出します。
# https://github.com/rails/rails/blob/v7.0.5/activerecord/lib/active_record/core.rb#L687-L701
# inspectの補足
# ActiveRecord#inspectでフィルタされたときの出力イメージ。tokenの値がフィルタされている。
# Customer.new(name: 'name', token: 'some-token')
#=> #<Customer:0x000000010bf999d8 id: nil, name: "name", token: "[FILTERED]", created_at: nil, updated_at: nil>
#
# Returns the contents of the record as a nicely formatted string.
def inspect
# We check defined?(@attributes) not to issue warnings if the object is
# allocated but not initialized.
inspection = if defined?(@attributes) && @attributes
self.class.attribute_names.filter_map do |name|
if _has_attribute?(name)
# 各カラムの値を出力するときにattribute_for_inspectが呼ばれる
"#{name}: #{attribute_for_inspect(name)}"
end
end.join(", ")
else
"not initialized"
end
"#<#{self.class} #{inspection}>"
end
attribute_for_inspect
では、実際の値を取得してformat_for_inspect
が呼ばれます。
# https://github.com/rails/rails/blob/v7.0.5/activerecord/lib/active_record/attribute_methods.rb#L283-L288
def attribute_for_inspect(attr_name)
attr_name = attr_name.to_s
attr_name = self.class.attribute_aliases[attr_name] || attr_name
value = _read_attribute(attr_name)
# NOTE: 値を取得した後にformat_for_inspectが呼ばれる
format_for_inspect(attr_name, value)
end
format_for_inspect
内でinspect_filter
を使ってフィルターをしています。これにより、ActiveRecordの出力内容がマスクされるようになっています。
# https://github.com/rails/rails/blob/v7.0.5/activerecord/lib/active_record/attribute_methods.rb#L407-L421
def format_for_inspect(name, value)
if value.nil?
value.inspect
else
inspected_value = if value.is_a?(String) && value.length > 50
"#{value[0, 50]}...".inspect
elsif value.is_a?(Date) || value.is_a?(Time)
%("#{value.to_fs(:inspect)}")
else
value.inspect
end
inspection_filter.filter_param(name, inspected_value)
end
end