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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +33 -0
- data/lib/octri.rb +142 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30c386e0986ecaad25cd764612997a14fe61120b12257659199155b3ca982cb3
|
|
4
|
+
data.tar.gz: 40e41bdde6dc0f3da903d909fe721588d461fde3e76a6f1958a7d6656ea37506
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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"
|