octri 1.0.1 → 1.2.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 +23 -0
  3. data/README.md +33 -0
  4. data/lib/octri.rb +142 -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: 30c386e0986ecaad25cd764612997a14fe61120b12257659199155b3ca982cb3
4
+ data.tar.gz: 40e41bdde6dc0f3da903d909fe721588d461fde3e76a6f1958a7d6656ea37506
5
5
  SHA512:
6
- metadata.gz: 76178a0d41df4536d8beb2b7a7a00c007414f618783795dbc9aa511977a037015f531275f5719d8ede4765a9174dc8b587faf5e398a3c379a0dc5ed66f4f066d
7
- data.tar.gz: 35cdb51b77150bfe8f23b0e0827b3758d3e327137a85728208fabae9c2e775c5708d483451e67697d4b0919e6f8600c021988e9225eb9df93886349b1c617c29
6
+ metadata.gz: d0c03fdeeb42ef797b3b592f119946337e1c17c2fdecc5b813c08376e6dd0db046ed3c774432c826ed9d731ca68998f87167089c801cc73d2c90762168d90cee
7
+ data.tar.gz: 25338e81385ba2822595d943f7b5c57f51e8cad24640d9d83946ac64743e5a57d599ebfb03d67c834b9a277dad9ed456555b18e1a7d00155288801518be06caf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0
4
+
5
+ - Direct identifiers are now redacted by key, the same way credentials are:
6
+ `email`, `phone`, `address`, first/last/full name, `username`, `userAgent`,
7
+ passport, tax and national ids, dates of birth, postal codes, coordinates
8
+ and `ipAddress`, wherever the word appears in a key (`billingEmail`,
9
+ `customer_phone_number`). This matches what every generated SDK already did.
10
+ - The user's `email` is therefore `[redacted]` before sending. The user's `id`
11
+ still survives; it is the identity the dashboard counts affected users by.
12
+
13
+ ## 1.1.0
14
+
15
+ - Payloads are now scrubbed before they are sent. Values under keys that name a
16
+ credential (`password`, `secret`, `token`, `apiKey`, `authorization`,
17
+ `cookie`, `ssn` and the rest) are replaced with `[redacted]` at any depth, and
18
+ free text is swept for bearer tokens, JWTs, Luhn-valid card numbers and email
19
+ addresses.
20
+ - `add_scrub_fields` adds your own key names to that list.
21
+ - `set_before_send` hands you each payload before it goes out; return
22
+ `nil` to drop the event. Redaction runs after the hook.
23
+ - The `user` field keeps the identity you set, since that is the point of it.
24
+ Credential-shaped keys inside it are still redacted.
25
+
3
26
  ## 1.0.1
4
27
 
5
28
  - 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,31 @@ 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
+ # Credentials, then direct identifiers, matched the same way: `phone` also
38
+ # covers `phoneNumber` and `customerPhone`, `address` covers `ipAddress` and
39
+ # `billingAddress`. Bare `ip`, `url` and `name` are deliberately absent: as
40
+ # substrings they would take `zip`, `curl` and the error name with them.
41
+ SCRUB_KEYS = %w[
42
+ password passwd passphrase secret token apikey authorization credential
43
+ cookie session privatekey accesskey cardnumber creditcard cvv ssn
44
+ email phone address firstname lastname fullname username useragent passport taxid
45
+ nationalid dateofbirth birthdate birthday postalcode zipcode latitude longitude socialsecurity ipaddress
46
+ ].freeze
47
+
48
+ REDACTED = "[redacted]"
49
+ TRUNCATED = "[truncated]"
50
+ CIRCULAR = "[circular]"
51
+ # Deep enough for real context objects, shallow enough to stay cheap.
52
+ MAX_SCRUB_DEPTH = 8
53
+
54
+ BEARER_RE = %r{\bbearer\s+[\w.~+/-]+=*}i.freeze
55
+ JWT_RE = /\beyJ[\w-]+\.[\w-]+\.[\w-]+/.freeze
56
+ DIGIT_RUN_RE = /\b(?:\d[ -]?){12,18}\d\b/.freeze
57
+ EMAIL_RE = /[\w.%+-]+@[\w-]+(?:\.[\w-]+)+/.freeze
58
+
33
59
  class << self
34
60
  # Configure the reporter. Call once at startup before mounting the middleware.
35
61
  def init(url:, token: nil, environment:, release: nil)
@@ -134,6 +160,33 @@ module Octri
134
160
  patch_net_http if http
135
161
  end
136
162
 
163
+ # ── Scrubbing ─────────────────────────────────────────────────────────────
164
+
165
+ # Redact more key names, on top of the built-in list. Matching ignores case
166
+ # and separators and is a substring test, so `account` also covers
167
+ # `account_number`.
168
+ #
169
+ # Octri.add_scrub_fields("account_number", "otp")
170
+ def add_scrub_fields(*fields)
171
+ @extra_scrub_keys ||= []
172
+ fields.flatten.each do |field|
173
+ key = normalize_key(field)
174
+ @extra_scrub_keys << key unless key.empty? || @extra_scrub_keys.include?(key)
175
+ end
176
+ @extra_scrub_keys
177
+ end
178
+
179
+ # Run a block on every payload just before it is sent. Return the payload
180
+ # (editing it in place is fine) to send it, or nil to drop the event:
181
+ #
182
+ # Octri.set_before_send { |payload| payload[:path] == "/health" ? nil : payload }
183
+ #
184
+ # Redaction still runs afterwards, so a hook cannot leak a credential by
185
+ # accident. Call it without a block to remove the hook.
186
+ def set_before_send(&hook)
187
+ @before_send = hook
188
+ end
189
+
137
190
  # ── Reporting ──────────────────────────────────────────────────────────────
138
191
  # Log an application event directly, without a generated Octri API SDK.
139
192
  # Delivery is fire-and-forget; event_id may be supplied for idempotency.
@@ -333,15 +386,103 @@ module Octri
333
386
  lines
334
387
  end
335
388
 
389
+ def normalize_key(key)
390
+ key.to_s.downcase.gsub(/[^a-z0-9]/, "")
391
+ end
392
+
393
+ def secret_key?(key)
394
+ normalized = normalize_key(key)
395
+ return false if normalized.empty?
396
+
397
+ SCRUB_KEYS.any? { |candidate| normalized.include?(candidate) } ||
398
+ (@extra_scrub_keys || []).any? { |candidate| normalized.include?(candidate) }
399
+ end
400
+
401
+ # Tells a card number from the order ids and timestamps that look like one.
402
+ def passes_luhn?(digits)
403
+ sum = 0
404
+ double = false
405
+ digits.reverse.each_char do |char|
406
+ digit = char.ord - 48
407
+ if double
408
+ digit *= 2
409
+ digit -= 9 if digit > 9
410
+ end
411
+ sum += digit
412
+ double = !double
413
+ end
414
+ (sum % 10).zero?
415
+ end
416
+
417
+ # Removes credentials and personal data that leaked into free text.
418
+ def scrub_text(value)
419
+ return value if value.empty?
420
+
421
+ value
422
+ .gsub(BEARER_RE, REDACTED)
423
+ .gsub(JWT_RE, REDACTED)
424
+ .gsub(DIGIT_RUN_RE) { |run| passes_luhn?(run.delete("^0-9")) ? REDACTED : run }
425
+ .gsub(EMAIL_RE, REDACTED)
426
+ end
427
+
428
+ # Redacts credential-shaped keys anywhere in the payload, and strips secrets
429
+ # out of the free text around them. `user` is the field you deliberately fill
430
+ # with an identity, so its strings are left alone; its keys are still checked.
431
+ def scrub_value(value, depth, text, seen)
432
+ case value
433
+ when String
434
+ text ? scrub_text(value) : value
435
+ when Hash, Array
436
+ return TRUNCATED if depth >= MAX_SCRUB_DEPTH
437
+ # Walking a copy means a cycle would recurse forever, and a context hash
438
+ # holding a reference back to itself is worth surviving.
439
+ return CIRCULAR if seen.include?(value.object_id)
440
+
441
+ seen.add(value.object_id)
442
+ begin
443
+ scrub_collection(value, depth, text, seen)
444
+ ensure
445
+ seen.delete(value.object_id)
446
+ end
447
+ else
448
+ value
449
+ end
450
+ end
451
+
452
+ def scrub_collection(value, depth, text, seen)
453
+ return value.map { |item| scrub_value(item, depth + 1, text, seen) } if value.is_a?(Array)
454
+
455
+ value.each_with_object({}) do |(key, nested), out|
456
+ out[key] = if secret_key?(key)
457
+ REDACTED
458
+ else
459
+ scrub_value(nested, depth + 1, text && key.to_s != "user", seen)
460
+ end
461
+ end
462
+ end
463
+
464
+ # The last thing every payload passes through. Both the hook and the
465
+ # redaction live here rather than in the capture methods, so nothing can be
466
+ # reported around them.
467
+ def scrub_payload(payload)
468
+ hooked = @before_send ? @before_send.call(payload) : payload
469
+ return nil unless hooked.is_a?(Hash)
470
+
471
+ scrub_value(hooked, 0, true, Set.new)
472
+ end
473
+
336
474
  def post_json(path, payload, idempotency_key:)
337
475
  cfg = @config
338
476
  return if cfg.nil?
339
477
  return unless safe_idempotency_key?(idempotency_key)
340
478
  return if cfg.token && cfg.token != "" && !safe_header_value?(cfg.token)
341
479
 
480
+ scrubbed = scrub_payload(payload)
481
+ return if scrubbed.nil?
482
+
342
483
  begin
343
484
  Thread.new do
344
- body = JSON.generate(payload)
485
+ body = JSON.generate(scrubbed)
345
486
  uri = URI("#{cfg.url}#{path}")
346
487
  http = Net::HTTP.new(uri.host, uri.port)
347
488
  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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Octri