Module: Sentiero::Redaction
- Defined in:
- lib/sentiero/redaction.rb,
lib/sentiero/redaction/config.rb
Overview
Redaction engine for side-channel capture (navigation, form_submit, metadata, error, click) that bypasses rrweb input masking. Must stay byte-for-byte equivalent to the JS twin frontend/src/redaction.js; test/fixtures/redaction_cases.json pins that parity.
Defined Under Namespace
Classes: Config
Constant Summary collapse
- REDACTED =
"[redacted]"- TEXT_PATTERN_ORDER =
Fixed application order, identical to the JS module.
%i[url jwt email long_hex card].freeze
- TEXT_PATTERNS =
{ jwt: /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/, email: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/, long_hex: /\b[0-9a-fA-F]{32,}\b/, card: /\b\d(?:[ -]?\d){12,18}\b/ }.freeze
- URL_IN_TEXT =
%r{https?://\S+}- BUILTIN_DENYLIST =
%w[ token access_token refresh_token id_token password passwd pwd secret api_key apikey key sig signature code auth session sessionid otp ].freeze
- CUSTOM_EVENT_TYPE =
5- META_EVENT_TYPE =
4- URL_METADATA_KEYS =
%w[url referrer entry_url entry_referrer].freeze
- CUSTOM_FIELD_MAP =
tag => { "field" => :url|:text }
{ "navigation" => {"url" => :url, "text" => :text}, "__form_submit" => {"url" => :url}, "error" => {"message" => :text, "stack" => :text, "source" => :url}, "__click" => {"selector" => :text} }.freeze
Class Method Summary collapse
- .apply_text_pattern(text, name) ⇒ Object
- .deep_redact_strings(value, config) ⇒ Object
- .filter_param(pair, config) ⇒ Object
- .filter_url(url, config) ⇒ Object
- .redact_custom_event(event, config) ⇒ Object
-
.redact_dom_event(event, config) ⇒ Object
DOM text/data is left alone unless the operator opts in.
- .redact_event(event, config = Config.new) ⇒ Object
-
.redact_meta_event(event, config) ⇒ Object
rrweb Meta events (type 4) carry the full page URL in data.href, which bypasses rrweb's own input masking entirely; always URL-redact it like any other structural URL field (navigation.url, error.source, ...).
- .redact_metadata(metadata, config = Config.new) ⇒ Object
- .redact_text(value, config = Config.new) ⇒ Object
- .redact_url(url, config = Config.new) ⇒ Object
-
.split_url(url) ⇒ Object
Manual split (not URI) so JS and Ruby behave identically on edge cases.
- .strip_url_string(url) ⇒ Object
-
.url_decode(value) ⇒ Object
Plain percent-decode (leaves "+" alone, unlike www-form decoding).
Class Method Details
.apply_text_pattern(text, name) ⇒ Object
91 92 93 94 95 96 97 |
# File 'lib/sentiero/redaction.rb', line 91 def apply_text_pattern(text, name) if name == :url text.gsub(URL_IN_TEXT) { |m| strip_url_string(m) } else text.gsub(TEXT_PATTERNS.fetch(name), REDACTED) end end |
.deep_redact_strings(value, config) ⇒ Object
195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/sentiero/redaction.rb', line 195 def deep_redact_strings(value, config) case value when String then redact_text(value, config) when Array then value.map { |v| deep_redact_strings(v, config) } when Hash # Keys can carry PII too (e.g. a caller using an email as a hash key); # redact them the same as values. Last-write-wins on key collisions. value.to_h { |k, v| [deep_redact_strings(k, config), deep_redact_strings(v, config)] } else value end end |
.filter_param(pair, config) ⇒ Object
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/sentiero/redaction.rb', line 108 def filter_param(pair, config) eq = pair.index("=") name = (eq ? pair[0...eq] : pair).downcase # Denylist wins over the allowlist so allowlisting a built-in secret name # (token/password/...) can't re-enable persisting it. return nil if config.effective_denylist.include?(name) return pair if config.effective_allowlist.include?(name) return pair unless eq # Match patterns against the decoded value (email=user%40example.com must # be caught the same as email=user@example.com) but only substitute when # something actually matched; a clean survivor keeps its original, # unmodified encoding rather than being needlessly re-encoded. raw_value = pair[(eq + 1)..] decoded = url_decode(raw_value) redacted = redact_text(decoded, config) (redacted == decoded) ? pair : "#{pair[0...eq]}=#{redacted}" end |
.filter_url(url, config) ⇒ Object
99 100 101 102 103 104 105 106 |
# File 'lib/sentiero/redaction.rb', line 99 def filter_url(url, config) base, query, frag = split_url(url) pairs = query.empty? ? [] : query.split("&").filter_map { |p| filter_param(p, config) } out = base out += "?#{pairs.join("&")}" unless pairs.empty? out += "##{redact_text(frag, config)}" unless frag.empty? out end |
.redact_custom_event(event, config) ⇒ Object
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/sentiero/redaction.rb', line 158 def redact_custom_event(event, config) map = CUSTOM_FIELD_MAP[event["data"]["tag"]] payload = event["data"]["payload"] return event unless payload.is_a?(Hash) # Mapped fields use their url/text treatment; every other field (and every # field of an unmapped tag) is deep-redacted rather than stored raw, so a # buggy/hostile client can't smuggle PII through an unmapped key. new_payload = payload.to_h do |k, v| case map&.dig(k) when :url then [k, redact_url(v, config)] when :text then [k, redact_text(v, config)] else [k, deep_redact_strings(v, config)] end end event.merge("data" => event["data"].merge("payload" => new_payload)) end |
.redact_dom_event(event, config) ⇒ Object
DOM text/data is left alone unless the operator opts in.
177 178 179 180 181 182 183 184 |
# File 'lib/sentiero/redaction.rb', line 177 def redact_dom_event(event, config) return event if config.dom_patterns.empty? && config.custom_patterns.empty? return event unless event["data"] dom_cfg = Config.new(disabled_patterns: TEXT_PATTERN_ORDER - config.dom_patterns, custom_patterns: config.custom_patterns) event.merge("data" => deep_redact_strings(event["data"], dom_cfg)) end |
.redact_event(event, config = Config.new) ⇒ Object
67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/sentiero/redaction.rb', line 67 def redact_event(event, config = Config.new) return event unless event.is_a?(Hash) if event["type"] == CUSTOM_EVENT_TYPE && event["data"].is_a?(Hash) redact_custom_event(event, config) elsif event["type"] == META_EVENT_TYPE && event["data"].is_a?(Hash) (event, config) else redact_dom_event(event, config) end end |
.redact_meta_event(event, config) ⇒ Object
rrweb Meta events (type 4) carry the full page URL in data.href, which bypasses rrweb's own input masking entirely; always URL-redact it like any other structural URL field (navigation.url, error.source, ...).
189 190 191 192 193 |
# File 'lib/sentiero/redaction.rb', line 189 def (event, config) return event unless event["data"].key?("href") event.merge("data" => event["data"].merge("href" => redact_url(event["data"]["href"], config))) end |
.redact_metadata(metadata, config = Config.new) ⇒ Object
79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/sentiero/redaction.rb', line 79 def (, config = Config.new) return unless .is_a?(Hash) .to_h do |key, value| if URL_METADATA_KEYS.include?(key) [key, redact_url(value, config)] else [key, deep_redact_strings(value, config)] end end end |
.redact_text(value, config = Config.new) ⇒ Object
56 57 58 59 60 61 62 63 64 65 |
# File 'lib/sentiero/redaction.rb', line 56 def redact_text(value, config = Config.new) return value unless value.is_a?(String) out = value config.active_text_patterns.each do |name| out = apply_text_pattern(out, name) end config.custom_patterns.each { |re| out = out.gsub(re, REDACTED) } out end |
.redact_url(url, config = Config.new) ⇒ Object
46 47 48 49 50 51 52 53 54 |
# File 'lib/sentiero/redaction.rb', line 46 def redact_url(url, config = Config.new) return url unless url.is_a?(String) case config.url_mode when :keep_all then url when :keep_filtered then filter_url(url, config) else strip_url_string(url) end end |
.split_url(url) ⇒ Object
Manual split (not URI) so JS and Ruby behave identically on edge cases.
136 137 138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/sentiero/redaction.rb', line 136 def split_url(url) base = url frag = "" if (h = base.index("#")) frag = base[(h + 1)..] base = base[0...h] end query = "" if (q = base.index("?")) query = base[(q + 1)..] base = base[0...q] end [base, query, frag] end |
.strip_url_string(url) ⇒ Object
151 152 153 154 155 156 |
# File 'lib/sentiero/redaction.rb', line 151 def strip_url_string(url) cut = url.index("?") || url.length hash = url.index("#") cut = hash if hash && hash < cut url[0...cut] end |
.url_decode(value) ⇒ Object
Plain percent-decode (leaves "+" alone, unlike www-form decoding). Falls back to the raw value on malformed escapes or invalid UTF-8 rather than raising, since this parses attacker-controlled URLs from public events.
130 131 132 133 |
# File 'lib/sentiero/redaction.rb', line 130 def url_decode(value) decoded = URI::RFC2396_PARSER.unescape(value) decoded.valid_encoding? ? decoded : value end |