Module: EndPointBlank::Masking
- Defined in:
- lib/end_point_blank/masking.rb
Overview
Client-side masking. Applies configured rules to an outgoing payload's maskable fields for the given record_type, then runs the optional user hook. Payload keys are SYMBOLS matching the writers' payloads / intake wire keys.
Rule shape (string-keyed-or-symbol-keyed hash):
:target — one of request_body, request_headers, path,
response_body, error_message; mapped to a wire key
per FIELD_MAP for the record_type.
:path — a JSONPath (constrained subset); may be nil/"".
:regex — a regex source string; may be nil/"".
:replacement_value — literal replacement string.
Matching semantics ("path scopes, regex matches within"):
path only — replace each node selected by the path entirely.
regex only — global regex substitution on every string leaf.
path + regex — within each selected node, regex-substitute its string leaves.
Constant Summary collapse
- FIELD_MAP =
{ request: { "request_body" => :request, "request_headers" => :headers, "path" => :path }, response: { "response_body" => :body }, error: { "error_message" => :message }, log: {} }.freeze
- JSON_TARGETS =
Targets whose wire value is a JSON string body (decode/apply/re-encode).
%w[request_body response_body].freeze
Class Method Summary collapse
- .apply(payload, record_type, rules, hook) ⇒ Object
- .apply_rule(payload, record_type, rule) ⇒ Object
-
.apply_to_raw_string(value, rule) ⇒ Object
A plain, non-JSON string target: path cannot apply (no-op); regex applies.
-
.apply_to_value(value, rule) ⇒ Object
Applies the rule to a structured value (decoded JSON or header Hash).
-
.compiled_regex(rule) ⇒ Object
Compiles rule; blank/nil/invalid ⇒ nil (regex step no-ops).
-
.descend(value, key, rest, &block) ⇒ Object
Recursive descent: at this node and every nested node, any entry whose key is
keymatches the remaining tokens. - .expand(template, groups) ⇒ Object
-
.mask_field(value, rule, target) ⇒ Object
Body targets: JSON string.
- .parse_bracket(rest) ⇒ Object
-
.parse_path(string) ⇒ Object
--- Constrained JSONPath subset (mirrors intake's JsonPath) ---------------.
- .parse_quoted(rest, quote_char) ⇒ Object
- .parse_tokens(rest, acc) ⇒ Object
-
.regex_replace_all(regexp, string, template) ⇒ Object
--- Replacement backreferences (shared contract) --------------------------.
-
.regex_replace_leaves(node, re, repl) ⇒ Object
Recurse over containers; substitute on every string leaf.
- .replacement(rule) ⇒ Object
-
.take_name(string) ⇒ Object
Consumes a leading [A-Za-z0-9_]+ run; returns [name, remaining].
-
.transform(value, tokens, &block) ⇒ Object
Walks value following tokens, replacing each fully-matched location with the block's result and rebuilding parents immutably.
Class Method Details
.apply(payload, record_type, rules, hook) ⇒ Object
33 34 35 36 |
# File 'lib/end_point_blank/masking.rb', line 33 def apply(payload, record_type, rules, hook) masked = (rules || []).reduce(payload) { |acc, rule| apply_rule(acc, record_type, rule) } hook ? hook.call(masked, record_type.to_s) : masked end |
.apply_rule(payload, record_type, rule) ⇒ Object
38 39 40 41 42 43 44 45 |
# File 'lib/end_point_blank/masking.rb', line 38 def apply_rule(payload, record_type, rule) field_map = FIELD_MAP.fetch(record_type, {}) target = rule[:target] key = field_map[target] return payload unless key && payload.key?(key) payload.merge(key => mask_field(payload[key], rule, target)) end |
.apply_to_raw_string(value, rule) ⇒ Object
A plain, non-JSON string target: path cannot apply (no-op); regex applies.
72 73 74 75 76 77 |
# File 'lib/end_point_blank/masking.rb', line 72 def apply_to_raw_string(value, rule) re = compiled_regex(rule) return value unless re regex_replace_all(re, value, replacement(rule)) end |
.apply_to_value(value, rule) ⇒ Object
Applies the rule to a structured value (decoded JSON or header Hash).
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/end_point_blank/masking.rb', line 80 def apply_to_value(value, rule) path = rule[:path] || rule["path"] tokens = parse_path(path) return value if tokens.nil? && path.is_a?(String) && path != "" re = compiled_regex(rule) repl = replacement(rule) if tokens && re # path + regex: select nodes, apply regex to leaves within each. transform(value, tokens) { |old| regex_replace_leaves(old, re, repl) } elsif tokens # path only: replace each selected node entirely. transform(value, tokens) { |_old| repl } elsif re # regex only: substitute across every string leaf. regex_replace_leaves(value, re, repl) else value end end |
.compiled_regex(rule) ⇒ Object
Compiles rule; blank/nil/invalid ⇒ nil (regex step no-ops).
107 108 109 110 111 112 113 114 |
# File 'lib/end_point_blank/masking.rb', line 107 def compiled_regex(rule) source = rule[:regex] || rule["regex"] return nil if source.nil? || source == "" Regexp.new(source) rescue RegexpError, TypeError nil end |
.descend(value, key, rest, &block) ⇒ Object
Recursive descent: at this node and every nested node, any entry whose key
is key matches the remaining tokens.
268 269 270 271 272 273 274 275 276 277 278 279 280 |
# File 'lib/end_point_blank/masking.rb', line 268 def descend(value, key, rest, &block) case value when Hash value.each_with_object({}) do |(k, v), out| v = descend(v, key, rest, &block) out[k] = k == key ? transform(v, rest, &block) : v end when Array value.map { |e| descend(e, key, rest, &block) } else value end end |
.expand(template, groups) ⇒ Object
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/end_point_blank/masking.rb', line 144 def (template, groups) out = +"" i = 0 len = template.length while i < len ch = template[i] if ch != "$" out << ch i += 1 elsif template[i + 1] == "$" out << "$" i += 2 elsif template[i + 1] =~ /\d/ j = i + 1 j += 1 while j < len && template[j] =~ /\d/ n = template[(i + 1)...j].to_i out << (groups[n] || "") i = j else out << "$" i += 1 end end out end |
.mask_field(value, rule, target) ⇒ Object
Body targets: JSON string. Decode, apply on the decoded value, re-encode. On non-JSON: path no-ops; regex (if present) applies to the raw string. request_headers: a Hash. path applies; regex applies to string leaves. path / error_message: plain strings — path no-ops, only regex applies.
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'lib/end_point_blank/masking.rb', line 51 def mask_field(value, rule, target) case value when String if JSON_TARGETS.include?(target) begin decoded = JSON.parse(value) rescue JSON::ParserError return apply_to_raw_string(value, rule) end JSON.generate(apply_to_value(decoded, rule)) else apply_to_raw_string(value, rule) end when Hash apply_to_value(value, rule) else value end end |
.parse_bracket(rest) ⇒ Object
207 208 209 210 211 212 213 214 215 216 217 |
# File 'lib/end_point_blank/masking.rb', line 207 def parse_bracket(rest) if rest.start_with?("*]") [[:wildcard], rest[2..]] elsif rest.start_with?("'") parse_quoted(rest[1..], "'") elsif rest.start_with?('"') parse_quoted(rest[1..], '"') elsif (m = rest.match(/\A(\d+)\](.*)\z/m)) [[:index, m[1].to_i], m[2]] end end |
.parse_path(string) ⇒ Object
--- Constrained JSONPath subset (mirrors intake's JsonPath) ---------------
Tokens: [:child, name] / [:index, n] / [:wildcard] / [:descendant, name]. parse_path returns a token array, or nil for blank/unsupported/garbled input (caller treats nil as "matches nothing"). Never raises.
176 177 178 179 180 181 |
# File 'lib/end_point_blank/masking.rb', line 176 def parse_path(string) return nil unless string.is_a?(String) return nil unless string.start_with?("$") parse_tokens(string[1..], []) end |
.parse_quoted(rest, quote_char) ⇒ Object
219 220 221 222 223 224 |
# File 'lib/end_point_blank/masking.rb', line 219 def parse_quoted(rest, quote_char) name, remaining = rest.split("#{quote_char}]", 2) return nil if remaining.nil? [[:child, name], remaining] end |
.parse_tokens(rest, acc) ⇒ Object
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/end_point_blank/masking.rb', line 183 def parse_tokens(rest, acc) return acc if rest.empty? if rest.start_with?("..") name, remaining = take_name(rest[2..]) return nil if name.empty? parse_tokens(remaining, acc + [[:descendant, name]]) elsif rest.start_with?(".*") parse_tokens(rest[2..], acc + [[:wildcard]]) elsif rest.start_with?(".") name, remaining = take_name(rest[1..]) return nil if name.empty? parse_tokens(remaining, acc + [[:child, name]]) elsif rest.start_with?("[") result = parse_bracket(rest[1..]) return nil unless result token, remaining = result parse_tokens(remaining, acc + [token]) end end |
.regex_replace_all(regexp, string, template) ⇒ Object
--- Replacement backreferences (shared contract) --------------------------
In a regex substitution, replacement_value is a TEMPLATE. For each match we build the replacement ourselves (NOT Ruby's native \N substitution):
$$ → literal "$"
$<digits> → capture group N (full consecutive digit run); 0 = whole
match; missing/non-participating group → "".
lone/trailing $ before a non-digit → literal "$".
groups is 0-indexed: groups = whole match, groups = nth capture.
136 137 138 139 140 141 142 |
# File 'lib/end_point_blank/masking.rb', line 136 def regex_replace_all(regexp, string, template) string.gsub(regexp) do m = Regexp.last_match groups = (0...m.size).map { |i| m[i] } (template, groups) end end |
.regex_replace_leaves(node, re, repl) ⇒ Object
Recurse over containers; substitute on every string leaf.
117 118 119 120 121 122 123 124 |
# File 'lib/end_point_blank/masking.rb', line 117 def regex_replace_leaves(node, re, repl) case node when String then regex_replace_all(re, node, repl) when Hash then node.each_with_object({}) { |(k, v), out| out[k] = regex_replace_leaves(v, re, repl) } when Array then node.map { |e| regex_replace_leaves(e, re, repl) } else node end end |
.replacement(rule) ⇒ Object
102 103 104 |
# File 'lib/end_point_blank/masking.rb', line 102 def replacement(rule) (rule[:replacement_value] || rule["replacement_value"] || "...").to_s end |
.take_name(string) ⇒ Object
Consumes a leading [A-Za-z0-9_]+ run; returns [name, remaining].
227 228 229 230 231 232 |
# File 'lib/end_point_blank/masking.rb', line 227 def take_name(string) m = string.match(/\A([A-Za-z0-9_]+)(.*)\z/m) return ["", string] unless m [m[1], m[2]] end |
.transform(value, tokens, &block) ⇒ Object
Walks value following tokens, replacing each fully-matched location with the block's result and rebuilding parents immutably. Never raises.
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 |
# File 'lib/end_point_blank/masking.rb', line 236 def transform(value, tokens, &block) return block.call(value) if tokens.empty? token, *rest = tokens case token[0] when :child key = token[1] return value unless value.is_a?(Hash) && value.key?(key) value.merge(key => transform(value[key], rest, &block)) when :index i = token[1] return value unless value.is_a?(Array) && i >= 0 && i < value.length out = value.dup out[i] = transform(out[i], rest, &block) out when :wildcard case value when Hash then value.each_with_object({}) { |(k, v), o| o[k] = transform(v, rest, &block) } when Array then value.map { |e| transform(e, rest, &block) } else value end when :descendant descend(value, token[1], rest, &block) else value end end |