Skip to content

Commit 4913a71

Browse files
committed
Default Regex Filter Matchers, Job Argument Filtering, Reserved Keys Protection, SQL Bind Parameter Filtering
1 parent 3f3e7c6 commit 4913a71

File tree

9 files changed

+298
-4
lines changed

9 files changed

+298
-4
lines changed

lib/log_struct/builders/active_job.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ def self.safe_executions(job)
2121
job.respond_to?(:executions) ? job.executions : nil
2222
end
2323

24+
# Respect log_arguments? setting on job classes.
25+
# Arguments are logged by default but can be opted-out per job class.
26+
# When logged, sensitive keys are filtered by Formatter.process_values.
2427
sig { params(job: T.untyped).returns(T.nilable(T::Array[T.untyped])) }
2528
def self.safe_arguments(job)
26-
return nil unless job.class.respond_to?(:log_arguments?)
29+
return job.arguments unless job.class.respond_to?(:log_arguments?)
2730
job.class.log_arguments? ? job.arguments : nil
2831
end
2932

lib/log_struct/config_struct/filters.rb

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,32 @@ class Filters < T::Struct
8888
# Default: false
8989
prop :mac_addresses, T::Boolean, default: false
9090

91+
# Default regex pattern for matching sensitive keys.
92+
# Matches keys containing: password, token, secret, auth, cred
93+
# Also matches specific key patterns: api_key, secret_key, private_key, access_key, encryption_key
94+
# Examples: access_token, api_key, auth_header, credentials
95+
# Uses start/end of string or underscore/hyphen boundaries to prevent
96+
# false positives like "keyboard" or "turkey" (which contain "key" mid-word)
97+
# Note: "key" alone is too broad (matches cron_key, primary_key), so we only match
98+
# specific sensitive key patterns
99+
DEFAULT_SENSITIVE_KEY_PATTERN = T.let(
100+
/(^|[_-])(password|token|secret|auth|cred)([_-]|$)|(^|[_-])(api|secret|private|access|encryption)_key([_-]|$)/i,
101+
Regexp
102+
)
103+
91104
# Additional filter matchers built from Rails filter_parameters entries that aren't simple symbols.
92105
# Each matcher receives the key (String) and optional value, returning true when the pair should be filtered.
106+
# By default, includes a regex matcher for common sensitive key patterns.
93107
prop :filter_matchers,
94108
T::Array[FilterMatcher],
95-
factory: -> { [] }
109+
factory: -> {
110+
[
111+
FilterMatcher.new(
112+
callable: ->(key, _value) { DEFAULT_SENSITIVE_KEY_PATTERN.match?(key) },
113+
label: "default_sensitive_pattern"
114+
)
115+
]
116+
}
96117
end
97118
end
98119
end

lib/log_struct/integrations/active_record.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,12 @@ def self.looks_sensitive?(value)
281281
return true if value.match?(/\A[A-Za-z0-9+\/]{20,}={0,2}\z/) # Base64
282282
return true if value.match?(/(password|secret|token|key|auth)/i)
283283

284+
# Filter JWT tokens (header.payload.signature format, starts with "ey")
285+
return true if value.match?(/\Aey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\z/)
286+
287+
# Filter Bearer tokens
288+
return true if value.match?(/\ABearer\s+/i)
289+
284290
false
285291
end
286292
end

lib/log_struct/integrations/good_job/log_subscriber.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,21 @@ def build_base_fields(job, payload)
148148
job_id: job&.job_id,
149149
job_class: job&.job_class,
150150
queue_name: job&.queue_name&.to_sym,
151-
arguments: job&.arguments,
151+
arguments: safe_arguments(job),
152152
executions: execution&.executions
153153
)
154154
end
155155

156+
# Respect log_arguments? setting on job classes (consistent with ActiveJob behavior).
157+
# Arguments are logged by default but can be opted-out per job class.
158+
# When logged, sensitive keys are filtered by Formatter.process_values.
159+
sig { params(job: T.untyped).returns(T.nilable(T::Array[T.untyped])) }
160+
def safe_arguments(job)
161+
return nil unless job
162+
return job.arguments unless job.class.respond_to?(:log_arguments?)
163+
job.class.log_arguments? ? job.arguments : nil
164+
end
165+
156166
# Calculate wait time from job creation to execution start
157167
sig { params(execution: T.untyped).returns(T.nilable(Float)) }
158168
def calculate_wait_time(execution)

lib/log_struct/shared/merge_additional_data_fields.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# frozen_string_literal: true
33

44
require_relative "interfaces/additional_data_field"
5+
require_relative "../enums/source"
56

67
module LogStruct
78
module Log
@@ -13,12 +14,24 @@ module MergeAdditionalDataFields
1314
requires_ancestor { T::Struct }
1415
requires_ancestor { Interfaces::AdditionalDataField }
1516

17+
# Reserved keys that cannot be overwritten by additional_data.
18+
# These are the core log structure fields that must not be modified.
19+
RESERVED_KEYS = T.let(%i[src evt lvl ts].freeze, T::Array[Symbol])
20+
1621
sig { params(hash: T::Hash[Symbol, T.untyped]).void }
1722
def merge_additional_data_fields(hash)
1823
ad = additional_data
1924
return unless ad
2025
ad.each do |key, value|
21-
hash[key.to_sym] = value
26+
sym_key = key.to_sym
27+
if RESERVED_KEYS.include?(sym_key)
28+
LogStruct.handle_exception(
29+
ArgumentError.new("additional_data attempted to overwrite reserved key: #{sym_key}"),
30+
source: Source::Internal
31+
)
32+
next
33+
end
34+
hash[sym_key] = value
2235
end
2336
end
2437
end

test/log_struct/integrations/active_record_test.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,58 @@ class ActiveRecordTest < ActiveSupport::TestCase
293293
assert_includes T.must(log).bind_params, 123
294294
end
295295

296+
test "filters JWT tokens in bind parameters" do
297+
LogStruct.config.integrations.sql_log_bind_params = true
298+
setup_activerecord_for_test
299+
300+
# Real-looking JWT token (header.payload.signature format, starts with "ey")
301+
# cspell:disable-next-line
302+
jwt_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
303+
304+
logged_events = []
305+
LogStruct.stub(:info, ->(log) { logged_events << log }) do
306+
simulate_sql_notification(
307+
binds: [jwt_token, "normal_value"],
308+
type_casted_binds: [jwt_token, "normal_value"]
309+
)
310+
end
311+
312+
assert_equal 1, logged_events.size
313+
log = T.let(logged_events.first, T.nilable(LogStruct::Log::SQL))
314+
315+
assert_not_nil log
316+
317+
# JWT token should be filtered
318+
assert_includes T.must(log).bind_params, "[FILTERED]"
319+
# Normal value should pass through
320+
assert_includes T.must(log).bind_params, "normal_value"
321+
end
322+
323+
test "filters Bearer tokens in bind parameters" do
324+
LogStruct.config.integrations.sql_log_bind_params = true
325+
setup_activerecord_for_test
326+
327+
bearer_token = "Bearer abc123def456"
328+
329+
logged_events = []
330+
LogStruct.stub(:info, ->(log) { logged_events << log }) do
331+
simulate_sql_notification(
332+
binds: [bearer_token, "safe_value"],
333+
type_casted_binds: [bearer_token, "safe_value"]
334+
)
335+
end
336+
337+
assert_equal 1, logged_events.size
338+
log = T.let(logged_events.first, T.nilable(LogStruct::Log::SQL))
339+
340+
assert_not_nil log
341+
342+
# Bearer token should be filtered
343+
assert_includes T.must(log).bind_params, "[FILTERED]"
344+
# Safe value should pass through
345+
assert_includes T.must(log).bind_params, "safe_value"
346+
end
347+
296348
test "excludes bind parameters when disabled" do
297349
LogStruct.config.integrations.sql_log_bind_params = false
298350
setup_activerecord_for_test

test/log_struct/integrations/good_job/log_subscriber_test.rb

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,97 @@ class LogSubscriberTest < ActiveSupport::TestCase
266266
assert_nil wait_time
267267
end
268268

269+
test "respects log_arguments? when job class opts out" do
270+
clear_log_buffer(@log_output)
271+
272+
# Create a job class that opts out of logging arguments
273+
job_class = Class.new do
274+
def self.log_arguments?
275+
false
276+
end
277+
end
278+
Object.const_set(:NoLogArgsJob, job_class)
279+
280+
begin
281+
# Create a mock job where job.class returns the actual job class
282+
# rubocop:disable Sorbet/ConstantsFromStrings
283+
klass = Object.const_get(:NoLogArgsJob)
284+
# rubocop:enable Sorbet/ConstantsFromStrings
285+
job = create_mock_job_with_class(klass, "job_123", "default")
286+
event_data = create_test_event({
287+
job: job,
288+
duration: 0.1
289+
})
290+
291+
@subscriber.enqueue(event_data)
292+
::SemanticLogger.flush
293+
294+
output = @log_output.string
295+
log = JSON.parse(output.lines.first.strip)
296+
297+
# Arguments should not be present when log_arguments? returns false
298+
refute log.key?("arguments")
299+
ensure
300+
Object.send(:remove_const, :NoLogArgsJob)
301+
end
302+
end
303+
304+
test "logs arguments by default when job class does not define log_arguments?" do
305+
clear_log_buffer(@log_output)
306+
307+
# Standard job without log_arguments? defined - should log arguments
308+
job = create_mock_job("StandardJob", "job_123", "default")
309+
event_data = create_test_event({
310+
job: job,
311+
duration: 0.1
312+
})
313+
314+
@subscriber.enqueue(event_data)
315+
::SemanticLogger.flush
316+
317+
output = @log_output.string
318+
log = JSON.parse(output.lines.first.strip)
319+
320+
# Arguments should be present by default
321+
assert log.key?("arguments")
322+
assert_equal ["arg1", "arg2"], log["arguments"]
323+
end
324+
325+
test "logs arguments when job class log_arguments? returns true" do
326+
clear_log_buffer(@log_output)
327+
328+
job_class = Class.new do
329+
def self.log_arguments?
330+
true
331+
end
332+
end
333+
Object.const_set(:LogArgsJob, job_class)
334+
335+
begin
336+
# Create a mock job where job.class returns the actual job class
337+
# rubocop:disable Sorbet/ConstantsFromStrings
338+
klass = Object.const_get(:LogArgsJob)
339+
# rubocop:enable Sorbet/ConstantsFromStrings
340+
job = create_mock_job_with_class(klass, "job_123", "default")
341+
event_data = create_test_event({
342+
job: job,
343+
duration: 0.1
344+
})
345+
346+
@subscriber.enqueue(event_data)
347+
::SemanticLogger.flush
348+
349+
output = @log_output.string
350+
log = JSON.parse(output.lines.first.strip)
351+
352+
# Arguments should be present when log_arguments? returns true
353+
assert log.key?("arguments")
354+
assert_equal ["arg1", "arg2"], log["arguments"]
355+
ensure
356+
Object.send(:remove_const, :LogArgsJob)
357+
end
358+
end
359+
269360
private
270361

271362
def create_test_event(payload_data, start_time: nil, finish_time: nil)
@@ -293,6 +384,23 @@ def create_mock_job(job_class, job_id, queue_name, extra_attributes = {})
293384
}.merge(extra_attributes))
294385
end
295386

387+
# Create a mock job where job.class returns the actual job class (for testing log_arguments?)
388+
def create_mock_job_with_class(klass, job_id, queue_name, extra_attributes = {})
389+
mock = Object.new
390+
mock.define_singleton_method(:class) { klass }
391+
mock.define_singleton_method(:job_class) { klass.name }
392+
mock.define_singleton_method(:job_id) { job_id }
393+
mock.define_singleton_method(:queue_name) { queue_name }
394+
mock.define_singleton_method(:arguments) { ["arg1", "arg2"] }
395+
mock.define_singleton_method(:priority) { 0 }
396+
mock.define_singleton_method(:scheduled_at) { nil }
397+
mock.define_singleton_method(:enqueue_caller_location) { nil }
398+
extra_attributes.each do |key, value|
399+
mock.define_singleton_method(key) { value }
400+
end
401+
mock
402+
end
403+
296404
def create_mock_execution(attributes = {})
297405
OpenStruct.new({
298406
executions: 1,

test/log_struct/integrations/lograge_formatter_test.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,41 @@ def test_prefers_payload_request_id_over_header
180180

181181
assert_equal "payload-request-id", options[:request_id]
182182
end
183+
184+
def test_additional_data_cannot_overwrite_reserved_keys
185+
# Reserved keys are: src, evt, lvl, ts
186+
data = {
187+
method: "GET",
188+
path: "/users",
189+
status: 200,
190+
duration: 12.34,
191+
# These should be in additional_data but NOT overwrite the real values
192+
src: "attacker",
193+
evt: "spoofed",
194+
lvl: "fake",
195+
ts: "2000-01-01T00:00:00Z"
196+
}
197+
198+
exceptions_reported = []
199+
LogStruct.stub(:handle_exception, ->(exc, source:) { exceptions_reported << [exc, source] }) do
200+
log = @formatter.call(data)
201+
json_hash = log.serialize
202+
203+
# Reserved keys should retain their real values
204+
assert_equal "rails", json_hash[:src]
205+
assert_equal "request", json_hash[:evt]
206+
assert_equal "info", json_hash[:lvl]
207+
refute_equal "2000-01-01T00:00:00Z", json_hash[:ts]
208+
end
209+
210+
# Should have reported 4 exceptions (one for each reserved key attempted)
211+
assert_equal 4, exceptions_reported.size
212+
exceptions_reported.each do |exc, source|
213+
assert_kind_of ArgumentError, exc
214+
assert_match(/additional_data attempted to overwrite reserved key/, exc.message)
215+
assert_equal LogStruct::Source::Internal, source
216+
end
217+
end
183218
end
184219
end
185220
end

test/log_struct/param_filters_test.rb

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,51 @@ class ParamFiltersTest < ActiveSupport::TestCase
103103

104104
assert_equal Integer, s5[:_class]
105105
end
106+
107+
test "default regex filter matcher catches common sensitive key patterns" do
108+
# Reset config to use fresh defaults (with default filter_matchers)
109+
LogStruct.configuration = LogStruct::Configuration.new
110+
111+
# Should filter keys containing sensitive patterns with word boundaries
112+
assert ParamFilters.should_filter_key?(:access_token)
113+
assert ParamFilters.should_filter_key?(:refresh_token)
114+
assert ParamFilters.should_filter_key?(:api_key)
115+
assert ParamFilters.should_filter_key?(:private_key)
116+
assert ParamFilters.should_filter_key?(:secret_key)
117+
assert ParamFilters.should_filter_key?(:access_key)
118+
assert ParamFilters.should_filter_key?(:encryption_key)
119+
assert ParamFilters.should_filter_key?(:client_secret)
120+
assert ParamFilters.should_filter_key?(:auth_header)
121+
assert ParamFilters.should_filter_key?(:auth_token)
122+
assert ParamFilters.should_filter_key?(:credentials)
123+
assert ParamFilters.should_filter_key?(:password_hash)
124+
125+
# Case insensitive
126+
assert ParamFilters.should_filter_key?(:ACCESS_TOKEN)
127+
assert ParamFilters.should_filter_key?(:Api_Key)
128+
end
129+
130+
test "default regex filter matcher does not match false positives" do
131+
# Reset config to use fresh defaults (with default filter_matchers)
132+
LogStruct.configuration = LogStruct::Configuration.new
133+
134+
# Word boundaries should prevent these false positives
135+
refute ParamFilters.should_filter_key?(:keyboard)
136+
refute ParamFilters.should_filter_key?(:turkey)
137+
refute ParamFilters.should_filter_key?(:monkey)
138+
refute ParamFilters.should_filter_key?(:hockey)
139+
refute ParamFilters.should_filter_key?(:credenza)
140+
141+
# "key" alone is too broad - these should NOT be filtered
142+
refute ParamFilters.should_filter_key?(:cron_key)
143+
refute ParamFilters.should_filter_key?(:primary_key)
144+
refute ParamFilters.should_filter_key?(:foreign_key)
145+
refute ParamFilters.should_filter_key?(:unique_key)
146+
147+
# Normal fields should not be filtered
148+
refute ParamFilters.should_filter_key?(:username)
149+
refute ParamFilters.should_filter_key?(:email_address)
150+
refute ParamFilters.should_filter_key?(:first_name)
151+
end
106152
end
107153
end

0 commit comments

Comments
 (0)