activesupport-json_logging 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b40f43e3cd600bfc139665fa3e6cc9df7a4a56eab58bec7f760b986faa9d2f7
4
- data.tar.gz: d8d3352e482dc9698aa94a499f9905a1fdec0ca157cedf4e8a61194dfd7e7fba
3
+ metadata.gz: 8860a79aadc768a5173400d8c56041edde0fcd66b9e3f2f50e4918956c31b451
4
+ data.tar.gz: 07420fcc1f8aa91271ebf0484a338b921dba8fd41c16e2e50adf789a9eaaed33
5
5
  SHA512:
6
- metadata.gz: 4b0befde4733aeade6f332adaf529735ed718fc5b4ad03dbbcbcd6d66e0124ed107e508f47fc3efa70f4fc31479d61ac42508d364c185c846a9c93ce51924cac
7
- data.tar.gz: 83b705a450abaf4b389495652843bc4ed537de7485bb1158423b5a9055e40616024ed928844a485d65aa26cadfbe64746570dd823943dc724b29e849dcbd7ecf
6
+ metadata.gz: df9baad44f3b33dce3e18b795691fd6e7d8d620930826d2d9e423808b266b294b6ab3355619df9a2a406d51c7c0d3338be984599871579fbc67b69c5fc198a42
7
+ data.tar.gz: c93486338ce6906fb91bd96b55732a67525883971d7b7551690b34a4280337482160c096459b63623f0c2e629cb41b6af028fa008b274fc756137e5f21fc9958
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 1.3.1 (2026-09-04)
6
+
7
+ - Copy large structured hashes before Rails parameter filtering so caller fields stay intact
8
+ - Keep original key names on the large-hash encoder path when a Rails parameter filter is present
9
+ - Isolate nested hashes in `with_context` from later caller mutations
10
+ - Match fallback sensitive-key detection to Rails default filter substrings including email, otp, ssn, token, and credentials
11
+ - Rebuild the compiled Rails parameter filter after in-place `filter_parameters` changes
12
+ - Treat JSON object and array strings longer than 10,000 characters as truncated text
13
+ - Replace home-directory prefixes in log strings and backtraces with a tilde
14
+ - Skip EventSubscriber writes when the logger has INFO disabled; keep the Rails event timestamp as an integer
15
+ - Keep overflow sensitive keys as filtered after the 50-key hash cap without copying their values
16
+ - Require json 2.21.2 or newer
17
+
3
18
  ## 1.3.0 (2026-07-25)
4
19
 
5
20
  - BREAKING: Require Ruby 3.2+ and Rails 7+ (drop Ruby 2.7–3.1 and Rails 6 support)
data/README.md CHANGED
@@ -148,7 +148,7 @@ Or enable the Railtie opt-in (off by default):
148
148
  config.json_logging.subscribe_event_reporter = true
149
149
  ```
150
150
 
151
- The subscriber writes one JSON line per event and keeps the Rails event shape (`name`, `payload`, `tags`, `context`, `timestamp`, `source_location`). It writes through `Logger#<<` so the logger formatter does not wrap the event again. Payload and tag objects that implement `#serialize` are encoded via that method. Encode and write failures never raise from `#emit`.
151
+ The subscriber writes one JSON line per event and keeps the Rails event shape (`name`, `payload`, `tags`, `context`, `timestamp`, `source_location`). It writes through `Logger#<<` so the logger formatter does not wrap the event again. It skips that write when the logger reports that INFO is disabled (`logger.level` above INFO). An IO destination always writes. `timestamp` stays the Rails event integer (nanoseconds), not the ISO8601 string used by `JsonLoggerExtension`. Payload and tag objects that implement `#serialize` are encoded via that method. Encode and write failures never raise from `#emit`.
152
152
 
153
153
  ```ruby
154
154
  Rails.event.set_context(request_id: "abc123")
@@ -616,9 +616,9 @@ logger.reopen # Reopen the logger (if supported by logdev)
616
616
  ## Security & privacy
617
617
 
618
618
  - **Rails ParameterFilter integration**: Automatically uses `Rails.application.config.filter_parameters` to filter sensitive data (passwords, tokens, etc.). This includes encrypted attributes automatically. See [Rails parameter filtering guide](https://thoughtbot.com/blog/parameter-filtering).
619
- - **Input sanitization**: Removes control characters, truncates long strings, and limits structure depth/size:
619
+ - **Input sanitization**: Removes control characters, truncates long strings, redacts home-directory prefixes to `~`, and limits structure depth/size:
620
620
  - Maximum string length: 10,000 characters (truncated with `...[truncated]` suffix)
621
- - Maximum context hash size: 50 keys (additional keys are truncated)
621
+ - Maximum context hash size: 50 keys (additional keys are truncated; overflow keys that match sensitive patterns or `filter_parameters` still appear as `[FILTERED]`)
622
622
  - Maximum nesting depth: 10 levels (deeper structures return `{"error" => "max_depth_exceeded"}`)
623
623
  - Maximum backtrace lines: 20 lines per exception
624
624
  - **Single-line JSON**: Emits single-line JSON to avoid log injection via newlines
@@ -632,7 +632,7 @@ This gem automatically uses Rails' `config.filter_parameters` when available. Co
632
632
 
633
633
  ```ruby
634
634
  Rails.application.config.filter_parameters += [
635
- :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
635
+ :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
636
636
  ]
637
637
  ```
638
638
 
@@ -121,9 +121,13 @@ module JsonLogging
121
121
  def write_line(line)
122
122
  if @io
123
123
  @io.write(line)
124
- else
125
- resolve_logger << line
124
+ return
126
125
  end
126
+
127
+ logger = resolve_logger
128
+ return if logger.respond_to?(:info?) && !logger.info?
129
+
130
+ logger << line
127
131
  end
128
132
 
129
133
  def resolve_logger
@@ -223,14 +223,6 @@ module JsonLogging
223
223
  "#{JSON.generate(payload)}\n"
224
224
  end
225
225
 
226
- def build_sanitized_hash_payload(message:, severity:, timestamp:)
227
- hash = message.is_a?(Hash) ? message : message.to_hash
228
- payload = Sanitizer.sanitize_hash(hash)
229
- payload["severity"] = severity
230
- payload["timestamp"] = timestamp
231
- payload
232
- end
233
-
234
226
  def deep_stringify_structure(obj)
235
227
  case obj
236
228
  when Hash
@@ -25,6 +25,8 @@ module JsonLogging
25
25
  end
26
26
 
27
27
  def json_string?(str)
28
+ return false if str.length > Sanitizer::MAX_STRING_LENGTH
29
+
28
30
  (str.start_with?("{") && str.end_with?("}")) ||
29
31
  (str.start_with?("[") && str.end_with?("]"))
30
32
  end
@@ -1,4 +1,5 @@
1
1
  require_relative "structured_hash_sanitizer"
2
+ require_relative "sanitizer_hash_limit"
2
3
 
3
4
  module JsonLogging
4
5
  module Sanitizer
@@ -18,7 +19,7 @@ module JsonLogging
18
19
  MAX_BACKTRACE_LINES = 20
19
20
 
20
21
  # Common sensitive key patterns (case insensitive) - fallback when Rails ParameterFilter not available
21
- SENSITIVE_KEY_PATTERNS = /\b(password|passwd|pwd|secret|token|api_key|apikey|access_token|auth_token|private_key|credential)\b/i
22
+ SENSITIVE_KEY_PATTERNS = /(passw|pwd|email|secret|token|_key|crypt|salt|certificate|otp|ssn|cvv|cvc|credential)/i
22
23
 
23
24
  @parameter_filter = nil
24
25
  @parameter_filter_config = nil
@@ -38,7 +39,7 @@ module JsonLogging
38
39
  return @parameter_filter
39
40
  end
40
41
 
41
- @parameter_filter_config = filter_params
42
+ @parameter_filter_config = filter_params.dup.freeze
42
43
  @parameter_filter_requires_full_tree_walk = parameter_filter_requires_full_tree_walk?(filter_params)
43
44
  @parameter_filter = ActiveSupport::ParameterFilter.new(filter_params)
44
45
  rescue
@@ -76,12 +77,14 @@ module JsonLogging
76
77
  # Sanitize a string by removing/escaping control characters and truncating
77
78
  def sanitize_string(str)
78
79
  return str unless str.is_a?(String)
79
- return str if str.length <= MAX_STRING_LENGTH && !str.match?(CONTROL_CHARS)
80
80
 
81
- # Remove or replace control characters
81
+ home_prefix = home_directory_prefix
82
+ has_home_directory = home_prefix && str.include?(home_prefix)
83
+ return str if str.length <= MAX_STRING_LENGTH && !str.match?(CONTROL_CHARS) && !has_home_directory
84
+
82
85
  sanitized = str.gsub(CONTROL_CHARS, "")
86
+ sanitized = redact_home_directory(sanitized, home_prefix) if has_home_directory
83
87
 
84
- # Truncate if too long
85
88
  if sanitized.length > MAX_STRING_LENGTH
86
89
  sanitized = sanitized[0, MAX_STRING_LENGTH] + "...[truncated]"
87
90
  end
@@ -91,12 +94,8 @@ module JsonLogging
91
94
  "<sanitization_error>"
92
95
  end
93
96
 
94
- # Sanitize a hash, removing sensitive keys and limiting size/depth
95
- # Uses Rails ParameterFilter when available, falls back to pattern matching
96
97
  def sanitize_hash(hash, depth: 0)
97
98
  return hash unless hash.is_a?(Hash)
98
-
99
- # Prevent excessive nesting
100
99
  return {"error" => "max_depth_exceeded"} if depth > MAX_DEPTH
101
100
 
102
101
  limited_hash = limited_hash_for_sanitization(hash)
@@ -109,13 +108,7 @@ module JsonLogging
109
108
  end
110
109
 
111
110
  def limited_hash_for_sanitization(hash)
112
- if hash.size > MAX_CONTEXT_SIZE
113
- truncated = hash.first(MAX_CONTEXT_SIZE).to_h
114
- truncated["_truncated"] = true
115
- truncated
116
- else
117
- hash
118
- end
111
+ SanitizerHashLimit.apply(hash, filter_config: rails_parameter_filter && @parameter_filter_config)
119
112
  end
120
113
 
121
114
  def fast_path_sanitized_hash(hash, depth: 0)
@@ -126,38 +119,26 @@ module JsonLogging
126
119
  end
127
120
 
128
121
  def sanitize_hash_with_filtering(limited_hash, depth: 0)
129
- # Use Rails ParameterFilter if available (handles encrypted attributes automatically)
130
122
  filter = rails_parameter_filter
131
123
  if filter
132
- # ParameterFilter will filter based on Rails.config.filter_parameters
133
- # This includes encrypted attributes automatically
134
- # Create a deep copy since filter modifies in place
124
+ # ParameterFilter mutates in place; copy first
135
125
  filtered = limited_hash.respond_to?(:deep_dup) ? limited_hash.deep_dup : limited_hash.dup
136
126
  filtered = filter.filter(filtered)
137
-
138
- # Then sanitize values (strings, control chars, etc.) preserving filtered structure
139
127
  filtered.each_with_object({}) do |(key, value), result|
140
128
  result[key.to_s] = sanitize_value(value, depth: depth + 1)
141
129
  end
142
-
143
130
  else
144
- # Fallback: use pattern matching for sensitive keys
145
131
  limited_hash.each_with_object({}) do |(key, value), result|
146
132
  key_string = key.to_s
147
-
148
- # Skip sensitive keys
149
133
  if SENSITIVE_KEY_PATTERNS.match?(key_string)
150
134
  result[sensitive_filtered_key_name(key_string)] = "[FILTERED]"
151
135
  next
152
136
  end
153
-
154
137
  result[key_string] = sanitize_value(value, depth: depth + 1)
155
138
  end
156
139
  end
157
140
  end
158
141
 
159
- # Sanitize a value (handles strings, hashes, arrays, etc.)
160
- # Preserves numeric, boolean, and nil types
161
142
  def sanitize_value(value, depth: 0)
162
143
  case value
163
144
  when String
@@ -165,24 +146,20 @@ module JsonLogging
165
146
  when Hash
166
147
  sanitize_hash(value, depth: depth)
167
148
  when Array
168
- # Limit array size
169
149
  sanitized = value.first(MAX_CONTEXT_SIZE).map { |v| sanitize_value(v, depth: depth + 1) }
170
150
  sanitized << "[truncated]" if value.size > MAX_CONTEXT_SIZE
171
151
  sanitized
172
152
  when Exception
173
153
  sanitize_exception(value)
174
154
  when Numeric, TrueClass, FalseClass, NilClass
175
- # Preserve numeric, boolean, and nil types
176
155
  value
177
156
  else
178
- # For other types, convert to string and sanitize
179
157
  sanitize_string(value.to_s)
180
158
  end
181
159
  rescue
182
160
  "<unprintable>"
183
161
  end
184
162
 
185
- # Sanitize exception, including backtrace
186
163
  def sanitize_exception(ex)
187
164
  {
188
165
  "error" => {
@@ -195,11 +172,9 @@ module JsonLogging
195
172
  {"error" => {"class" => "Exception", "message" => "<sanitization_failed>"}}
196
173
  end
197
174
 
198
- # Sanitize backtrace - truncate and remove sensitive paths
199
175
  def sanitize_backtrace(backtrace)
200
176
  return [] unless backtrace.is_a?(Array)
201
177
 
202
- # Take first MAX_BACKTRACE_LINES, sanitize each
203
178
  backtrace.first(MAX_BACKTRACE_LINES).map do |line|
204
179
  sanitize_string(line.to_s)
205
180
  end
@@ -207,7 +182,6 @@ module JsonLogging
207
182
  []
208
183
  end
209
184
 
210
- # Check if a key looks sensitive
211
185
  def sensitive_key?(key)
212
186
  SENSITIVE_KEY_PATTERNS.match?(key.to_s)
213
187
  end
@@ -255,5 +229,22 @@ module JsonLogging
255
229
  def sensitive_filtered_key_name(key_string)
256
230
  key_string.gsub(/(?<!^)(?=[A-Z])/, "_").downcase + "_filtered"
257
231
  end
232
+
233
+ def home_directory_prefix
234
+ prefix = ENV["HOME"]
235
+ prefix = Dir.home if prefix.blank?
236
+ prefix = prefix.to_s.chomp("/").chomp("\\")
237
+ return if prefix.empty?
238
+
239
+ prefix
240
+ rescue ArgumentError, RuntimeError
241
+ nil
242
+ end
243
+
244
+ def redact_home_directory(str, prefix)
245
+ return str unless prefix
246
+
247
+ str.gsub(/#{Regexp.escape(prefix)}(?=\/|\\|\z)/, "~")
248
+ end
258
249
  end
259
250
  end
@@ -0,0 +1,42 @@
1
+ module JsonLogging
2
+ module SanitizerHashLimit
3
+ FILTERED = "[FILTERED]"
4
+
5
+ module_function
6
+
7
+ def apply(hash, filter_config:)
8
+ return hash if hash.size <= Sanitizer::MAX_CONTEXT_SIZE
9
+
10
+ truncated = {}
11
+ kept = 0
12
+ extra_filtered = 0
13
+ hash.each do |key, value|
14
+ if kept < Sanitizer::MAX_CONTEXT_SIZE
15
+ truncated[key] = value
16
+ kept += 1
17
+ elsif extra_filtered < Sanitizer::MAX_CONTEXT_SIZE && key_requires_filtering?(key, filter_config)
18
+ truncated[key] = FILTERED
19
+ extra_filtered += 1
20
+ end
21
+ end
22
+ truncated["_truncated"] = true
23
+ truncated
24
+ end
25
+
26
+ def key_requires_filtering?(key, filter_config)
27
+ return true if Sanitizer.sensitive_key?(key)
28
+
29
+ Array(filter_config).any? do |filter|
30
+ next false if filter.is_a?(Proc)
31
+
32
+ key_string = key.to_s
33
+ if filter.is_a?(Regexp)
34
+ filter.match?(key_string)
35
+ else
36
+ token = filter.to_s
37
+ !token.empty? && !token.include?(".") && key_string.match?(/#{Regexp.escape(token)}/i)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -6,35 +6,6 @@ module JsonLogging
6
6
 
7
7
  module_function
8
8
 
9
- def eligible?(hash)
10
- return false if Sanitizer.primitive_log_hash?(hash)
11
- return false unless Sanitizer::StructuredHash.structured_log_hash?(hash)
12
- return false unless large_structured_hash?(hash)
13
-
14
- filter = Sanitizer.rails_parameter_filter
15
- return true unless filter
16
-
17
- !Sanitizer.rails_parameter_filter_requires_full_tree_walk?
18
- end
19
-
20
- def large_structured_hash?(hash, leaf_threshold: LEAF_THRESHOLD)
21
- leaf_count = 0
22
- walker = lambda do |value|
23
- case value
24
- when Hash
25
- value.each_value { |entry| walker.call(entry) }
26
- when Array
27
- value.each { |entry| walker.call(entry) }
28
- else
29
- leaf_count += 1
30
- throw(:enough, true) if leaf_count > leaf_threshold
31
- end
32
- end
33
-
34
- catch(:enough) { walker.call(hash) }
35
- leaf_count > leaf_threshold
36
- end
37
-
38
9
  def try_encode_line(hash, severity:, timestamp:, field_overrides: {})
39
10
  tree = prepared_tree(hash, field_overrides: field_overrides)
40
11
  return nil unless tree
@@ -88,13 +59,7 @@ module JsonLogging
88
59
  return jsonable.tree unless filter
89
60
  return jsonable.tree if Sanitizer.rails_parameter_filter_requires_full_tree_walk?
90
61
 
91
- tree = if jsonable.owned
92
- jsonable.tree
93
- else
94
- Sanitizer::StructuredHash.stringify_keys_copy(jsonable.tree)
95
- end
96
-
97
- filter.filter(tree)
62
+ filter.filter(Sanitizer::StructuredHash.stringify(jsonable.tree))
98
63
  end
99
64
 
100
65
  def append_json_line(tree, severity:, timestamp:, field_overrides:)
@@ -44,11 +44,7 @@ module JsonLogging
44
44
 
45
45
  def sanitize_without_filter(hash, depth: 0)
46
46
  jsonable = jsonable_tree(hash, depth: depth)
47
- if jsonable.owned
48
- stringify(jsonable.tree)
49
- else
50
- jsonable.tree.dup
51
- end
47
+ stringify(jsonable.tree)
52
48
  end
53
49
 
54
50
  def jsonable_tree(hash, depth: 0, seen: nil, parent_key: nil, omit_keys: nil, count_leaves: false, leaf_counter: nil)
@@ -133,7 +129,8 @@ module JsonLogging
133
129
  return
134
130
  end
135
131
 
136
- if Sanitizer::SENSITIVE_KEY_PATTERNS.match?(key_string)
132
+ # ParameterFilter keeps original key names; fallback rename would hide them from it.
133
+ if Sanitizer.rails_parameter_filter.nil? && Sanitizer::SENSITIVE_KEY_PATTERNS.match?(key_string)
137
134
  return [Sanitizer.sensitive_filtered_key_name(key_string), "[FILTERED]", true]
138
135
  end
139
136
 
@@ -195,12 +192,6 @@ module JsonLogging
195
192
  value
196
193
  end
197
194
  end
198
-
199
- def stringify_keys_copy(hash)
200
- return hash if hash.keys.all? { |key| key.is_a?(String) }
201
-
202
- hash.transform_keys(&:to_s)
203
- end
204
195
  end
205
196
  end
206
197
  end
@@ -1,3 +1,3 @@
1
1
  module JsonLogging
2
- VERSION = "1.3.0"
2
+ VERSION = "1.3.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activesupport-json_logging
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -49,6 +49,20 @@ dependencies:
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
51
  version: '9.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: json
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: 2.21.2
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: 2.21.2
52
66
  - !ruby/object:Gem::Dependency
53
67
  name: rspec
54
68
  requirement: !ruby/object:Gem::Requirement
@@ -77,20 +91,6 @@ dependencies:
77
91
  - - ">="
78
92
  - !ruby/object:Gem::Version
79
93
  version: 2.2.0
80
- - !ruby/object:Gem::Dependency
81
- name: webmock
82
- requirement: !ruby/object:Gem::Requirement
83
- requirements:
84
- - - "~>"
85
- - !ruby/object:Gem::Version
86
- version: '3'
87
- type: :development
88
- prerelease: false
89
- version_requirements: !ruby/object:Gem::Requirement
90
- requirements:
91
- - - "~>"
92
- - !ruby/object:Gem::Version
93
- version: '3'
94
94
  - !ruby/object:Gem::Dependency
95
95
  name: rake
96
96
  requirement: !ruby/object:Gem::Requirement
@@ -339,6 +339,7 @@ files:
339
339
  - lib/json_logging/message_parser.rb
340
340
  - lib/json_logging/payload_builder.rb
341
341
  - lib/json_logging/sanitizer.rb
342
+ - lib/json_logging/sanitizer_hash_limit.rb
342
343
  - lib/json_logging/severity.rb
343
344
  - lib/json_logging/structured_hash_json_encoder.rb
344
345
  - lib/json_logging/structured_hash_sanitizer.rb