Skip to content

Commit d4037c8

Browse files
authored
Merge pull request #5 from DocSpring/nathan/security-fixes
2 parents 3f3e7c6 + c233657 commit d4037c8

File tree

12 files changed

+288
-5
lines changed

12 files changed

+288
-5
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

rails_test_app/create_app.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ def rails_supports_skip_kamal?(clean_env)
132132
"BUNDLER_SETUP" => nil,
133133
"GEM_HOME" => nil,
134134
"GEM_PATH" => nil,
135+
# Install gems locally to avoid issues with world-writable system gem directories on CI
136+
# (Ruby 4.0 bundler rejects reinstalling gems in world-writable directories for security)
137+
"BUNDLE_PATH" => File.join(RAILS_APP_DIR, "vendor", "bundle"),
135138
# Silence noisy warnings from duplicate gems during generator execution
136139
"RUBYOPT" => "-W0"
137140
}

scripts/merge_coverage.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
#!/usr/bin/env bash
22

3-
rake coverage:merge
3+
bundle exec rake coverage:merge

scripts/rails_tests.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ export RUBYOPT="-W0"
7575
# Set CI=true so LogStruct is enabled for integration tests
7676
export CI=true
7777

78+
# Install gems locally to avoid issues with world-writable system gem directories on CI
79+
# (Ruby 4.0 bundler rejects reinstalling gems in world-writable directories for security)
80+
export BUNDLE_PATH="$TEST_APP_DIR/vendor/bundle"
81+
7882
# Install gems for the test app under the selected Ruby
7983
bundle install
8084

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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,67 @@ 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+
log = run_enqueue_with_job_class(:NoLogArgsJob)
282+
283+
refute log.key?("arguments")
284+
ensure
285+
Object.send(:remove_const, :NoLogArgsJob)
286+
end
287+
end
288+
289+
test "logs arguments by default when job class does not define log_arguments?" do
290+
clear_log_buffer(@log_output)
291+
292+
# Standard job without log_arguments? defined - should log arguments
293+
job = create_mock_job("StandardJob", "job_123", "default")
294+
event_data = create_test_event({
295+
job: job,
296+
duration: 0.1
297+
})
298+
299+
@subscriber.enqueue(event_data)
300+
::SemanticLogger.flush
301+
302+
output = @log_output.string
303+
log = JSON.parse(output.lines.first.strip)
304+
305+
# Arguments should be present by default
306+
assert log.key?("arguments")
307+
assert_equal ["arg1", "arg2"], log["arguments"]
308+
end
309+
310+
test "logs arguments when job class log_arguments? returns true" do
311+
clear_log_buffer(@log_output)
312+
313+
job_class = Class.new do
314+
def self.log_arguments?
315+
true
316+
end
317+
end
318+
Object.const_set(:LogArgsJob, job_class)
319+
320+
begin
321+
log = run_enqueue_with_job_class(:LogArgsJob)
322+
323+
assert log.key?("arguments")
324+
assert_equal ["arg1", "arg2"], log["arguments"]
325+
ensure
326+
Object.send(:remove_const, :LogArgsJob)
327+
end
328+
end
329+
269330
private
270331

271332
def create_test_event(payload_data, start_time: nil, finish_time: nil)
@@ -293,6 +354,35 @@ def create_mock_job(job_class, job_id, queue_name, extra_attributes = {})
293354
}.merge(extra_attributes))
294355
end
295356

357+
# Create a mock job where job.class returns the actual job class (for testing log_arguments?)
358+
def create_mock_job_with_class(klass, job_id, queue_name, extra_attributes = {})
359+
mock = Object.new
360+
mock.define_singleton_method(:class) { klass }
361+
mock.define_singleton_method(:job_class) { klass.name }
362+
mock.define_singleton_method(:job_id) { job_id }
363+
mock.define_singleton_method(:queue_name) { queue_name }
364+
mock.define_singleton_method(:arguments) { ["arg1", "arg2"] }
365+
mock.define_singleton_method(:priority) { 0 }
366+
mock.define_singleton_method(:scheduled_at) { nil }
367+
mock.define_singleton_method(:enqueue_caller_location) { nil }
368+
extra_attributes.each do |key, value|
369+
mock.define_singleton_method(key) { value }
370+
end
371+
mock
372+
end
373+
374+
# Run an enqueue event with a job class that has log_arguments? method and return the parsed log
375+
def run_enqueue_with_job_class(job_class)
376+
# rubocop:disable Sorbet/ConstantsFromStrings
377+
klass = Object.const_get(job_class)
378+
# rubocop:enable Sorbet/ConstantsFromStrings
379+
job = create_mock_job_with_class(klass, "job_123", "default")
380+
event_data = create_test_event({job: job, duration: 0.1})
381+
@subscriber.enqueue(event_data)
382+
::SemanticLogger.flush
383+
JSON.parse(@log_output.string.lines.first.strip)
384+
end
385+
296386
def create_mock_execution(attributes = {})
297387
OpenStruct.new({
298388
executions: 1,

0 commit comments

Comments
 (0)