Module: Realuptime::Errors::Scrub

Defined in:
lib/realuptime/errors/scrub.rb

Overview

PII scrub-by-default, client-side (docs/errors-plan.md, "PII scrub-by-default, specified"). A byte-for-byte port of packages/errors-js/scrub.ts and packages/errors-py/realuptime_errors.py's scrub half; the shared vectors (scrub-vectors.json, vendored into this gem and asserted byte-identical to packages/errors-js/scrub-vectors.json by test/vendored_vectors_test.rb) pin all four implementations -- JS, Python, this one, and the server's second net -- to identical output.

The five pattern rules, in the shared order:

1. JWT three-dot shape
2. prefixed keys (sk_/pk_/rk_/ghp_/gho_/ghs_/xox?_/rua_/rue_/ru_live_)
3. card-shaped digit runs, 13-19 digits, Luhn-checked
4. long hex runs (32+)
5. long base64-ish runs (40+)

REMOVAL (value replaced whole) unless allow-listed by name:

- headers authorization / proxy-authorization / cookie / set-cookie
- identity (wire v2): user.email and user.username. user.id is NOT
removed; an opaque id in the customer's own key space is not
contact data, and it is what makes "how many users hit this"
answerable.

Constant Summary collapse

SCRUBBED =
"[scrubbed]"
REMOVED_HEADERS =
%w[authorization proxy-authorization cookie set-cookie].freeze
REMOVED_USER_FIELDS =
%w[user.email user.username].freeze
JWT_RE =
/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/.freeze
PREFIXED_KEY_RE =
/\b(?:sk|pk|rk|ghp|gho|ghs|xox[a-z]|rua|rue|ru_live)_[A-Za-z0-9_-]{8,}/.freeze
CARD_RE =
/(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)/.freeze
HEX_RE =
/\b[0-9a-fA-F]{32,}\b/.freeze
BASE64_RE =
/(?<![A-Za-z0-9+_=-])[A-Za-z0-9+_-]{40,}={0,2}/.freeze

Class Method Summary collapse

Class Method Details

.luhn_valid?(digits) ⇒ Boolean

Returns:

  • (Boolean)


40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/realuptime/errors/scrub.rb', line 40

def luhn_valid?(digits)
  total = 0
  double = false
  digits.reverse.each_char do |ch|
    d = ch.ord - 48
    if double
      d *= 2
      d -= 9 if d > 9
    end
    total += d
    double = !double
  end
  (total % 10).zero?
end

.scrub_breadcrumb(crumb) ⇒ Object



78
79
80
81
82
83
84
85
# File 'lib/realuptime/errors/scrub.rb', line 78

def scrub_breadcrumb(crumb)
  return crumb unless crumb.is_a?(Hash)

  out = crumb.dup
  out["message"] = scrub_string(out["message"]) if out["message"].is_a?(String)
  out["data"] = scrub_string_map(out["data"]) if out["data"].is_a?(Hash)
  out
end

.scrub_event(event, allow_fields = nil) ⇒ Object

Scrubs one wire event (string-keyed Hash). Returns a new Hash; never mutates the input.



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/realuptime/errors/scrub.rb', line 118

def scrub_event(event, allow_fields = nil)
  allowed = (allow_fields || []).map { |f| f.to_s.downcase }
  out = event.dup
  out["message"] = scrub_string(out["message"]) if out["message"].is_a?(String)
  out["breadcrumbs"] = out["breadcrumbs"].map { |c| scrub_breadcrumb(c) } if out["breadcrumbs"].is_a?(Array)
  out["frames"] = out["frames"].map { |f| scrub_frame(f) } if out["frames"].is_a?(Array)
  %w[tags context device].each do |key|
    out[key] = scrub_string_map(out[key]) if out[key].is_a?(Hash)
  end
  out["user"] = scrub_user(out["user"], allowed) if out["user"].is_a?(Hash)
  request = out["request"]
  if request.is_a?(Hash)
    request = request.dup
    request["path"] = scrub_string(request["path"]) if request["path"].is_a?(String)
    request["route"] = scrub_string(request["route"]) if request["route"].is_a?(String)
    headers = request["headers"]
    if headers.is_a?(Hash)
      request["headers"] = headers.each_with_object({}) do |(name, value), scrubbed|
        lower = name.to_s.downcase
        scrubbed[name] = if REMOVED_HEADERS.include?(lower) && !allowed.include?(lower)
                           SCRUBBED
                         else
                           value.is_a?(String) ? scrub_string(value) : value
                         end
      end
    end
    out["request"] = request
  end
  out
end

.scrub_frame(frame) ⇒ Object



102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/realuptime/errors/scrub.rb', line 102

def scrub_frame(frame)
  return frame unless frame.is_a?(Hash)
  return frame unless frame["contextLine"] || frame["preContext"] || frame["postContext"] || frame["vars"]

  out = frame.dup
  out["contextLine"] = scrub_string(out["contextLine"]) if out["contextLine"].is_a?(String)
  %w[preContext postContext].each do |key|
    lines = out[key]
    out[key] = lines.map { |line| line.is_a?(String) ? scrub_string(line) : line } if lines.is_a?(Array)
  end
  out["vars"] = scrub_string_map(out["vars"]) if out["vars"].is_a?(Hash)
  out
end

.scrub_string(value) ⇒ Object

The five pattern rules over one string. Pure.



56
57
58
59
60
61
62
63
64
65
# File 'lib/realuptime/errors/scrub.rb', line 56

def scrub_string(value)
  out = value.gsub(JWT_RE, SCRUBBED)
  out = out.gsub(PREFIXED_KEY_RE, SCRUBBED)
  out = out.gsub(CARD_RE) do |run|
    digits = run.delete(" -")
    digits.length >= 13 && digits.length <= 19 && luhn_valid?(digits) ? SCRUBBED : run
  end
  out = out.gsub(HEX_RE, SCRUBBED)
  out.gsub(BASE64_RE, SCRUBBED)
end

.scrub_string_map(value) ⇒ Object

A flat string map (tags, custom context, device, frame locals): VALUES pass the pattern rules, KEYS are left alone (a key is a label the integrator chose).



70
71
72
73
74
75
76
# File 'lib/realuptime/errors/scrub.rb', line 70

def scrub_string_map(value)
  return value unless value.is_a?(Hash)

  value.each_with_object({}) do |(name, entry), out|
    out[name] = entry.is_a?(String) ? scrub_string(entry) : entry
  end
end

.scrub_user(user, allowed) ⇒ Object



87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/realuptime/errors/scrub.rb', line 87

def scrub_user(user, allowed)
  return user unless user.is_a?(Hash)

  out = user.dup
  out["id"] = scrub_string(out["id"]) if out["id"].is_a?(String)
  REMOVED_USER_FIELDS.each do |field|
    key = field.sub("user.", "")
    value = out[key]
    next unless value.is_a?(String)

    out[key] = allowed.include?(field) ? scrub_string(value) : SCRUBBED
  end
  out
end