chronos-ruby 0.1.0.pre.2 → 0.2.0.pre.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 +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +32 -19
- data/docs/adr/ADR-005-sanitize-before-backlog.md +2 -2
- data/docs/architecture.md +2 -2
- data/docs/compatibility.md +1 -1
- data/docs/configuration.md +10 -1
- data/docs/data-collected.md +10 -7
- data/docs/examples/plain-ruby.md +8 -0
- data/docs/modules/async-queue.md +1 -1
- data/docs/modules/notice-pipeline.md +5 -3
- data/docs/modules/sanitization.md +19 -0
- data/docs/modules/serialization.md +9 -0
- data/docs/modules/transport.md +1 -1
- data/docs/performance.md +14 -2
- data/docs/privacy-lgpd.md +78 -10
- data/docs/troubleshooting.md +2 -2
- data/lib/chronos/adapters/net_http_transport.rb +2 -2
- data/lib/chronos/application/capture_exception.rb +2 -2
- data/lib/chronos/configuration.rb +79 -4
- data/lib/chronos/core/notice.rb +1 -1
- data/lib/chronos/core/payload_serializer.rb +35 -87
- data/lib/chronos/core/runtime_info.rb +1 -1
- data/lib/chronos/core/safe_serializer.rb +119 -0
- data/lib/chronos/core/sanitizer.rb +161 -0
- data/lib/chronos/core/sensitive_value_filter.rb +118 -0
- data/lib/chronos/internal/bounded_queue.rb +1 -1
- data/lib/chronos/internal/worker_pool.rb +1 -1
- data/lib/chronos/ports/transport.rb +1 -1
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +4 -1
- metadata +22 -11
|
@@ -19,12 +19,19 @@ module Chronos
|
|
|
19
19
|
# config.host = 'https://chronos.example.com'
|
|
20
20
|
# snapshot = config.snapshot
|
|
21
21
|
class Configuration
|
|
22
|
+
DEFAULT_BLOCKLIST_KEYS = %w(
|
|
23
|
+
password password_confirmation passwd secret api_key apikey authorization
|
|
24
|
+
token access_token refresh_token private_key client_secret cookie set-cookie
|
|
25
|
+
session credit_card card_number cvv cpf cnpj
|
|
26
|
+
).freeze
|
|
27
|
+
|
|
22
28
|
ATTRIBUTES = [
|
|
23
29
|
:project_id, :project_key, :host, :environment, :app_version,
|
|
24
30
|
:service_name, :root_directory, :logger, :timeout, :open_timeout,
|
|
25
31
|
:queue_size, :workers, :enabled, :error_notifications,
|
|
26
32
|
:ignored_environments, :proxy, :ssl_verify, :user_agent,
|
|
27
|
-
:max_payload_size, :gzip
|
|
33
|
+
:max_payload_size, :gzip, :blocklist_keys, :allowlist_keys,
|
|
34
|
+
:filters, :hash_keys, :anonymize_ip
|
|
28
35
|
].freeze
|
|
29
36
|
|
|
30
37
|
attr_accessor(*ATTRIBUTES)
|
|
@@ -50,6 +57,11 @@ module Chronos
|
|
|
50
57
|
@user_agent = "chronos-ruby/#{Chronos::VERSION}"
|
|
51
58
|
@max_payload_size = 1_048_576
|
|
52
59
|
@gzip = false
|
|
60
|
+
@blocklist_keys = DEFAULT_BLOCKLIST_KEYS.dup
|
|
61
|
+
@allowlist_keys = []
|
|
62
|
+
@filters = []
|
|
63
|
+
@hash_keys = []
|
|
64
|
+
@anonymize_ip = true
|
|
53
65
|
end
|
|
54
66
|
|
|
55
67
|
def snapshot
|
|
@@ -75,6 +87,7 @@ module Chronos
|
|
|
75
87
|
errors << "queue_size must be a positive integer" unless positive_integer?(queue_size)
|
|
76
88
|
errors << "workers must be a positive integer" unless positive_integer?(workers)
|
|
77
89
|
errors << "max_payload_size must be a positive integer" unless positive_integer?(max_payload_size)
|
|
90
|
+
errors.concat(privacy_errors)
|
|
78
91
|
errors
|
|
79
92
|
end
|
|
80
93
|
|
|
@@ -83,8 +96,53 @@ module Chronos
|
|
|
83
96
|
def to_hash
|
|
84
97
|
ATTRIBUTES.each_with_object({}) do |attribute, values|
|
|
85
98
|
value = public_send(attribute)
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
values[attribute] = deep_copy(value)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def privacy_errors
|
|
104
|
+
errors = []
|
|
105
|
+
errors << "blocklist_keys must be an array" unless blocklist_keys.is_a?(Array)
|
|
106
|
+
errors << "allowlist_keys must be an array" unless allowlist_keys.is_a?(Array)
|
|
107
|
+
errors << "hash_keys must be an array" unless hash_keys.is_a?(Array)
|
|
108
|
+
errors.concat(matcher_errors("blocklist_keys", blocklist_keys))
|
|
109
|
+
errors.concat(matcher_errors("allowlist_keys", allowlist_keys))
|
|
110
|
+
errors.concat(matcher_errors("hash_keys", hash_keys))
|
|
111
|
+
errors.concat(filter_errors)
|
|
112
|
+
errors.concat(anonymization_errors)
|
|
113
|
+
errors
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def filter_errors
|
|
117
|
+
return ["filters must be an array"] unless filters.is_a?(Array)
|
|
118
|
+
return [] if filters.all? { |filter| filter.respond_to?(:call) }
|
|
119
|
+
|
|
120
|
+
["filters must contain only callable objects"]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def anonymization_errors
|
|
124
|
+
return [] if anonymize_ip == true || anonymize_ip == false
|
|
125
|
+
|
|
126
|
+
["anonymize_ip must be true or false"]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def matcher_errors(name, values)
|
|
130
|
+
return [] unless values.is_a?(Array)
|
|
131
|
+
return [] if values.all? { |value| value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Regexp) }
|
|
132
|
+
|
|
133
|
+
["#{name} must contain only String, Symbol, or Regexp values"]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def deep_copy(value)
|
|
137
|
+
case value
|
|
138
|
+
when Hash
|
|
139
|
+
value.each_with_object({}) { |(key, child), result| result[deep_copy(key)] = deep_copy(child) }
|
|
140
|
+
when Array
|
|
141
|
+
value.map { |child| deep_copy(child) }
|
|
142
|
+
when String, Regexp
|
|
143
|
+
value.dup
|
|
144
|
+
else
|
|
145
|
+
value
|
|
88
146
|
end
|
|
89
147
|
end
|
|
90
148
|
|
|
@@ -125,7 +183,7 @@ module Chronos
|
|
|
125
183
|
def initialize(values)
|
|
126
184
|
ATTRIBUTES.each do |attribute|
|
|
127
185
|
value = values[attribute]
|
|
128
|
-
|
|
186
|
+
deep_freeze(value)
|
|
129
187
|
instance_variable_set("@#{attribute}", value)
|
|
130
188
|
end
|
|
131
189
|
freeze
|
|
@@ -134,6 +192,23 @@ module Chronos
|
|
|
134
192
|
def enabled_for_environment?
|
|
135
193
|
enabled && !ignored_environments.map(&:to_s).include?(environment.to_s)
|
|
136
194
|
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def deep_freeze(value)
|
|
199
|
+
return value if value.respond_to?(:call)
|
|
200
|
+
|
|
201
|
+
case value
|
|
202
|
+
when Hash
|
|
203
|
+
value.each do |key, child|
|
|
204
|
+
deep_freeze(key)
|
|
205
|
+
deep_freeze(child)
|
|
206
|
+
end
|
|
207
|
+
when Array
|
|
208
|
+
value.each { |child| deep_freeze(child) }
|
|
209
|
+
end
|
|
210
|
+
value.freeze
|
|
211
|
+
end
|
|
137
212
|
end
|
|
138
213
|
end
|
|
139
214
|
end
|
data/lib/chronos/core/notice.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Chronos
|
|
|
4
4
|
#
|
|
5
5
|
# @responsibility Carry exception and diagnostic context through the pipeline.
|
|
6
6
|
# @motivation Keep transport details separate from exception normalization.
|
|
7
|
-
# @limits It does not sanitize, serialize, enqueue, or send itself.
|
|
7
|
+
# @limits It does not sanitize, serialize, enqueue, or send itself; raw values live only in memory.
|
|
8
8
|
# @collaborators NoticeBuilder and PayloadSerializer.
|
|
9
9
|
# @thread_safety Immutable after construction and safe to share.
|
|
10
10
|
# @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails.
|
|
@@ -26,30 +26,27 @@ module Chronos
|
|
|
26
26
|
|
|
27
27
|
# Converts a Notice into the versioned Chronos JSON envelope.
|
|
28
28
|
#
|
|
29
|
-
# @responsibility
|
|
30
|
-
# @motivation
|
|
31
|
-
# @limits
|
|
32
|
-
# @collaborators Notice and SerializedEvent.
|
|
29
|
+
# @responsibility Sanitize an event, normalize it to JSON primitives, and enforce size limits.
|
|
30
|
+
# @motivation Ensure sensitive or unsafe application values never reach transport unchanged.
|
|
31
|
+
# @limits Detection is bounded and does not replace application-specific data governance.
|
|
32
|
+
# @collaborators Notice, Sanitizer, SafeSerializer, and SerializedEvent.
|
|
33
33
|
# @thread_safety Stateless apart from immutable configuration.
|
|
34
34
|
# @compatibility Uses the JSON standard library available on Ruby 2.2.10.
|
|
35
35
|
# @example
|
|
36
36
|
# event = serializer.call(notice)
|
|
37
37
|
# event.body #=> "{...}"
|
|
38
38
|
# @errors Serialization failures are handled by CaptureException.
|
|
39
|
-
# @performance Linear in payload size with bounded depth and collection sizes.
|
|
39
|
+
# @performance Linear in payload size with bounded depth, nodes, and collection sizes.
|
|
40
40
|
class PayloadSerializer
|
|
41
|
-
|
|
42
|
-
MAX_KEYS = 100
|
|
43
|
-
MAX_ITEMS = 100
|
|
44
|
-
MAX_STRING_BYTES = 8192
|
|
45
|
-
|
|
46
|
-
def initialize(config, clock = nil)
|
|
41
|
+
def initialize(config, clock = nil, options = {})
|
|
47
42
|
@config = config
|
|
48
43
|
@clock = clock || proc { Time.now }
|
|
44
|
+
@sanitizer = options[:sanitizer] || Sanitizer.new(config)
|
|
45
|
+
@safe_serializer = options[:safe_serializer] || SafeSerializer.new
|
|
49
46
|
end
|
|
50
47
|
|
|
51
48
|
def call(notice)
|
|
52
|
-
envelope = build_envelope(notice)
|
|
49
|
+
envelope = @safe_serializer.call(@sanitizer.call(build_envelope(notice)))
|
|
53
50
|
body = JSON.generate(envelope)
|
|
54
51
|
body = JSON.generate(compact_envelope(envelope)) if body.bytesize > @config.max_payload_size
|
|
55
52
|
raise Error, "event exceeds max_payload_size" if body.bytesize > @config.max_payload_size
|
|
@@ -66,44 +63,44 @@ module Chronos
|
|
|
66
63
|
"event_type" => "exception",
|
|
67
64
|
"occurred_at" => notice.timestamp,
|
|
68
65
|
"sent_at" => @clock.call.utc.iso8601(6),
|
|
69
|
-
"project_key" =>
|
|
70
|
-
"environment" =>
|
|
66
|
+
"project_key" => @config.project_id,
|
|
67
|
+
"environment" => notice.environment,
|
|
71
68
|
"service" => {
|
|
72
|
-
"name" =>
|
|
73
|
-
"version" =>
|
|
74
|
-
"instance_id" =>
|
|
69
|
+
"name" => @config.service_name,
|
|
70
|
+
"version" => @config.app_version,
|
|
71
|
+
"instance_id" => notice.host
|
|
75
72
|
},
|
|
76
|
-
"runtime" =>
|
|
77
|
-
"context" =>
|
|
73
|
+
"runtime" => notice.runtime,
|
|
74
|
+
"context" => notice.context,
|
|
78
75
|
"payload" => payload(notice)
|
|
79
76
|
}
|
|
80
77
|
end
|
|
81
78
|
|
|
82
79
|
def payload(notice)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
80
|
+
{
|
|
81
|
+
"exception" => {
|
|
82
|
+
"class" => notice.exception_class,
|
|
83
|
+
"message" => notice.message,
|
|
84
|
+
"backtrace" => notice.backtrace,
|
|
85
|
+
"causes" => notice.causes
|
|
86
|
+
},
|
|
87
|
+
"severity" => notice.severity,
|
|
88
|
+
"parameters" => notice.parameters,
|
|
89
|
+
"session" => notice.session,
|
|
90
|
+
"user" => notice.user,
|
|
91
|
+
"versions" => notice.versions,
|
|
92
|
+
"host" => notice.host,
|
|
93
|
+
"process" => notice.process,
|
|
94
|
+
"thread" => notice.thread,
|
|
95
|
+
"tags" => notice.tags,
|
|
96
|
+
"fingerprint" => notice.fingerprint
|
|
97
|
+
}
|
|
101
98
|
end
|
|
102
99
|
|
|
103
100
|
def compact_envelope(envelope)
|
|
104
101
|
payload = envelope["payload"]
|
|
105
102
|
exception = payload["exception"]
|
|
106
|
-
exception["message"] =
|
|
103
|
+
exception["message"] = @safe_serializer.call(exception["message"], :max_string_bytes => 1024)
|
|
107
104
|
exception["backtrace"] = Array(exception["backtrace"]).first(20)
|
|
108
105
|
payload["parameters"] = {"_truncated" => true}
|
|
109
106
|
payload["session"] = {"_truncated" => true}
|
|
@@ -111,55 +108,6 @@ module Chronos
|
|
|
111
108
|
envelope["context"] = {"_truncated" => true}
|
|
112
109
|
envelope
|
|
113
110
|
end
|
|
114
|
-
|
|
115
|
-
def normalize(value, depth)
|
|
116
|
-
return "<maximum depth reached>" if depth >= MAX_DEPTH
|
|
117
|
-
|
|
118
|
-
case value
|
|
119
|
-
when nil, true, false, Integer
|
|
120
|
-
value
|
|
121
|
-
when Float
|
|
122
|
-
value.finite? ? value : value.to_s
|
|
123
|
-
when String, Symbol
|
|
124
|
-
truncate_string(safe_string(value), MAX_STRING_BYTES)
|
|
125
|
-
when Array
|
|
126
|
-
value.first(MAX_ITEMS).map { |child| normalize(child, depth + 1) }
|
|
127
|
-
when Hash
|
|
128
|
-
normalize_hash(value, depth)
|
|
129
|
-
else
|
|
130
|
-
"<#{safe_class_name(value)}>"
|
|
131
|
-
end
|
|
132
|
-
rescue StandardError
|
|
133
|
-
"<unserializable value>"
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def normalize_hash(value, depth)
|
|
137
|
-
result = {}
|
|
138
|
-
value.to_a.first(MAX_KEYS).each do |key, child|
|
|
139
|
-
result[truncate_string(safe_string(key), 256)] = normalize(child, depth + 1)
|
|
140
|
-
end
|
|
141
|
-
result
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def safe_string(value)
|
|
145
|
-
return nil if value.nil?
|
|
146
|
-
|
|
147
|
-
value.to_s.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "�")
|
|
148
|
-
rescue StandardError
|
|
149
|
-
"<unreadable value>"
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def truncate_string(value, limit)
|
|
153
|
-
return value if value.nil? || value.bytesize <= limit
|
|
154
|
-
|
|
155
|
-
value.byteslice(0, limit).to_s.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "�") + "…"
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def safe_class_name(value)
|
|
159
|
-
value.class.name.to_s
|
|
160
|
-
rescue StandardError
|
|
161
|
-
"Object"
|
|
162
|
-
end
|
|
163
111
|
end
|
|
164
112
|
end
|
|
165
113
|
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Core
|
|
3
|
+
# Converts bounded Ruby structures to values accepted by JSON.generate.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Normalize only JSON primitives while enforcing structural budgets.
|
|
6
|
+
# @motivation Prevent application objects, cycles, or invalid encoding from breaking capture.
|
|
7
|
+
# @limits It does not redact secrets; Sanitizer must run before this component.
|
|
8
|
+
# @collaborators PayloadSerializer.
|
|
9
|
+
# @thread_safety Each call owns its traversal state and can run concurrently.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
11
|
+
# @example
|
|
12
|
+
# serializer.call(:status => :ok) #=> {"status"=>"ok"}
|
|
13
|
+
# @errors Individual unreadable values become bounded placeholders.
|
|
14
|
+
# @performance Depth, nodes, keys, items, and string bytes are bounded.
|
|
15
|
+
class SafeSerializer
|
|
16
|
+
DEFAULTS = {
|
|
17
|
+
:max_depth => 10,
|
|
18
|
+
:max_keys => 100,
|
|
19
|
+
:max_items => 100,
|
|
20
|
+
:max_string_bytes => 8192,
|
|
21
|
+
:max_nodes => 2_000
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(options = {})
|
|
25
|
+
@options = DEFAULTS.merge(options).freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(value, overrides = {})
|
|
29
|
+
settings = @options.merge(overrides)
|
|
30
|
+
normalize(value, 0, {}, {:nodes => 0}, settings)
|
|
31
|
+
rescue StandardError
|
|
32
|
+
"<unserializable value>"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def normalize(value, depth, seen, state, settings)
|
|
38
|
+
state[:nodes] += 1
|
|
39
|
+
return "<node limit reached>" if state[:nodes] > settings[:max_nodes]
|
|
40
|
+
return "<maximum depth reached>" if depth >= settings[:max_depth]
|
|
41
|
+
|
|
42
|
+
return normalize_array(value, depth, seen, state, settings) if value.is_a?(Array)
|
|
43
|
+
return normalize_hash(value, depth, seen, state, settings) if value.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
normalize_scalar(value, settings)
|
|
46
|
+
rescue StandardError
|
|
47
|
+
"<unserializable value>"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def normalize_scalar(value, settings)
|
|
51
|
+
case value
|
|
52
|
+
when nil, true, false, Integer
|
|
53
|
+
value
|
|
54
|
+
when Float
|
|
55
|
+
value.finite? ? value : value.to_s
|
|
56
|
+
when String
|
|
57
|
+
truncate_string(safe_string(value), settings[:max_string_bytes])
|
|
58
|
+
when Symbol
|
|
59
|
+
truncate_string(value.to_s, settings[:max_string_bytes])
|
|
60
|
+
else
|
|
61
|
+
"<#{safe_class_name(value)}>"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize_array(value, depth, seen, state, settings)
|
|
66
|
+
return "<circular reference>" if seen[value.object_id]
|
|
67
|
+
|
|
68
|
+
seen[value.object_id] = true
|
|
69
|
+
result = value.first(settings[:max_items]).map do |child|
|
|
70
|
+
normalize(child, depth + 1, seen, state, settings)
|
|
71
|
+
end
|
|
72
|
+
seen.delete(value.object_id)
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize_hash(value, depth, seen, state, settings)
|
|
77
|
+
return "<circular reference>" if seen[value.object_id]
|
|
78
|
+
|
|
79
|
+
seen[value.object_id] = true
|
|
80
|
+
result = {}
|
|
81
|
+
value.to_a.first(settings[:max_keys]).each do |key, child|
|
|
82
|
+
result[safe_key(key)] = normalize(child, depth + 1, seen, state, settings)
|
|
83
|
+
end
|
|
84
|
+
seen.delete(value.object_id)
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def safe_key(value)
|
|
89
|
+
string = if value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Numeric)
|
|
90
|
+
value.to_s
|
|
91
|
+
else
|
|
92
|
+
"<#{safe_class_name(value)}>"
|
|
93
|
+
end
|
|
94
|
+
truncate_string(safe_string(string), 256)
|
|
95
|
+
rescue StandardError
|
|
96
|
+
"<unreadable key>"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def safe_string(value)
|
|
100
|
+
value.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "�")
|
|
101
|
+
rescue StandardError
|
|
102
|
+
"<unreadable value>"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def truncate_string(value, limit)
|
|
106
|
+
return value if value.bytesize <= limit
|
|
107
|
+
|
|
108
|
+
prefix = value.byteslice(0, limit).to_s
|
|
109
|
+
safe_string(prefix) + "…"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def safe_class_name(value)
|
|
113
|
+
value.class.name.to_s
|
|
114
|
+
rescue StandardError
|
|
115
|
+
"Object"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Chronos
|
|
4
|
+
module Core
|
|
5
|
+
# Removes secrets and personal data from event values before serialization.
|
|
6
|
+
#
|
|
7
|
+
# @responsibility Recursively redact configured keys and recognized sensitive strings.
|
|
8
|
+
# @motivation Make privacy protection the default before transport or future persistence.
|
|
9
|
+
# @limits Detection is conservative and cannot replace an application data-governance review.
|
|
10
|
+
# @collaborators Configuration::Snapshot and application-provided filters.
|
|
11
|
+
# @thread_safety Instances are immutable; configured filters must be thread-safe.
|
|
12
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6; independent of frameworks.
|
|
13
|
+
# @example
|
|
14
|
+
# sanitizer.call("password" => "secret") #=> {"password"=>"[FILTERED]"}
|
|
15
|
+
# @errors Unsafe values and failing custom filters become redacted placeholders.
|
|
16
|
+
# @performance Work is linear in the structurally bounded event tree.
|
|
17
|
+
class Sanitizer
|
|
18
|
+
FILTERED = SensitiveValueFilter::FILTERED
|
|
19
|
+
MAX_DEPTH = 10
|
|
20
|
+
MAX_KEYS = 100
|
|
21
|
+
MAX_ITEMS = 100
|
|
22
|
+
MAX_NODES = 2_000
|
|
23
|
+
|
|
24
|
+
def initialize(config)
|
|
25
|
+
@blocklist_names, @blocklist_patterns = compile_matchers(config.blocklist_keys)
|
|
26
|
+
@allowlist_names, @allowlist_patterns = compile_matchers(config.allowlist_keys)
|
|
27
|
+
@hash_names, @hash_patterns = compile_matchers(config.hash_keys)
|
|
28
|
+
@filters = config.filters
|
|
29
|
+
@sensitive_value_filter = SensitiveValueFilter.new(config.anonymize_ip)
|
|
30
|
+
@hash_scope = config.project_id.to_s
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(value)
|
|
35
|
+
sanitize_value(value, nil, true, 0, :nodes => 0, :seen => {})
|
|
36
|
+
rescue StandardError
|
|
37
|
+
FILTERED
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def sanitize_value(value, key, run_filters, depth, state)
|
|
43
|
+
state[:nodes] += 1
|
|
44
|
+
return "<node limit reached>" if state[:nodes] > MAX_NODES
|
|
45
|
+
return "<maximum depth reached>" if depth >= MAX_DEPTH
|
|
46
|
+
|
|
47
|
+
result = sanitize_type(value, depth, state)
|
|
48
|
+
run_filters && key && !@filters.empty? ? filter_value(key, result, depth, state) : result
|
|
49
|
+
rescue StandardError
|
|
50
|
+
FILTERED
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def sanitize_type(value, depth, state)
|
|
54
|
+
return sanitize_hash(value, depth, state) if value.is_a?(Hash)
|
|
55
|
+
return sanitize_array(value, depth, state) if value.is_a?(Array)
|
|
56
|
+
return @sensitive_value_filter.call(value) if value.is_a?(String)
|
|
57
|
+
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def sanitize_hash(value, depth, state)
|
|
62
|
+
return "<circular reference>" if state[:seen][value.object_id]
|
|
63
|
+
|
|
64
|
+
state[:seen][value.object_id] = true
|
|
65
|
+
result = {}
|
|
66
|
+
value.to_a.first(MAX_KEYS).each do |key, child|
|
|
67
|
+
result[key] = sanitize_hash_value(key, child, depth, state)
|
|
68
|
+
end
|
|
69
|
+
state[:seen].delete(value.object_id)
|
|
70
|
+
result
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def sanitize_hash_value(key, value, depth, state)
|
|
74
|
+
return FILTERED if blocked_key?(key) && !allowed_key?(key)
|
|
75
|
+
return hash_value(value) if hashed_key?(key)
|
|
76
|
+
|
|
77
|
+
sanitize_value(value, key, true, depth + 1, state)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def sanitize_array(value, depth, state)
|
|
81
|
+
return "<circular reference>" if state[:seen][value.object_id]
|
|
82
|
+
|
|
83
|
+
state[:seen][value.object_id] = true
|
|
84
|
+
result = value.first(MAX_ITEMS).map do |child|
|
|
85
|
+
sanitize_value(child, nil, true, depth + 1, state)
|
|
86
|
+
end
|
|
87
|
+
state[:seen].delete(value.object_id)
|
|
88
|
+
result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def blocked_key?(key)
|
|
92
|
+
matches_any?(@blocklist_names, @blocklist_patterns, key, true)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def allowed_key?(key)
|
|
96
|
+
matches_any?(@allowlist_names, @allowlist_patterns, key, false)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def hashed_key?(key)
|
|
100
|
+
matches_any?(@hash_names, @hash_patterns, key, false)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def compile_matchers(matchers)
|
|
104
|
+
names = []
|
|
105
|
+
patterns = []
|
|
106
|
+
matchers.each do |matcher|
|
|
107
|
+
matcher.is_a?(Regexp) ? patterns << matcher : names << normalize_key(matcher)
|
|
108
|
+
end
|
|
109
|
+
[names.freeze, patterns.freeze]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def matches_any?(names, patterns, key, fallback)
|
|
113
|
+
candidate = safe_key(key)
|
|
114
|
+
key_name = normalize_key(candidate)
|
|
115
|
+
name_match = names.any? { |name| key_name == name || key_name.end_with?("_#{name}") }
|
|
116
|
+
name_match || patterns.any? { |pattern| pattern =~ candidate }
|
|
117
|
+
rescue StandardError
|
|
118
|
+
fallback
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def normalize_key(value)
|
|
122
|
+
value.to_s.downcase.gsub(/[^a-z0-9]+/, "_").sub(/\A_+/, "").sub(/_+\z/, "")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def safe_key(value)
|
|
126
|
+
return value if value.is_a?(String)
|
|
127
|
+
return value.to_s if value.is_a?(Symbol) || value.is_a?(Numeric)
|
|
128
|
+
|
|
129
|
+
"<#{safe_class_name(value)}>"
|
|
130
|
+
rescue StandardError
|
|
131
|
+
"<unreadable key>"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def hash_value(value)
|
|
135
|
+
scalar = case value
|
|
136
|
+
when String, Symbol, Numeric
|
|
137
|
+
value.to_s
|
|
138
|
+
else
|
|
139
|
+
return FILTERED
|
|
140
|
+
end
|
|
141
|
+
digest = Digest::SHA256.hexdigest("chronos:#{@hash_scope}:#{scalar}")
|
|
142
|
+
"[HASHED_SHA256:#{digest}]"
|
|
143
|
+
rescue StandardError
|
|
144
|
+
FILTERED
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def filter_value(key, value, depth, state)
|
|
148
|
+
filtered = @filters.inject(value) { |current, filter| filter.call(key, current) }
|
|
149
|
+
sanitize_value(filtered, nil, false, depth, state)
|
|
150
|
+
rescue StandardError
|
|
151
|
+
FILTERED
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def safe_class_name(value)
|
|
155
|
+
value.class.name.to_s
|
|
156
|
+
rescue StandardError
|
|
157
|
+
"Object"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|