Skip to content

Commit 0651e9e

Browse files
committed
Preserve Rails filter matchers and harden ParamFilters
1 parent 3c09e9c commit 0651e9e

File tree

7 files changed

+244
-17
lines changed

7 files changed

+244
-17
lines changed

lib/log_struct/concerns/configuration.rb

Lines changed: 122 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,20 +72,133 @@ def merge_rails_filter_parameters!
7272

7373
rails_filter_params = ::Rails.application.config.filter_parameters
7474
return unless rails_filter_params.is_a?(Array)
75+
return if rails_filter_params.empty?
76+
77+
symbol_filters = T.let([], T::Array[Symbol])
78+
matchers = T.let([], T::Array[ConfigStruct::FilterMatcher])
79+
leftovers = T.let([], T::Array[T.untyped])
80+
81+
rails_filter_params.each do |entry|
82+
matcher = build_filter_matcher(entry)
83+
84+
if matcher
85+
matchers << matcher
86+
next
87+
end
88+
89+
normalized_symbol = normalize_filter_symbol(entry)
90+
if normalized_symbol
91+
symbol_filters << normalized_symbol
92+
else
93+
leftovers << entry
94+
end
95+
end
96+
97+
if symbol_filters.any?
98+
config.filters.filter_keys |= symbol_filters
99+
end
75100

76-
# Convert all Rails filter parameters to symbols and merge with our filter keys
77-
converted_params = rails_filter_params.map do |param|
78-
param.respond_to?(:to_sym) ? param.to_sym : param
101+
if matchers.any?
102+
matchers.each do |matcher|
103+
existing = config.filters.filter_matchers.any? do |registered|
104+
registered.label == matcher.label
105+
end
106+
config.filters.filter_matchers << matcher unless existing
107+
end
79108
end
80109

81-
# Add Rails filter parameters to our filter keys
82-
config.filters.filter_keys += converted_params
110+
replace_filter_parameters(rails_filter_params, leftovers)
111+
end
112+
113+
private
114+
115+
sig { params(filter: T.untyped).returns(T.nilable(Symbol)) }
116+
def normalize_filter_symbol(filter)
117+
return filter if filter.is_a?(Symbol)
118+
return filter.downcase.to_sym if filter.is_a?(String)
83119

84-
# Ensure no duplicates
85-
config.filters.filter_keys.uniq!
120+
return nil unless filter.respond_to?(:to_sym)
121+
122+
begin
123+
sym = filter.to_sym
124+
sym.is_a?(Symbol) ? sym : nil
125+
rescue
126+
nil
127+
end
128+
end
129+
130+
sig { params(filter: T.untyped).returns(T.nilable(ConfigStruct::FilterMatcher)) }
131+
def build_filter_matcher(filter)
132+
case filter
133+
when ::Regexp
134+
callable = Kernel.lambda do |key, _value|
135+
filter.match?(key)
136+
end
137+
return ConfigStruct::FilterMatcher.new(callable: callable, label: filter.inspect)
138+
else
139+
return build_callable_filter_matcher(filter) if callable_filter?(filter)
140+
end
141+
142+
nil
143+
end
144+
145+
sig { params(filter: T.untyped).returns(T::Boolean) }
146+
def callable_filter?(filter)
147+
filter.respond_to?(:call)
148+
end
149+
150+
sig { params(filter: T.untyped).returns(T.nilable(ConfigStruct::FilterMatcher)) }
151+
def build_callable_filter_matcher(filter)
152+
callable = Kernel.lambda do |key, value|
153+
call_args = case arity_for_filter(filter)
154+
when 0
155+
[]
156+
when 1
157+
[key]
158+
else
159+
[key, value]
160+
end
161+
162+
result = filter.call(*call_args)
163+
!!result
164+
rescue ArgumentError
165+
begin
166+
!!filter.call(key)
167+
rescue => e
168+
handle_filter_error(e, filter, key)
169+
false
170+
end
171+
rescue => e
172+
handle_filter_error(e, filter, key)
173+
false
174+
end
175+
ConfigStruct::FilterMatcher.new(callable: callable, label: filter.inspect)
176+
end
177+
178+
sig { params(filter: T.untyped).returns(Integer) }
179+
def arity_for_filter(filter)
180+
filter.respond_to?(:arity) ? filter.arity : 2
181+
end
182+
183+
sig { params(filter_params: T::Array[T.untyped], leftovers: T::Array[T.untyped]).void }
184+
def replace_filter_parameters(filter_params, leftovers)
185+
filter_params.clear
186+
filter_params.concat(leftovers)
187+
end
86188

87-
# Clear Rails filter parameters since we've incorporated them
88-
::Rails.application.config.filter_parameters.clear
189+
sig { params(error: StandardError, filter: T.untyped, key: String).void }
190+
def handle_filter_error(error, filter, key)
191+
context = {
192+
filter: filter.class.name,
193+
key: key,
194+
filter_label: begin
195+
filter.inspect
196+
rescue
197+
"unknown"
198+
end
199+
}
200+
201+
LogStruct.handle_exception(error, source: Source::Internal, context: context)
89202
end
90203
end
91204
end

lib/log_struct/config_struct/filters.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33

44
module LogStruct
55
module ConfigStruct
6+
class FilterMatcher < T::Struct
7+
extend T::Sig
8+
9+
const :callable, T.proc.params(key: String, value: T.untyped).returns(T::Boolean)
10+
const :label, String
11+
12+
sig { params(key: String, value: T.untyped).returns(T::Boolean) }
13+
def matches?(key, value)
14+
callable.call(key, value)
15+
end
16+
end
17+
618
class Filters < T::Struct
719
include Sorbet::SerializeSymbolKeys
820

@@ -75,6 +87,12 @@ class Filters < T::Struct
7587
# Filter MAC addresses
7688
# Default: false
7789
prop :mac_addresses, T::Boolean, default: false
90+
91+
# Additional filter matchers built from Rails filter_parameters entries that aren't simple symbols.
92+
# Each matcher receives the key (String) and optional value, returning true when the pair should be filtered.
93+
prop :filter_matchers,
94+
T::Array[FilterMatcher],
95+
factory: -> { [] }
7896
end
7997
end
8098
end

lib/log_struct/formatter.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def process_values(arg, recursion_depth: 0)
6262
# Process each key-value pair
6363
arg.each do |key, value|
6464
# Check if this key should be filtered at any depth
65-
result[key] = if ParamFilters.should_filter_key?(key)
65+
result[key] = if ParamFilters.should_filter_key?(key, value)
6666
# Filter the value
6767
{_filtered: ParamFilters.summarize_json_attribute(key, value)}
6868
else

lib/log_struct/param_filters.rb

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
require "digest"
55
require_relative "hash_utils"
6+
require_relative "config_struct/filters"
7+
require_relative "enums/source"
68

79
module LogStruct
810
# This class contains methods for filtering sensitive data in logs
@@ -12,19 +14,30 @@ class << self
1214
extend T::Sig
1315

1416
# Check if a key should be filtered based on our defined sensitive keys
15-
sig { params(key: T.any(String, Symbol)).returns(T::Boolean) }
16-
def should_filter_key?(key)
17-
LogStruct.config.filters.filter_keys.include?(key.to_s.downcase.to_sym)
17+
sig { params(key: T.untyped, value: T.untyped).returns(T::Boolean) }
18+
def should_filter_key?(key, value = nil)
19+
filters = LogStruct.config.filters
20+
normalized_key = key.to_s
21+
normalized_symbol = normalized_key.downcase.to_sym
22+
23+
return true if filters.filter_keys.include?(normalized_symbol)
24+
25+
filters.filter_matchers.any? do |matcher|
26+
matcher.matches?(normalized_key, value)
27+
rescue => e
28+
handle_filter_matcher_error(e, matcher, normalized_key)
29+
false
30+
end
1831
end
1932

2033
# Check if a key should be hashed rather than completely filtered
21-
sig { params(key: T.any(String, Symbol)).returns(T::Boolean) }
34+
sig { params(key: T.untyped).returns(T::Boolean) }
2235
def should_include_string_hash?(key)
2336
LogStruct.config.filters.filter_keys_with_hashes.include?(key.to_s.downcase.to_sym)
2437
end
2538

2639
# Convert a value to a filtered summary hash (e.g. { _filtered: { class: "String", ... }})
27-
sig { params(key: T.any(String, Symbol), data: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
40+
sig { params(key: T.untyped, data: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
2841
def summarize_json_attribute(key, data)
2942
case data
3043
when Hash
@@ -59,12 +72,18 @@ def summarize_hash(hash)
5972
return {_class: "Hash", _empty: true} if hash.empty?
6073

6174
# Don't include byte size if hash contains any filtered keys
62-
has_sensitive_keys = hash.keys.any? { |key| should_filter_key?(key) }
75+
has_sensitive_keys = T.let(false, T::Boolean)
76+
normalized_keys = []
77+
78+
hash.each do |key, value|
79+
has_sensitive_keys ||= should_filter_key?(key, value)
80+
normalized_keys << normalize_summary_key(key)
81+
end
6382

6483
summary = {
6584
_class: Hash,
6685
_keys_count: hash.keys.size,
67-
_keys: hash.keys.map(&:to_sym).take(10)
86+
_keys: normalized_keys.take(10)
6887
}
6988

7089
# Only add byte size if no sensitive keys are present
@@ -84,6 +103,30 @@ def summarize_array(array)
84103
_bytes: array.to_json.bytesize
85104
}
86105
end
106+
107+
private
108+
109+
sig { params(key: T.any(String, Symbol, Integer, T.untyped)).returns(T.any(Symbol, String)) }
110+
def normalize_summary_key(key)
111+
if key.is_a?(Symbol)
112+
key
113+
elsif key.respond_to?(:to_sym)
114+
key.to_sym
115+
else
116+
key.to_s
117+
end
118+
rescue
119+
key.to_s
120+
end
121+
122+
sig { params(error: StandardError, matcher: ConfigStruct::FilterMatcher, key: String).void }
123+
def handle_filter_matcher_error(error, matcher, key)
124+
context = {
125+
matcher: matcher.label,
126+
key: key
127+
}
128+
LogStruct.handle_exception(error, source: Source::Internal, context: context)
129+
end
87130
end
88131
end
89132
end

test/code_examples/configuration_test.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ def test_filter_configuration
136136
config.filters.ssns = true # Filter social security numbers
137137
config.filters.url_passwords = true # Filter passwords in URLs
138138

139+
# Configure additional matchers for custom filtering rules
140+
config.filters.filter_matchers = [
141+
LogStruct::ConfigStruct::FilterMatcher.new(
142+
callable: ->(key, _value) { key.start_with?("secret_") },
143+
label: "secret prefix matcher"
144+
)
145+
]
146+
139147
# Configure the salt used for hashing filtered email addresses
140148
config.filters.hash_salt = ENV.fetch("EMAIL_HASH_SALT", "test_salt")
141149

@@ -156,6 +164,7 @@ def test_filter_configuration
156164
assert LogStruct.configuration.filters.url_passwords
157165
refute LogStruct.configuration.filters.ip_addresses
158166
refute LogStruct.configuration.filters.mac_addresses
167+
assert_equal 1, LogStruct.configuration.filters.filter_matchers.size
159168
assert_equal LogStruct.configuration.filters.hash_salt, LogStruct.configuration.filters.hash_salt
160169
assert_equal 12, LogStruct.configuration.filters.hash_length
161170
end

test/log_struct/configuration_test.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,5 +159,41 @@ def test_merge_rails_filter_parameters
159159
# Restore original filter keys
160160
LogStruct.config.filters.filter_keys = original_filter_keys
161161
end
162+
163+
def test_merge_rails_filter_parameters_preserves_regex_filters
164+
original_filter_keys = LogStruct.config.filters.filter_keys.dup
165+
original_matchers = LogStruct.config.filters.filter_matchers.dup
166+
167+
regex_filter = /\Aapi_/i
168+
filter_params = [regex_filter]
169+
170+
filter_params.define_singleton_method(:clear) do
171+
replace([])
172+
end
173+
174+
mock_config = T.unsafe(Object.new)
175+
mock_config.define_singleton_method(:filter_parameters) { filter_params }
176+
mock_config.define_singleton_method(:respond_to?) do |method_name, include_private = false|
177+
if method_name.to_sym == :filter_parameters
178+
true
179+
else
180+
super(method_name, include_private)
181+
end
182+
end
183+
184+
mock_app = T.unsafe(Object.new)
185+
mock_app.define_singleton_method(:config) { mock_config }
186+
187+
LogStruct.config.filters.filter_keys = []
188+
189+
Rails.stub(:application, mock_app) do
190+
LogStruct.merge_rails_filter_parameters!
191+
end
192+
193+
assert ParamFilters.should_filter_key?("api_token"), "regex-based Rails filters should continue filtering keys"
194+
ensure
195+
LogStruct.config.filters.filter_keys = original_filter_keys
196+
LogStruct.config.filters.filter_matchers = original_matchers
197+
end
162198
end
163199
end

test/log_struct/param_filters_test.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ class ParamFiltersTest < ActiveSupport::TestCase
5656
refute summary2.key?(:_bytes)
5757
end
5858

59+
test "summarize_hash handles non symbol keys" do
60+
summary = ParamFilters.summarize_hash({1 => "value"})
61+
62+
assert_equal Hash, summary[:_class]
63+
assert_equal 1, summary[:_keys_count]
64+
assert_equal ["1"], summary[:_keys]
65+
end
66+
5967
test "summarize_array reports count/bytes and handles empty" do
6068
s = ParamFilters.summarize_array([1, 2, 3])
6169

0 commit comments

Comments
 (0)