Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
### Added

- [OpenAPI] Add `openapi:validate` Rake task for OpenAPI tags validation (#163).
- [Logger] Add `config.filter_parameters` for filtering structured log context.

### Fixed

Expand Down
35 changes: 35 additions & 0 deletions lib/rage/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,25 @@ def log_context
def log_tags
@log_tags ||= LogTags.new
end

# Allows configuring case-insensitive partial matches for filtering structured log context keys.
# Matching keys will have their values replaced with `"[FILTERED]"` before the log entry is written.
# @return [Rage::Configuration::FilterParameters]
#
# @example Filter common secrets from structured logs
# Rage.configure do
# config.filter_parameters = [:password, :token, :secret]
# end
def filter_parameters
@filter_parameters ||= FilterParameters.new
end

# Replace the current list of filtered log parameter keys.
# @param parameters [String, Symbol, Array<String, Symbol>, nil] one or more parameter filters
def filter_parameters=(parameters)
@filter_parameters = FilterParameters.new
@filter_parameters << parameters if parameters
end
# @!endgroup

# @!group Telemetry Configuration
Expand Down Expand Up @@ -411,6 +430,18 @@ def validate_input!(obj)
end
end

class FilterParameters < LogContext
private

def validate_input!(obj)
if obj.is_a?(Array)
obj.each { |item| validate_input!(item) }
elsif !obj.is_a?(String) && !obj.is_a?(Symbol)
raise ArgumentError, "filter parameters have to be strings, symbols, or arrays of strings and symbols"
end
end
end

class ErrorReporters
# @private
def initialize
Expand Down Expand Up @@ -1278,6 +1309,10 @@ def __finalize(before_boot = false)
@logger.dynamic_tags = Rage.__log_processor.dynamic_tags
end

if @filter_parameters
@logger.filter_parameters = @filter_parameters.objects
end

if before_boot && @blocking_operation_pool&.enabled
if defined?(Rage::FiberScheduler::BlockingOperationWait)
Iodine.on_state(:pre_start) { puts "INFO: Using blocking operation pool" }
Expand Down
69 changes: 65 additions & 4 deletions lib/rage/logger/logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,13 @@ class Rage::Logger
fatal: Logger::FATAL,
unknown: Logger::UNKNOWN
}
FILTERED = "[FILTERED]"
private_constant :FILTERED

attr_reader :level, :formatter

# @private
attr_reader :dynamic_tags, :dynamic_context
attr_reader :dynamic_tags, :dynamic_context, :filter_parameters

# @private
attr_reader :external_logger
Expand Down Expand Up @@ -132,6 +134,29 @@ def dynamic_context=(dynamic_context)
rebuild!
end

# @private
def filter_parameters=(filter_parameters)
if filter_parameters&.any?
normalized_filters = filter_parameters.filter_map do |filter|
filter = filter.to_s.downcase
filter unless filter.empty?
end.uniq

if normalized_filters.any?
@filter_parameters = normalized_filters.freeze
@filter_parameters_matcher = Regexp.union(@filter_parameters)
else
@filter_parameters = nil
@filter_parameters_matcher = nil
end
else
@filter_parameters = nil
@filter_parameters_matcher = nil
end

rebuild!
end

# Add custom keys to an entry.
#
# @param context [Hash] a hash of custom keys
Expand Down Expand Up @@ -196,7 +221,7 @@ def #{level_name}(msg = nil, context = nil)
RUBY
elsif @external_logger.is_a?(External::Static)
# an object that implements Ruby's Logger interface is used as a logger
write_call = <<~RUBY
write_call = build_filtered_context_call(<<~RUBY)
@external_logger.wrapped.#{level_name}(
#{build_formatter_call(level_name, level_val)}
)
Expand Down Expand Up @@ -233,7 +258,7 @@ def #{level_name}(msg = nil, context = nil)
request_info: "Fiber[:__rage_logger_final].freeze"
})

write_call = <<~RUBY
write_call = build_filtered_context_call(<<~RUBY)
@external_logger.wrapped.call(#{parameters})
RUBY

Expand All @@ -253,7 +278,7 @@ def #{level_name}(msg = nil, context = nil)
end
RUBY
else
write_call = <<~RUBY
write_call = build_filtered_context_call(<<~RUBY)
@logdev.write(
#{build_formatter_call(level_name, level_val)}
)
Expand Down Expand Up @@ -296,6 +321,42 @@ def build_formatter_call(level_name, level_val)
end
end

def build_filtered_context_call(write_call)
return write_call unless @filter_parameters

<<~RUBY
with_filtered_context do
#{write_call}
end
RUBY
end

def with_filtered_context
context = Fiber[:__rage_logger_context]
return yield if context.nil? || context.empty?

Fiber[:__rage_logger_context] = filter_value(context)
yield
ensure
Fiber[:__rage_logger_context] = context
end

def filter_value(value)
if value.is_a?(Hash)
value.each_with_object({}) do |(key, nested_value), filtered|
filtered[key] = if @filter_parameters_matcher.match?(key.to_s.downcase)
FILTERED
else
filter_value(nested_value)
end
end
elsif value.is_a?(Array)
value.map { |item| filter_value(item) }
else
value
end
end

def with_dynamic_tags_and_context
do_calls, end_calls = [], []

Expand Down
64 changes: 64 additions & 0 deletions spec/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,70 @@ def call
end
end

describe "#filter_parameters" do
subject { described_class.new.filter_parameters }

describe "#push" do
it "returns empty array" do
expect(subject.objects).to eq([])
end

it "allows to add strings and symbols" do
subject << :password << "token"
expect(subject.objects).to eq([:password, "token"])
end

it "allows to add arrays" do
subject << [:password, "token"]
expect(subject.objects).to eq([:password, "token"])
end

it "removes duplicates" do
subject << :password << [:password, "token"]
expect(subject.objects).to eq([:password, "token"])
end

it "raises an error for invalid objects" do
expect {
subject << proc {}
}.to raise_error(ArgumentError)
end
end

describe "#filter_parameters=" do
let(:config) { described_class.new }

it "replaces current filters" do
config.filter_parameters << :password
config.filter_parameters = [:token]

expect(config.filter_parameters.objects).to eq([:token])
end

it "accepts nil" do
config.filter_parameters = nil
expect(config.filter_parameters.objects).to eq([])
end
end

describe "#__finalize" do
let(:config) { described_class.new }
let(:logger) { Rage::Logger.new(nil) }

before do
config.logger = logger
end

it "passes filters to logger" do
config.filter_parameters = [:password, "token"]

config.__finalize

expect(logger.filter_parameters).to eq(["password", "token"])
end
end
end

describe "#logger" do
subject { described_class.new }

Expand Down
87 changes: 87 additions & 0 deletions spec/logger_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,35 @@
end
end

context "with filtered parameters" do
before do
subject.filter_parameters = [:password, :token]
end

it "filters partial key matches case-insensitively" do
subject.info "passed", password_confirmation: "secret", "AUTH_TOKEN" => "abc", user_id: 12345

expect(io.tap(&:rewind).read).to eq("[my_test_tag] timestamp=very_accurate_timestamp pid=777 level=info password_confirmation=[FILTERED] AUTH_TOKEN=[FILTERED] user_id=12345 message=passed\n")
end

it "filters request log context" do
Fiber[:__rage_logger_final] = {
env: { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/test_path" },
params: { controller: "rspec", action: "index" },
response: [200, {}, []],
duration: 1.45
}

stub_const("RspecController", double(name: "RspecController"))

subject.with_context(password: "secret") do
subject.info nil
end

expect(io.tap(&:rewind).read).to eq("[my_test_tag] timestamp=very_accurate_timestamp pid=777 level=info method=GET path=/test_path controller=RspecController action=index password=[FILTERED] status=200 duration=1.45\n")
end
end

context "outside the request/response cycle" do
before do
Fiber[:__rage_logger_tags] = nil
Expand Down Expand Up @@ -646,6 +675,64 @@
end
end

context "with filter parameters" do
before do
subject.filter_parameters = [:password, :token]
end

it "filters nested context recursively without mutating original values" do
inline_context = {
user: { email: "user@example.com", password_confirmation: "secret" },
sessions: [{ "AUTH_TOKEN" => "abc123" }, { name: "browser" }]
}

subject.dynamic_context = proc { { api_token: "global-secret" } }

expect(external_logger).to receive(:call).with(
severity: :info,
tags: ["my_test_tag"],
context: {
api_token: "[FILTERED]",
user: { email: "user@example.com", password_confirmation: "[FILTERED]" },
sessions: [{ "AUTH_TOKEN" => "[FILTERED]" }, { name: "browser" }]
},
message: "test",
request_info: nil
)

subject.info "test", inline_context

expect(inline_context).to eq({
user: { email: "user@example.com", password_confirmation: "secret" },
sessions: [{ "AUTH_TOKEN" => "abc123" }, { name: "browser" }]
})
end

it "leaves request_info untouched" do
Fiber[:__rage_logger_final] = {
env: { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/test_path" },
params: { password: "secret" },
response: [200, {}, []],
duration: 1.45
}

expect(external_logger).to receive(:call).with(
severity: :info,
tags: ["my_test_tag"],
context: { api_token: "[FILTERED]" },
message: nil,
request_info: {
env: { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/test_path" },
params: { password: "secret" },
response: [200, {}, []],
duration: 1.45
}
)

subject.info nil, api_token: "secret"
end
end

context "with external logger as an instance" do
let(:external_logger_class) do
Class.new do
Expand Down