realuptime-errors 0.1.0 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cfb6b588d319a4a1ceb6dd9a50d0520b494b4e1066056ce4b87261c97237c130
4
- data.tar.gz: a5026d3e87519f3f6ddd140da1e25433b410c970548499f89a5e439d8de96ea8
3
+ metadata.gz: 288a63586cd55e7e41a173f368ab8321080037f6d91e7e67075088121d4b59bd
4
+ data.tar.gz: 1e2e73b36ea00e394abe1b1111249e130da6f9ef6a17fbf1715a9426bfb1278c
5
5
  SHA512:
6
- metadata.gz: 970d101abb8765cff0170a9a19fcf5a1b82697349334c6a463d26ed56f7520e0fb623e91e80b80c27090dee79b0076f55e2b13921103056e186ac5021e67cddc
7
- data.tar.gz: c05b0bffcd6ca1079e4c278c132fc8a090b73caf4796b5c7a90bcca78fcedf6598cf72b65760bb281e4ee1b2030dbedd72e1c2946cdf213cc900bc52a17cee25
6
+ metadata.gz: 5efbe30a69ed3b6b0a895bfe281d24cd4bb62ae251d591b7d931eb54069f5e1057e2b49f92da398b0684dd4ba356ac3e8ae4b30bb2a198d9e2ed5dfcab456691
7
+ data.tar.gz: 3f9a1392ae7b296ff85149dcc1e175cb8689d1e2460e37e1206b33c330040b840b759897131cc7feae4890533f55b52f3242dea2a4c2f758a28003748d4672ef
data/README.md CHANGED
@@ -11,10 +11,15 @@ they merge upstream.
11
11
 
12
12
  ## Install
13
13
 
14
- Installs straight from GitHub today. npm, PyPI, RubyGems, and Packagist packages are coming; this note disappears the day they ship.
14
+ Installable from the package registry (primary) or straight from this mirror's git history (e.g. to pin an exact commit).
15
15
 
16
16
  ```ruby
17
17
  # Gemfile
18
+ gem "realuptime-errors"
19
+ ```
20
+
21
+ ```ruby
22
+ # or, straight from this mirror
18
23
  gem "realuptime-errors", git: "https://github.com/RealUptimeHQ/realuptime-errors-ruby"
19
24
  ```
20
25
 
@@ -32,7 +37,7 @@ Realuptime::Errors.init(
32
37
  Realuptime::Errors.capture_exception(some_error)
33
38
  ```
34
39
 
35
- Rails: `gem "realuptime-errors", git: "...", require: "realuptime/errors/rails"`,
40
+ Rails: `gem "realuptime-errors", require: "realuptime/errors/rails"`,
36
41
  then set `Rails.application.config.realuptime_errors.dsn` (or
37
42
  `ENV["REALUPTIME_ERRORS_DSN"]`); the Railtie installs the Rack middleware
38
43
  and the ActiveJob hook.
@@ -86,7 +91,7 @@ ruby -Ilib -Itest -e 'Dir["test/*_test.rb"].each { |f| require "./#{f}" }'
86
91
 
87
92
  ## Version
88
93
 
89
- This mirror tracks SDK_VERSION `0.1.0` in `lib/realuptime/errors/version.rb`,
94
+ This mirror tracks SDK_VERSION `0.1.1` in `lib/realuptime/errors/version.rb`,
90
95
  the string every event actually carries on the wire.
91
96
 
92
97
  ## License
@@ -13,7 +13,7 @@ module Realuptime
13
13
  # gem "realuptime-errors", require: "realuptime/errors/rails"
14
14
  #
15
15
  # # config/initializers/realuptime_errors.rb (or ENV REALUPTIME_ERRORS_DSN)
16
- # Rails.application.config.realuptime_errors.dsn = "https://realuptime.io/api/errors/v1/ingest/rue_..."
16
+ # Rails.application.config.realuptime_errors.dsn = "https://ingest.realuptime.io/api/errors/v1/ingest/rue_..."
17
17
  # Rails.application.config.realuptime_errors.release = ENV["GIT_SHA"]
18
18
  #
19
19
  # What the Railtie installs, all through the ONE capture/scrub/transport
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Realuptime
4
4
  module Errors
5
- # PII scrub-by-default, client-side (docs/errors-plan.md, "PII
5
+ # PII scrub-by-default, client-side (the internal-docs repo's errors-plan.md, "PII
6
6
  # scrub-by-default, specified"). A byte-for-byte port of
7
7
  # packages/errors-js/scrub.ts and packages/errors-py/realuptime_errors.py's
8
8
  # scrub half; the shared vectors (scrub-vectors.json, vendored into this
@@ -30,13 +30,41 @@ module Realuptime
30
30
  REMOVED_USER_FIELDS = %w[user.email user.username].freeze
31
31
 
32
32
  JWT_RE = /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/.freeze
33
- PREFIXED_KEY_RE = /\b(?:sk|pk|rk|ghp|gho|ghs|xox[a-z]|rua|rue|ru_live)_[A-Za-z0-9_-]{8,}/.freeze
33
+ PREFIXED_KEY_RE = /\b(?:sk|pk|rk|ghp|gho|ghs|xox[a-z]|rua|rue|ru_live|whsec)_[A-Za-z0-9_-]{8,}/.freeze
34
+ AWS_KEY_RE = /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/.freeze
34
35
  CARD_RE = /(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)/.freeze
35
36
  HEX_RE = /\b[0-9a-fA-F]{32,}\b/.freeze
36
37
  BASE64_RE = /(?<![A-Za-z0-9+_=-])[A-Za-z0-9+_-]{40,}={0,2}/.freeze
38
+ URL_CREDS_RE = %r{\b([a-zA-Z][a-zA-Z0-9+.-]*)://[^\s/@]+:[^\s/@]+@}.freeze
39
+
40
+ # Key-name denylist (REA-474): see packages/errors-js/scrub.ts's module
41
+ # doc for the full rationale. Matched against whole camelCase/
42
+ # snake_case segments (and contiguous joins of them) so "tokenizer"
43
+ # and "authentic" never match "token" / "auth".
44
+ KEY_DENYLIST = %w[
45
+ password passwd secret token apikey auth authorization
46
+ credential credentials privatekey accesskey session
47
+ ].freeze
37
48
 
38
49
  module_function
39
50
 
51
+ def key_segments(key)
52
+ spaced = key.to_s.gsub(/([a-z0-9])([A-Z])/, '\1_\2')
53
+ spaced.split(/[^a-zA-Z0-9]+/).reject(&:empty?).map(&:downcase)
54
+ end
55
+
56
+ def key_denylisted?(key)
57
+ segments = key_segments(key)
58
+ segments.each_index do |i|
59
+ joined = +""
60
+ (i...segments.length).each do |j|
61
+ joined << segments[j]
62
+ return true if KEY_DENYLIST.include?(joined)
63
+ end
64
+ end
65
+ false
66
+ end
67
+
40
68
  def luhn_valid?(digits)
41
69
  total = 0
42
70
  double = false
@@ -52,10 +80,12 @@ module Realuptime
52
80
  (total % 10).zero?
53
81
  end
54
82
 
55
- # The five pattern rules over one string. Pure.
83
+ # The pattern rules over one string, in the shared order. Pure.
56
84
  def scrub_string(value)
57
- out = value.gsub(JWT_RE, SCRUBBED)
85
+ out = value.gsub(URL_CREDS_RE) { "#{Regexp.last_match(1)}://#{SCRUBBED}@" }
86
+ out = out.gsub(JWT_RE, SCRUBBED)
58
87
  out = out.gsub(PREFIXED_KEY_RE, SCRUBBED)
88
+ out = out.gsub(AWS_KEY_RE, SCRUBBED)
59
89
  out = out.gsub(CARD_RE) do |run|
60
90
  digits = run.delete(" -")
61
91
  digits.length >= 13 && digits.length <= 19 && luhn_valid?(digits) ? SCRUBBED : run
@@ -64,14 +94,21 @@ module Realuptime
64
94
  out.gsub(BASE64_RE, SCRUBBED)
65
95
  end
66
96
 
67
- # A flat string map (tags, custom context, device, frame locals):
68
- # VALUES pass the pattern rules, KEYS are left alone (a key is a label
69
- # the integrator chose).
97
+ # A flat string map (tags, custom context, device, frame locals): a
98
+ # key denylisted by name (REA-474) replaces the value whole,
99
+ # regardless of shape; otherwise the value passes the pattern rules.
100
+ # Keys themselves are always left alone.
70
101
  def scrub_string_map(value)
71
102
  return value unless value.is_a?(Hash)
72
103
 
73
104
  value.each_with_object({}) do |(name, entry), out|
74
- out[name] = entry.is_a?(String) ? scrub_string(entry) : entry
105
+ out[name] = if !entry.is_a?(String)
106
+ entry
107
+ elsif key_denylisted?(name)
108
+ SCRUBBED
109
+ else
110
+ scrub_string(entry)
111
+ end
75
112
  end
76
113
  end
77
114
 
@@ -134,7 +171,7 @@ module Realuptime
134
171
  if headers.is_a?(Hash)
135
172
  request["headers"] = headers.each_with_object({}) do |(name, value), scrubbed|
136
173
  lower = name.to_s.downcase
137
- scrubbed[name] = if REMOVED_HEADERS.include?(lower) && !allowed.include?(lower)
174
+ scrubbed[name] = if (REMOVED_HEADERS.include?(lower) || key_denylisted?(lower)) && !allowed.include?(lower)
138
175
  SCRUBBED
139
176
  else
140
177
  value.is_a?(String) ? scrub_string(value) : value
@@ -5,6 +5,11 @@ module Realuptime
5
5
  SDK_NAME = "realuptime-errors-ruby"
6
6
  # The string every batch carries on the wire ("sdk" field); the gemspec
7
7
  # and the public mirror's version both read this constant.
8
- SDK_VERSION = "0.1.0"
8
+ # REA-567: bumped from 0.1.0 (the version currently live on RubyGems)
9
+ # so a republish carries the key-name redaction pass (REA-474) and the
10
+ # ingest.realuptime.io host change (REA-473) that landed after 0.1.0
11
+ # shipped. No feature was added, so this is a patch bump, not a minor
12
+ # one.
13
+ SDK_VERSION = "0.1.1"
9
14
  end
10
15
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # realuptime-errors: zero-dependency Ruby error tracking SDK for RealUptime
4
- # Errors (docs/errors-plan.md, Linear REA-255).
4
+ # Errors (the internal-docs repo's errors-plan.md, Linear REA-255).
5
5
  #
6
6
  # One wire contract with the JS and Python SDKs (packages/errors-js/types.ts);
7
7
  # the shared scrub vectors (scrub-vectors.json, vendored into this gem and
@@ -18,7 +18,7 @@
18
18
  # Usage:
19
19
  #
20
20
  # require "realuptime/errors"
21
- # Realuptime::Errors.init(dsn: "https://realuptime.io/api/errors/v1/ingest/rue_...",
21
+ # Realuptime::Errors.init(dsn: "https://ingest.realuptime.io/api/errors/v1/ingest/rue_...",
22
22
  # release: "v2.4.1", environment: "production")
23
23
  # ...
24
24
  # Realuptime::Errors.capture_exception(error)
data/scrub-vectors.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "comment": "Shared PII-scrub test vectors for the Errors SDKs and the server-side second net (docs/errors-plan.md, 'The SDKs: two languages, one contract'). Consumed by packages/errors-js/scrub.test.ts, packages/errors-py/tests/test_scrub.py, and packages/db/error-scrub.test.ts. All three implementations must produce byte-identical output for every vector; a semantic change here is a wire-contract change and needs all three updated in one PR. The server's second net applies the PATTERN rules only: FIELD REMOVAL (removed headers, and v2's user.email / user.username) and allowFields are client-side duties -- a field that reaches the server was either never in the removal set or was deliberately opted back in, and the server cannot tell which. Vectors where the server's output therefore differs carry expectedServer; absent it, the server expects `expected`. Vectors named 'v2 ...' cover the REA-182 additions (identity, tags, custom context, device metadata, matched route, frame locals); every v1 vector above them is unchanged, which is itself part of the additive guarantee.",
2
+ "comment": "Shared PII-scrub test vectors for the Errors SDKs and the server-side second net (the internal-docs repo's errors-plan.md, 'The SDKs: two languages, one contract'). Consumed by packages/errors-js/scrub.test.ts, packages/errors-py/tests/test_scrub.py, and packages/db/error-scrub.test.ts. All three implementations must produce byte-identical output for every vector; a semantic change here is a wire-contract change and needs all three updated in one PR. The server's second net applies the PATTERN rules only: FIELD REMOVAL (removed headers, and v2's user.email / user.username) and allowFields are client-side duties -- a field that reaches the server was either never in the removal set or was deliberately opted back in, and the server cannot tell which. Vectors where the server's output therefore differs carry expectedServer; absent it, the server expects `expected`. Vectors named 'v2 ...' cover the REA-182 additions (identity, tags, custom context, device metadata, matched route, frame locals); every v1 vector above them is unchanged, which is itself part of the additive guarantee.",
3
3
  "version": 2,
4
4
  "vectors": [
5
5
  {
@@ -501,6 +501,190 @@
501
501
  "request": { "method": "GET", "path": "/orders/8842", "route": "/orders/:id", "status": 500 },
502
502
  "device": { "runtime": "node", "runtimeVersion": "22.4.0" }
503
503
  }
504
+ },
505
+
506
+ {
507
+ "name": "REA-474 key-name denylist: context password key redacted regardless of value shape",
508
+ "event": {
509
+ "message": "boom",
510
+ "context": { "db_password": "hunter2", "cartTotal": "49.99" }
511
+ },
512
+ "expected": {
513
+ "message": "boom",
514
+ "context": { "db_password": "[scrubbed]", "cartTotal": "49.99" }
515
+ }
516
+ },
517
+ {
518
+ "name": "REA-474 key-name denylist: tag named secret redacted regardless of value shape",
519
+ "event": {
520
+ "message": "boom",
521
+ "tags": { "client_secret": "not-shaped-like-anything", "plan": "growth" }
522
+ },
523
+ "expected": {
524
+ "message": "boom",
525
+ "tags": { "client_secret": "[scrubbed]", "plan": "growth" }
526
+ }
527
+ },
528
+ {
529
+ "name": "REA-474 key-name denylist: camelCase apiKey in frame vars redacted",
530
+ "event": {
531
+ "message": "boom",
532
+ "frames": [
533
+ {
534
+ "file": "app/billing.py",
535
+ "function": "charge",
536
+ "line": 12,
537
+ "inApp": true,
538
+ "vars": { "apiKey": "not-shaped-like-anything", "attempt": "3" }
539
+ }
540
+ ]
541
+ },
542
+ "expected": {
543
+ "message": "boom",
544
+ "frames": [
545
+ {
546
+ "file": "app/billing.py",
547
+ "function": "charge",
548
+ "line": 12,
549
+ "inApp": true,
550
+ "vars": { "apiKey": "[scrubbed]", "attempt": "3" }
551
+ }
552
+ ]
553
+ }
554
+ },
555
+ {
556
+ "name": "REA-474 key-name denylist: private_key and access_key in custom context redacted (device is a closed shape in some SDKs, so this uses context instead)",
557
+ "event": {
558
+ "message": "boom",
559
+ "context": { "private_key": "not-shaped", "access_key": "not-shaped", "cartTotal": "49.99" }
560
+ },
561
+ "expected": {
562
+ "message": "boom",
563
+ "context": { "private_key": "[scrubbed]", "access_key": "[scrubbed]", "cartTotal": "49.99" }
564
+ }
565
+ },
566
+ {
567
+ "name": "REA-474 key-name denylist: breadcrumb data session key redacted",
568
+ "event": {
569
+ "message": "boom",
570
+ "breadcrumbs": [
571
+ {
572
+ "timestamp": null,
573
+ "category": "auth",
574
+ "message": "login attempt",
575
+ "data": { "session": "not-shaped-like-anything", "outcome": "ok" }
576
+ }
577
+ ]
578
+ },
579
+ "expected": {
580
+ "message": "boom",
581
+ "breadcrumbs": [
582
+ {
583
+ "timestamp": null,
584
+ "category": "auth",
585
+ "message": "login attempt",
586
+ "data": { "session": "[scrubbed]", "outcome": "ok" }
587
+ }
588
+ ]
589
+ }
590
+ },
591
+ {
592
+ "name": "REA-474 key-name denylist: near-miss key names are NOT redacted (tokenizer, authentic)",
593
+ "event": {
594
+ "message": "boom",
595
+ "context": { "tokenizer": "gpt2", "authentic": "yes" }
596
+ },
597
+ "expected": {
598
+ "message": "boom",
599
+ "context": { "tokenizer": "gpt2", "authentic": "yes" }
600
+ }
601
+ },
602
+ {
603
+ "name": "REA-474 key-name denylist: a header named by key alone (x-auth-token) is removed client-side; the server has no name-based removal so it stays (value fails the pattern rules)",
604
+ "event": {
605
+ "message": "boom",
606
+ "request": {
607
+ "method": "GET",
608
+ "path": "/v1/charge",
609
+ "status": 500,
610
+ "headers": { "x-auth-token": "not-shaped-like-anything", "x-request-id": "req-1" }
611
+ }
612
+ },
613
+ "expected": {
614
+ "message": "boom",
615
+ "request": {
616
+ "method": "GET",
617
+ "path": "/v1/charge",
618
+ "status": 500,
619
+ "headers": { "x-auth-token": "[scrubbed]", "x-request-id": "req-1" }
620
+ }
621
+ },
622
+ "expectedServer": {
623
+ "message": "boom",
624
+ "request": {
625
+ "method": "GET",
626
+ "path": "/v1/charge",
627
+ "status": 500,
628
+ "headers": { "x-auth-token": "not-shaped-like-anything", "x-request-id": "req-1" }
629
+ }
630
+ }
631
+ },
632
+ {
633
+ "name": "REA-474 key-name denylist: allowFields opts a denylisted header name back in, same as an explicitly removed header",
634
+ "allowFields": ["x-auth-token"],
635
+ "event": {
636
+ "message": "boom",
637
+ "request": {
638
+ "method": "GET",
639
+ "path": "/v1/charge",
640
+ "status": 500,
641
+ "headers": { "x-auth-token": "not-shaped-like-anything" }
642
+ }
643
+ },
644
+ "expected": {
645
+ "message": "boom",
646
+ "request": {
647
+ "method": "GET",
648
+ "path": "/v1/charge",
649
+ "status": 500,
650
+ "headers": { "x-auth-token": "not-shaped-like-anything" }
651
+ }
652
+ }
653
+ },
654
+ {
655
+ "name": "REA-474 value shape: AWS access key id (AKIA) is scrubbed",
656
+ "event": { "message": "leaked creds AKIAIOSFODNN7EXAMPLE in log" },
657
+ "expected": { "message": "leaked creds [scrubbed] in log" }
658
+ },
659
+ {
660
+ "name": "REA-474 value shape: AWS temporary access key id (ASIA) is scrubbed",
661
+ "event": { "message": "leaked creds ASIAIOSFODNN7EXAMPLE in log" },
662
+ "expected": { "message": "leaked creds [scrubbed] in log" }
663
+ },
664
+ {
665
+ "name": "REA-474 value shape: a short AKIA-prefixed run stays (not the 16-char shape)",
666
+ "event": { "message": "AKIA123 is not a real key" },
667
+ "expected": { "message": "AKIA123 is not a real key" }
668
+ },
669
+ {
670
+ "name": "REA-474 value shape: Stripe webhook signing secret (whsec_) is scrubbed",
671
+ "event": { "message": "signature check failed for whsec_abcdefgh12345678" },
672
+ "expected": { "message": "signature check failed for [scrubbed]" }
673
+ },
674
+ {
675
+ "name": "REA-474 value shape: a postgres connection string's userinfo is scrubbed; host stays readable",
676
+ "event": { "message": "connect failed: postgres://dbuser:hunter2@db.internal:5432/app" },
677
+ "expected": { "message": "connect failed: postgres://[scrubbed]@db.internal:5432/app" }
678
+ },
679
+ {
680
+ "name": "REA-474 value shape: an https URL with userinfo credentials is scrubbed; host and path stay",
681
+ "event": { "message": "fetch failed for https://svc:s3cr3t-pass@api.example.com/v1/orders" },
682
+ "expected": { "message": "fetch failed for https://[scrubbed]@api.example.com/v1/orders" }
683
+ },
684
+ {
685
+ "name": "REA-474 value shape: a plain URL with no userinfo is left alone",
686
+ "event": { "message": "fetch failed for https://api.example.com/v1/orders" },
687
+ "expected": { "message": "fetch failed for https://api.example.com/v1/orders" }
504
688
  }
505
689
  ]
506
690
  }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: realuptime-errors
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - RealUptime
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-24 00:00:00.000000000 Z
11
+ date: 2026-09-25 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Captures exceptions with scrub-by-default PII handling, bounded buffering
14
14
  with honest drop counters, and framework integrations for Rails, Rack and Sidekiq.