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.
@@ -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
- value = value.dup if value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(String)
87
- values[attribute] = value
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
- value.freeze if value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(String)
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
@@ -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 Normalize values to JSON primitives and enforce size limits.
30
- # @motivation Prevent arbitrary application objects or invalid encodings from breaking capture.
31
- # @limits Version 0.1 applies structural limits, not the advanced privacy rules from 0.2.
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
- MAX_DEPTH = 10
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" => safe_string(@config.project_id),
70
- "environment" => safe_string(notice.environment),
66
+ "project_key" => @config.project_id,
67
+ "environment" => notice.environment,
71
68
  "service" => {
72
- "name" => safe_string(@config.service_name),
73
- "version" => safe_string(@config.app_version),
74
- "instance_id" => safe_string(notice.host)
69
+ "name" => @config.service_name,
70
+ "version" => @config.app_version,
71
+ "instance_id" => notice.host
75
72
  },
76
- "runtime" => normalize(notice.runtime, 0),
77
- "context" => normalize(notice.context, 0),
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
- normalize({
84
- "exception" => {
85
- "class" => notice.exception_class,
86
- "message" => notice.message,
87
- "backtrace" => notice.backtrace,
88
- "causes" => notice.causes
89
- },
90
- "severity" => notice.severity,
91
- "parameters" => notice.parameters,
92
- "session" => notice.session,
93
- "user" => notice.user,
94
- "versions" => notice.versions,
95
- "host" => notice.host,
96
- "process" => notice.process,
97
- "thread" => notice.thread,
98
- "tags" => notice.tags,
99
- "fingerprint" => notice.fingerprint
100
- }, 0)
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"] = truncate_string(exception["message"], 1024)
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
@@ -22,7 +22,7 @@ module Chronos
22
22
  },
23
23
  :host => safe_hostname,
24
24
  :process => {"pid" => Process.pid},
25
- :thread => {"id" => Thread.current.object_id.to_s}
25
+ :thread => {"id" => "0x#{Thread.current.object_id.to_s(16)}"}
26
26
  }
27
27
  end
28
28
 
@@ -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