Skip to content

Commit 641cc46

Browse files
authored
Merge pull request #16 from DocSpring/nathan/filtering-security-fixes
Keep Rails' parameter filtering working, and filter every value LogStruct logs
2 parents a356c64 + e2a5bbd commit 641cc46

16 files changed

Lines changed: 530 additions & 205 deletions

File tree

‎.cspell/project-words.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ defn
1515
demodulize
1616
docspring
1717
dotenv
18+
duplicable
1819
Dotenv
1920
ESRCH
2021
Fanout
@@ -47,6 +48,7 @@ nonblock
4748
nonblocking
4849
optparse
4950
parseable
51+
passw
5052
pentester
5153
pentesters
5254
popen

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Once initialized (and enabled), the gem automatically includes its modules into
5555
- Lograge request logs include request metadata (request_id, source_ip, user_agent, referer, host, content_type, accept) and custom fields from `lograge_custom_options`
5656
- A Rack middleware is inserted to catch and log errors, including security violations (IP spoofing, CSRF, blocked hosts, etc.)
5757
- Structured logging is set up for ActiveJob, Sidekiq, Shrine, etc.
58-
- Rails `config.filter_parameters` are merged into LogStruct's filters and then cleared (to avoid double filtering). Configure sensitive keys via `LogStruct.config.filters`.
58+
- LogStruct also filters the keys in Rails' `config.filter_parameters`, matched the way Rails matches them (`:passw` filters `user_password`), so the same keys are filtered in every log line. Rails' own list is left in place, so `request.filtered_parameters`, `ActiveRecord::Base.filter_attributes` and other Rails features keep filtering, and values Rails has already replaced are logged as `[FILTERED]`. Configure extra sensitive keys via `LogStruct.config.filters`.
5959
- When `RAILS_LOG_TO_STDOUT` is set, we log to STDOUT only. Otherwise, we log to STDOUT by default without adding a file appender to avoid duplicate logs.
6060

6161
### Default behavior by process type

‎docs/app/docs/filtering-sensitive-data/page.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,30 @@ export default function FilteringSensitiveDataPage() {
8989
:credit_card, :ssn, :social_security`}
9090
</CodeBlock>
9191

92+
<HeadingWithAnchor id="rails-filter-parameters" level={3}>
93+
Rails filter_parameters
94+
</HeadingWithAnchor>
95+
<p className="text-neutral-600 dark:text-neutral-300 mb-4">
96+
LogStruct also filters the keys in your app&apos;s <code>config.filter_parameters</code>,
97+
and matches them the way Rails does: a Symbol or String matches any key that contains it,
98+
ignoring case (<code>:passw</code> filters <code>user_password</code>), and a Regexp is
99+
matched as it is. Rails matches entries with a dot (
100+
<code>&quot;credit_card.code&quot;</code>) against the path of a nested request parameter.
101+
LogStruct checks one key at a time, so it only filters a key that contains the whole entry.
102+
Partial matches don&apos;t apply to the names of LogStruct&apos;s own fields at the top of a
103+
line, so Rails&apos; default <code>:_key</code> doesn&apos;t filter <code>cron_key</code>.
104+
Exact names and Regexps do. Rails&apos; list is left in place, so{' '}
105+
<code>request.filtered_parameters</code> and{' '}
106+
<code>ActiveRecord::Base.filter_attributes</code> keep working, and values Rails has already
107+
replaced are logged as <code>[FILTERED]</code>.
108+
</p>
109+
<p className="text-neutral-600 dark:text-neutral-300 mb-4">
110+
Filter blocks (<code>{'->(key, value) { ... }'}</code>) get copies of the key and value, as
111+
they do in Rails. LogStruct filters a key when its block returns a truthy value or changes
112+
the String it was given (Rails&apos; blocks mask a value by changing it in place). A block
113+
that takes Rails&apos; third argument, the request&apos;s parameters, gets an empty Hash.
114+
</p>
115+
92116
<HeadingWithAnchor id="email-hashing" level={3}>
93117
Email Hashing for Request Tracing
94118
</HeadingWithAnchor>

‎lib/log_struct/concerns/configuration.rb‎

Lines changed: 50 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# typed: strict
22
# frozen_string_literal: true
33

4+
require "active_support/parameter_filter"
5+
require "active_support/core_ext/object/duplicable"
46
require_relative "../configuration"
57

68
module LogStruct
@@ -13,6 +15,11 @@ module ClassMethods
1315
CONSOLE_COMMAND_ARGS = T.let(["console", "c"].freeze, T::Array[String])
1416
EMPTY_ARGV = T.let([].freeze, T::Array[String])
1517
CI_FALSE_VALUES = T.let(["false", "0", "no"].freeze, T::Array[String])
18+
RAILS_PATTERNS_LABEL = "Rails filter_parameters"
19+
RAILS_REGEXPS_LABEL = "Rails filter_parameters (Regexps)"
20+
RAILS_PATTERN_LABELS = T.let([RAILS_PATTERNS_LABEL, RAILS_REGEXPS_LABEL].freeze, T::Array[String])
21+
RAILS_FILTER_MASK = "[FILTERED]"
22+
RAILS_FILTER_PROBE = "value"
1623

1724
sig { params(block: T.proc.params(config: LogStruct::Configuration).void).void }
1825
def configure(&block)
@@ -84,40 +91,26 @@ def merge_rails_filter_parameters!
8491
return unless rails_filter_params.is_a?(Array)
8592
return if rails_filter_params.empty?
8693

87-
symbol_filters = T.let([], T::Array[Symbol])
88-
matchers = T.let([], T::Array[ConfigStruct::FilterMatcher])
89-
leftovers = T.let([], T::Array[T.untyped])
90-
91-
rails_filter_params.each do |entry|
92-
matcher = build_filter_matcher(entry)
93-
94-
if matcher
95-
matchers << matcher
96-
next
97-
end
98-
99-
normalized_symbol = normalize_filter_symbol(entry)
100-
if normalized_symbol
101-
symbol_filters << normalized_symbol
102-
else
103-
leftovers << entry
104-
end
94+
blocks, patterns = rails_filter_params.partition { |entry| callable_filter?(entry) }
95+
regexps, partial_names = patterns.partition { |entry| entry.is_a?(Regexp) }
96+
matchers = blocks.map { |block| build_callable_filter_matcher(block) }
97+
if partial_names.any?
98+
matchers << build_rails_pattern_matcher(partial_names, label: RAILS_PATTERNS_LABEL, matches_logstruct_fields: false)
10599
end
106-
107-
if symbol_filters.any?
108-
config.filters.filter_keys |= symbol_filters
100+
if regexps.any?
101+
matchers << build_rails_pattern_matcher(regexps, label: RAILS_REGEXPS_LABEL, matches_logstruct_fields: true)
109102
end
110103

111-
if matchers.any?
112-
matchers.each do |matcher|
113-
existing = config.filters.filter_matchers.any? do |registered|
114-
registered.label == matcher.label
115-
end
116-
config.filters.filter_matchers << matcher unless existing
117-
end
118-
end
104+
# Exact names also filter LogStruct's own fields, which the partial matches skip
105+
names = patterns.filter_map { |entry| entry.to_s.downcase.to_sym if entry.is_a?(Symbol) || entry.is_a?(String) }
106+
config.filters.filter_keys |= names
119107

120-
replace_filter_parameters(rails_filter_params, leftovers)
108+
registered = config.filters.filter_matchers
109+
# The latest list replaces the patterns merged before
110+
registered.reject! { |matcher| RAILS_PATTERN_LABELS.include?(matcher.label) }
111+
matchers.each do |matcher|
112+
registered << matcher unless registered.any? { |existing| existing.label == matcher.label }
113+
end
121114
end
122115

123116
private
@@ -183,55 +176,52 @@ def ci_build?
183176
!CI_FALSE_VALUES.include?(normalized)
184177
end
185178

186-
sig { params(filter: T.untyped).returns(T.nilable(Symbol)) }
187-
def normalize_filter_symbol(filter)
188-
return filter if filter.is_a?(Symbol)
189-
return filter.downcase.to_sym if filter.is_a?(String)
190-
191-
return nil unless filter.respond_to?(:to_sym)
192-
193-
begin
194-
sym = filter.to_sym
195-
sym.is_a?(Symbol) ? sym : nil
196-
rescue
197-
nil
198-
end
199-
end
200-
201-
sig { params(filter: T.untyped).returns(T.nilable(ConfigStruct::FilterMatcher)) }
202-
def build_filter_matcher(filter)
203-
case filter
204-
when ::Regexp
205-
callable = Kernel.lambda do |key, _value|
206-
filter.match?(key)
207-
end
208-
return ConfigStruct::FilterMatcher.new(callable: callable, label: filter.inspect)
209-
else
210-
return build_callable_filter_matcher(filter) if callable_filter?(filter)
179+
# Rails matches Symbols and Strings as case-insensitive substrings (:passw filters
180+
# "user_password") and Regexps as they are. Entries with a dot match nested request
181+
# parameters by their path in Rails. LogStruct checks one key at a time, so they only
182+
# match a key that contains the whole path.
183+
#
184+
# The substring matches skip LogStruct's own fields (Rails' :_key would hide GoodJob's
185+
# cron_key); their exact names are added to filter_keys instead. A Regexp says exactly
186+
# what it matches, so Regexps apply to every key, LogStruct's fields included.
187+
sig { params(patterns: T::Array[T.untyped], label: String, matches_logstruct_fields: T::Boolean).returns(ConfigStruct::FilterMatcher) }
188+
def build_rails_pattern_matcher(patterns, label:, matches_logstruct_fields:)
189+
parameter_filter = ::ActiveSupport::ParameterFilter.new(patterns, mask: RAILS_FILTER_MASK)
190+
callable = Kernel.lambda do |key, _value|
191+
parameter_filter.filter_param(key, RAILS_FILTER_PROBE) == RAILS_FILTER_MASK
211192
end
212-
213-
nil
193+
ConfigStruct::FilterMatcher.new(callable: callable, label: label, matches_logstruct_fields: matches_logstruct_fields)
214194
end
215195

216196
sig { params(filter: T.untyped).returns(T::Boolean) }
217197
def callable_filter?(filter)
218198
filter.respond_to?(:call)
219199
end
220200

221-
sig { params(filter: T.untyped).returns(T.nilable(ConfigStruct::FilterMatcher)) }
201+
# Rails gives filter blocks copies of the key and value, ignores what they return, and
202+
# logs the value as the block left it: the Rails guides' example reverses it in place,
203+
# and a block can replace it with "[FILTERED]". LogStruct filters a key when its block
204+
# returns a truthy value or changes a String it's given, and never changes the logged
205+
# value itself. Rails passes blocks with three arguments the request's parameters as
206+
# well; LogStruct checks one key at a time, so they get an empty Hash.
207+
sig { params(filter: T.untyped).returns(ConfigStruct::FilterMatcher) }
222208
def build_callable_filter_matcher(filter)
223-
callable = Kernel.lambda do |key, value|
209+
callable = Kernel.lambda do |original_key, original_value|
210+
key = original_key.dup
211+
value = original_value.duplicable? ? original_value.dup : original_value
224212
call_args = case arity_for_filter(filter)
225213
when 0
226214
[]
227215
when 1
228216
[key]
217+
when 3
218+
[key, value, {}]
229219
else
230220
[key, value]
231221
end
232222

233223
result = filter.call(*call_args)
234-
!!result
224+
!!result || (value.is_a?(String) && value != original_value)
235225
rescue ArgumentError
236226
begin
237227
!!filter.call(key)
@@ -251,12 +241,6 @@ def arity_for_filter(filter)
251241
filter.respond_to?(:arity) ? filter.arity : 2
252242
end
253243

254-
sig { params(filter_params: T::Array[T.untyped], leftovers: T::Array[T.untyped]).void }
255-
def replace_filter_parameters(filter_params, leftovers)
256-
filter_params.clear
257-
filter_params.concat(leftovers)
258-
end
259-
260244
sig { params(error: StandardError, filter: T.untyped, key: String).void }
261245
def handle_filter_error(error, filter, key)
262246
context = {

‎lib/log_struct/config_struct/filters.rb‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ class FilterMatcher < T::Struct
88

99
const :callable, T.proc.params(key: String, value: T.untyped).returns(T::Boolean)
1010
const :label, String
11+
# Whether the matcher also applies to the names of LogStruct's own fields at the top of a line.
12+
# LogStruct's matcher for Rails' partial matches doesn't: Rails' default :_key would filter cron_key.
13+
const :matches_logstruct_fields, T::Boolean, default: true
1114

1215
sig { params(key: String, value: T.untyped).returns(T::Boolean) }
1316
def matches?(key, value)
@@ -101,9 +104,9 @@ class Filters < T::Struct
101104
Regexp
102105
)
103106

104-
# Additional filter matchers built from Rails filter_parameters entries that aren't simple symbols.
105-
# 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.
107+
# Matchers for keys that aren't listed by name in filter_keys. Each matcher receives the key (String)
108+
# and optional value, returning true when the pair should be filtered. Includes a regex matcher for
109+
# common sensitive key patterns, and LogStruct adds matchers for Rails' filter_parameters at boot.
107110
prop :filter_matchers,
108111
T::Array[FilterMatcher],
109112
factory: -> {

0 commit comments

Comments
 (0)