Module: JsonLogging::Sanitizer

Defined in:
lib/json_logging/sanitizer.rb,
lib/json_logging/structured_hash_sanitizer.rb

Defined Under Namespace

Modules: StructuredHash

Constant Summary collapse

CONTROL_CHARS =

Control characters that should be escaped or removed from log messages

/[\x00-\x1F\x7F]/
MAX_STRING_LENGTH =

Maximum string length before truncation

10_000
MAX_CONTEXT_SIZE =

Maximum context hash size (number of keys)

50
MAX_DEPTH =

Maximum depth for nested structures

10
MAX_BACKTRACE_LINES =

Maximum backtrace lines to include

20
SENSITIVE_KEY_PATTERNS =

Common sensitive key patterns (case insensitive) - fallback when Rails ParameterFilter not available

/\b(password|passwd|pwd|secret|token|api_key|apikey|access_token|auth_token|private_key|credential)\b/i

Class Method Summary collapse

Class Method Details

.fast_path_sanitized_hash(hash, depth: 0) ⇒ Object



121
122
123
124
125
126
# File 'lib/json_logging/sanitizer.rb', line 121

def fast_path_sanitized_hash(hash, depth: 0)
  return sanitize_primitive_hash(hash) if primitive_log_hash?(hash)
  return StructuredHash.sanitize(hash, depth: depth) if StructuredHash.structured_log_hash?(hash)

  nil
end

.limited_hash_for_sanitization(hash) ⇒ Object



111
112
113
114
115
116
117
118
119
# File 'lib/json_logging/sanitizer.rb', line 111

def limited_hash_for_sanitization(hash)
  if hash.size > MAX_CONTEXT_SIZE
    truncated = hash.first(MAX_CONTEXT_SIZE).to_h
    truncated["_truncated"] = true
    truncated
  else
    hash
  end
end

.parameter_filter_requires_full_tree_walk?(filter_params) ⇒ Boolean

Returns:

  • (Boolean)


53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/json_logging/sanitizer.rb', line 53

def parameter_filter_requires_full_tree_walk?(filter_params)
  filter_params.any? do |filter|
    case filter
    when Proc
      true
    when Regexp
      filter.to_s.include?("\\.")
    else
      filter.to_s.include?(".")
    end
  end
end

.prepare_tags(tags) ⇒ Object



72
73
74
# File 'lib/json_logging/sanitizer.rb', line 72

def prepare_tags(tags)
  tags.flatten.compact.map(&:to_s).reject(&:empty?).map { |tag| sanitize_string(tag) }
end

.primitive_log_hash?(hash) ⇒ Boolean

Returns:

  • (Boolean)


215
216
217
# File 'lib/json_logging/sanitizer.rb', line 215

def primitive_log_hash?(hash)
  hash.all? { |_key, value| primitive_log_value?(value) }
end

.primitive_log_value?(value) ⇒ Boolean

Returns:

  • (Boolean)


219
220
221
222
223
224
225
226
227
228
# File 'lib/json_logging/sanitizer.rb', line 219

def primitive_log_value?(value)
  case value
  when String
    value.length <= MAX_STRING_LENGTH && !value.match?(CONTROL_CHARS)
  when Numeric, TrueClass, FalseClass, NilClass
    true
  else
    false
  end
end

.rails_parameter_filterObject

Get Rails ParameterFilter if available, nil otherwise



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/json_logging/sanitizer.rb', line 30

def rails_parameter_filter
  return nil unless defined?(Rails) && Rails.respond_to?(:application)
  return nil unless Rails.application.respond_to?(:config)

  filter_params = Rails.application.config.filter_parameters
  return nil if filter_params.empty?

  if @parameter_filter && @parameter_filter_config == filter_params
    return @parameter_filter
  end

  @parameter_filter_config = filter_params
  @parameter_filter_requires_full_tree_walk = parameter_filter_requires_full_tree_walk?(filter_params)
  @parameter_filter = ActiveSupport::ParameterFilter.new(filter_params)
rescue
  nil
end

.rails_parameter_filter_requires_full_tree_walk?Boolean

Returns:

  • (Boolean)


48
49
50
51
# File 'lib/json_logging/sanitizer.rb', line 48

def rails_parameter_filter_requires_full_tree_walk?
  rails_parameter_filter
  @parameter_filter_requires_full_tree_walk
end

.reset_rails_parameter_filter_cache!Object



66
67
68
69
70
# File 'lib/json_logging/sanitizer.rb', line 66

def reset_rails_parameter_filter_cache!
  @parameter_filter = nil
  @parameter_filter_config = nil
  @parameter_filter_requires_full_tree_walk = false
end

.sanitize_backtrace(backtrace) ⇒ Object

Sanitize backtrace - truncate and remove sensitive paths



199
200
201
202
203
204
205
206
207
208
# File 'lib/json_logging/sanitizer.rb', line 199

def sanitize_backtrace(backtrace)
  return [] unless backtrace.is_a?(Array)

  # Take first MAX_BACKTRACE_LINES, sanitize each
  backtrace.first(MAX_BACKTRACE_LINES).map do |line|
    sanitize_string(line.to_s)
  end
rescue
  []
end

.sanitize_exception(ex) ⇒ Object

Sanitize exception, including backtrace



186
187
188
189
190
191
192
193
194
195
196
# File 'lib/json_logging/sanitizer.rb', line 186

def sanitize_exception(ex)
  {
    "error" => {
      "class" => ex.class.name,
      "message" => sanitize_string(ex.message.to_s),
      "backtrace" => sanitize_backtrace(ex.backtrace)
    }
  }
rescue
  {"error" => {"class" => "Exception", "message" => "<sanitization_failed>"}}
end

.sanitize_hash(hash, depth: 0) ⇒ Object

Sanitize a hash, removing sensitive keys and limiting size/depth Uses Rails ParameterFilter when available, falls back to pattern matching



96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/json_logging/sanitizer.rb', line 96

def sanitize_hash(hash, depth: 0)
  return hash unless hash.is_a?(Hash)

  # Prevent excessive nesting
  return {"error" => "max_depth_exceeded"} if depth > MAX_DEPTH

  limited_hash = limited_hash_for_sanitization(hash)
  fast_path_result = fast_path_sanitized_hash(limited_hash, depth: depth)
  return fast_path_result if fast_path_result

  sanitize_hash_with_filtering(limited_hash, depth: depth)
rescue
  {"sanitization_error" => true}
end

.sanitize_hash_with_filtering(limited_hash, depth: 0) ⇒ Object



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/json_logging/sanitizer.rb', line 128

def sanitize_hash_with_filtering(limited_hash, depth: 0)
  # Use Rails ParameterFilter if available (handles encrypted attributes automatically)
  filter = rails_parameter_filter
  if filter
    # ParameterFilter will filter based on Rails.config.filter_parameters
    # This includes encrypted attributes automatically
    # Create a deep copy since filter modifies in place (Rails 6+)
    filtered = limited_hash.respond_to?(:deep_dup) ? limited_hash.deep_dup : limited_hash.dup
    filtered = filter.filter(filtered)

    # Then sanitize values (strings, control chars, etc.) preserving filtered structure
    filtered.each_with_object({}) do |(key, value), result|
      result[key.to_s] = sanitize_value(value, depth: depth + 1)
    end

  else
    # Fallback: use pattern matching for sensitive keys
    limited_hash.each_with_object({}) do |(key, value), result|
      key_string = key.to_s

      # Skip sensitive keys
      if SENSITIVE_KEY_PATTERNS.match?(key_string)
        result[sensitive_filtered_key_name(key_string)] = "[FILTERED]"
        next
      end

      result[key_string] = sanitize_value(value, depth: depth + 1)
    end
  end
end

.sanitize_primitive_hash(hash) ⇒ Object



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/json_logging/sanitizer.rb', line 230

def sanitize_primitive_hash(hash)
  filter = rails_parameter_filter
  if filter
    stringified = stringify_primitive_hash(hash)
    filter.filter(stringified.dup)
  else
    hash.each_with_object({}) do |(key, value), result|
      key_string = key.to_s

      if SENSITIVE_KEY_PATTERNS.match?(key_string)
        result[sensitive_filtered_key_name(key_string)] = "[FILTERED]"
        next
      end

      result[key_string] = value
    end
  end
end

.sanitize_string(str) ⇒ Object

Sanitize a string by removing/escaping control characters and truncating



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/json_logging/sanitizer.rb', line 77

def sanitize_string(str)
  return str unless str.is_a?(String)
  return str if str.length <= MAX_STRING_LENGTH && !str.match?(CONTROL_CHARS)

  # Remove or replace control characters
  sanitized = str.gsub(CONTROL_CHARS, "")

  # Truncate if too long
  if sanitized.length > MAX_STRING_LENGTH
    sanitized = sanitized[0, MAX_STRING_LENGTH] + "...[truncated]"
  end

  sanitized
rescue
  "<sanitization_error>"
end

.sanitize_value(value, depth: 0) ⇒ Object

Sanitize a value (handles strings, hashes, arrays, etc.) Preserves numeric, boolean, and nil types



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/json_logging/sanitizer.rb', line 161

def sanitize_value(value, depth: 0)
  case value
  when String
    sanitize_string(value)
  when Hash
    sanitize_hash(value, depth: depth)
  when Array
    # Limit array size
    sanitized = value.first(MAX_CONTEXT_SIZE).map { |v| sanitize_value(v, depth: depth + 1) }
    sanitized << "[truncated]" if value.size > MAX_CONTEXT_SIZE
    sanitized
  when Exception
    sanitize_exception(value)
  when Numeric, TrueClass, FalseClass, NilClass
    # Preserve numeric, boolean, and nil types
    value
  else
    # For other types, convert to string and sanitize
    sanitize_string(value.to_s)
  end
rescue
  "<unprintable>"
end

.sensitive_filtered_key_name(key_string) ⇒ Object



255
256
257
# File 'lib/json_logging/sanitizer.rb', line 255

def sensitive_filtered_key_name(key_string)
  key_string.gsub(/(?<!^)(?=[A-Z])/, "_").downcase + "_filtered"
end

.sensitive_key?(key) ⇒ Boolean

Check if a key looks sensitive

Returns:

  • (Boolean)


211
212
213
# File 'lib/json_logging/sanitizer.rb', line 211

def sensitive_key?(key)
  SENSITIVE_KEY_PATTERNS.match?(key.to_s)
end

.stringify_primitive_hash(hash) ⇒ Object



249
250
251
252
253
# File 'lib/json_logging/sanitizer.rb', line 249

def stringify_primitive_hash(hash)
  hash.each_with_object({}) do |(key, value), result|
    result[key.to_s] = value
  end
end