octri 1.0.1 → 1.1.0

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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +33 -0
  4. data/lib/octri.rb +136 -1
  5. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0bae18c311de3d67d561b38c42c0c32ec50dd6cdbdd69fbb33671658fb0bb1c4
4
- data.tar.gz: fb7db736fa76a53ddbf395d2bcc94b539e7cb04b1fb3c4d67103d6b60651e3b3
3
+ metadata.gz: 15e020993bd573362db0191b73266111e568d1e12d0977b2ce1b82c85c0bd04d
4
+ data.tar.gz: a51dd16d8ff052a321cd2dbcd9701ca0a3f6ea5e73874385859c3a90720bf580
5
5
  SHA512:
6
- metadata.gz: 76178a0d41df4536d8beb2b7a7a00c007414f618783795dbc9aa511977a037015f531275f5719d8ede4765a9174dc8b587faf5e398a3c379a0dc5ed66f4f066d
7
- data.tar.gz: 35cdb51b77150bfe8f23b0e0827b3758d3e327137a85728208fabae9c2e775c5708d483451e67697d4b0919e6f8600c021988e9225eb9df93886349b1c617c29
6
+ metadata.gz: '0109372656982385c8ed24a765f5e0b6235f2a24636276e8a70bef540350f09f55869c16bc412b8d17f83f51695bbd6b34d995d8bb5e2abdbdfb417b6d0cdadc'
7
+ data.tar.gz: 75d97ab367c60d668d95446052ea5b6e85f17b870d76b6f0893ad69e089935e4935ad7ea7f6d104df626f4438efefd2329a4d0c8cf29dedc7974d2ba6c3bf936
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ - Payloads are now scrubbed before they are sent. Values under keys that name a
6
+ credential (`password`, `secret`, `token`, `apiKey`, `authorization`,
7
+ `cookie`, `ssn` and the rest) are replaced with `[redacted]` at any depth, and
8
+ free text is swept for bearer tokens, JWTs, Luhn-valid card numbers and email
9
+ addresses.
10
+ - `add_scrub_fields` adds your own key names to that list.
11
+ - `set_before_send` hands you each payload before it goes out; return
12
+ `nil` to drop the event. Redaction runs after the hook.
13
+ - The `user` field keeps the identity you set, since that is the point of it.
14
+ Credential-shaped keys inside it are still redacted.
15
+
3
16
  ## 1.0.1
4
17
 
5
18
  - Source context is now read only for frames in your own code, skips files
data/README.md CHANGED
@@ -102,6 +102,39 @@ end
102
102
 
103
103
  ---
104
104
 
105
+ ## What gets redacted
106
+
107
+ Payloads are scrubbed on the way out, so a credential that ended up in a log
108
+ line or a context object never reaches the dashboard.
109
+
110
+ Any key whose name looks like a credential (`password`, `secret`, `token`,
111
+ `apiKey`, `authorization`, `cookie`, `ssn` and the rest of the usual list) has
112
+ its value replaced with `[redacted]`, at any depth. Matching ignores case and
113
+ separators, so `api_key`, `apiKey` and `X-API-KEY` are all the same key.
114
+
115
+ Free text is swept too: the message, an error message and its stack, and
116
+ anything else you send as a string. Bearer tokens, JWTs, card numbers and email
117
+ addresses come out as `[redacted]`. A card number has to pass the Luhn check
118
+ first, so an order number or a timestamp survives.
119
+
120
+ `user` is the exception. It is the field you fill with an identity on purpose,
121
+ so `user.email` is reported exactly as you set it. Credential-shaped keys inside
122
+ it are still redacted.
123
+
124
+ Add your own key names:
125
+
126
+ ```ruby
127
+ Octri.add_scrub_fields("account_number", "otp")
128
+ ```
129
+
130
+ Or take the payload yourself, and return `nil` to drop the event:
131
+
132
+ ```ruby
133
+ Octri.set_before_send { |payload| payload[:path] == "/health" ? nil : payload }
134
+ ```
135
+
136
+ Redaction runs after your hook, so a hook cannot leak a credential by accident.
137
+
105
138
  ## The rest of Octri
106
139
 
107
140
  | Product | What it does |
data/lib/octri.rb CHANGED
@@ -14,6 +14,7 @@
14
14
  # use Octri::Rack
15
15
 
16
16
  require "securerandom"
17
+ require "set"
17
18
  require "net/http"
18
19
  require "uri"
19
20
  require "json"
@@ -30,6 +31,25 @@ module Octri
30
31
  MAX_IDEMPOTENCY_KEY_LENGTH = 256
31
32
  TRACEPARENT_RE = /\A00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}\z/i.freeze
32
33
 
34
+ # Keys whose value never leaves the process. Compared against the key with case
35
+ # and separators removed, so `api_key`, `apiKey` and `API-KEY` all match
36
+ # `apikey`, and the test is a substring one, so `stripe_secret_key` matches too.
37
+ SCRUB_KEYS = %w[
38
+ password passwd passphrase secret token apikey authorization credential
39
+ cookie session privatekey accesskey cardnumber creditcard cvv ssn
40
+ ].freeze
41
+
42
+ REDACTED = "[redacted]"
43
+ TRUNCATED = "[truncated]"
44
+ CIRCULAR = "[circular]"
45
+ # Deep enough for real context objects, shallow enough to stay cheap.
46
+ MAX_SCRUB_DEPTH = 8
47
+
48
+ BEARER_RE = %r{\bbearer\s+[\w.~+/-]+=*}i.freeze
49
+ JWT_RE = /\beyJ[\w-]+\.[\w-]+\.[\w-]+/.freeze
50
+ DIGIT_RUN_RE = /\b(?:\d[ -]?){12,18}\d\b/.freeze
51
+ EMAIL_RE = /[\w.%+-]+@[\w-]+(?:\.[\w-]+)+/.freeze
52
+
33
53
  class << self
34
54
  # Configure the reporter. Call once at startup before mounting the middleware.
35
55
  def init(url:, token: nil, environment:, release: nil)
@@ -134,6 +154,33 @@ module Octri
134
154
  patch_net_http if http
135
155
  end
136
156
 
157
+ # ── Scrubbing ─────────────────────────────────────────────────────────────
158
+
159
+ # Redact more key names, on top of the built-in list. Matching ignores case
160
+ # and separators and is a substring test, so `account` also covers
161
+ # `account_number`.
162
+ #
163
+ # Octri.add_scrub_fields("account_number", "otp")
164
+ def add_scrub_fields(*fields)
165
+ @extra_scrub_keys ||= []
166
+ fields.flatten.each do |field|
167
+ key = normalize_key(field)
168
+ @extra_scrub_keys << key unless key.empty? || @extra_scrub_keys.include?(key)
169
+ end
170
+ @extra_scrub_keys
171
+ end
172
+
173
+ # Run a block on every payload just before it is sent. Return the payload
174
+ # (editing it in place is fine) to send it, or nil to drop the event:
175
+ #
176
+ # Octri.set_before_send { |payload| payload[:path] == "/health" ? nil : payload }
177
+ #
178
+ # Redaction still runs afterwards, so a hook cannot leak a credential by
179
+ # accident. Call it without a block to remove the hook.
180
+ def set_before_send(&hook)
181
+ @before_send = hook
182
+ end
183
+
137
184
  # ── Reporting ──────────────────────────────────────────────────────────────
138
185
  # Log an application event directly, without a generated Octri API SDK.
139
186
  # Delivery is fire-and-forget; event_id may be supplied for idempotency.
@@ -333,15 +380,103 @@ module Octri
333
380
  lines
334
381
  end
335
382
 
383
+ def normalize_key(key)
384
+ key.to_s.downcase.gsub(/[^a-z0-9]/, "")
385
+ end
386
+
387
+ def secret_key?(key)
388
+ normalized = normalize_key(key)
389
+ return false if normalized.empty?
390
+
391
+ SCRUB_KEYS.any? { |candidate| normalized.include?(candidate) } ||
392
+ (@extra_scrub_keys || []).any? { |candidate| normalized.include?(candidate) }
393
+ end
394
+
395
+ # Tells a card number from the order ids and timestamps that look like one.
396
+ def passes_luhn?(digits)
397
+ sum = 0
398
+ double = false
399
+ digits.reverse.each_char do |char|
400
+ digit = char.ord - 48
401
+ if double
402
+ digit *= 2
403
+ digit -= 9 if digit > 9
404
+ end
405
+ sum += digit
406
+ double = !double
407
+ end
408
+ (sum % 10).zero?
409
+ end
410
+
411
+ # Removes credentials and personal data that leaked into free text.
412
+ def scrub_text(value)
413
+ return value if value.empty?
414
+
415
+ value
416
+ .gsub(BEARER_RE, REDACTED)
417
+ .gsub(JWT_RE, REDACTED)
418
+ .gsub(DIGIT_RUN_RE) { |run| passes_luhn?(run.delete("^0-9")) ? REDACTED : run }
419
+ .gsub(EMAIL_RE, REDACTED)
420
+ end
421
+
422
+ # Redacts credential-shaped keys anywhere in the payload, and strips secrets
423
+ # out of the free text around them. `user` is the field you deliberately fill
424
+ # with an identity, so its strings are left alone; its keys are still checked.
425
+ def scrub_value(value, depth, text, seen)
426
+ case value
427
+ when String
428
+ text ? scrub_text(value) : value
429
+ when Hash, Array
430
+ return TRUNCATED if depth >= MAX_SCRUB_DEPTH
431
+ # Walking a copy means a cycle would recurse forever, and a context hash
432
+ # holding a reference back to itself is worth surviving.
433
+ return CIRCULAR if seen.include?(value.object_id)
434
+
435
+ seen.add(value.object_id)
436
+ begin
437
+ scrub_collection(value, depth, text, seen)
438
+ ensure
439
+ seen.delete(value.object_id)
440
+ end
441
+ else
442
+ value
443
+ end
444
+ end
445
+
446
+ def scrub_collection(value, depth, text, seen)
447
+ return value.map { |item| scrub_value(item, depth + 1, text, seen) } if value.is_a?(Array)
448
+
449
+ value.each_with_object({}) do |(key, nested), out|
450
+ out[key] = if secret_key?(key)
451
+ REDACTED
452
+ else
453
+ scrub_value(nested, depth + 1, text && key.to_s != "user", seen)
454
+ end
455
+ end
456
+ end
457
+
458
+ # The last thing every payload passes through. Both the hook and the
459
+ # redaction live here rather than in the capture methods, so nothing can be
460
+ # reported around them.
461
+ def scrub_payload(payload)
462
+ hooked = @before_send ? @before_send.call(payload) : payload
463
+ return nil unless hooked.is_a?(Hash)
464
+
465
+ scrub_value(hooked, 0, true, Set.new)
466
+ end
467
+
336
468
  def post_json(path, payload, idempotency_key:)
337
469
  cfg = @config
338
470
  return if cfg.nil?
339
471
  return unless safe_idempotency_key?(idempotency_key)
340
472
  return if cfg.token && cfg.token != "" && !safe_header_value?(cfg.token)
341
473
 
474
+ scrubbed = scrub_payload(payload)
475
+ return if scrubbed.nil?
476
+
342
477
  begin
343
478
  Thread.new do
344
- body = JSON.generate(payload)
479
+ body = JSON.generate(scrubbed)
345
480
  uri = URI("#{cfg.url}#{path}")
346
481
  http = Net::HTTP.new(uri.host, uri.port)
347
482
  http.use_ssl = uri.scheme == "https"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: octri
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Octri