Module: Foam::Otel::Redaction
- Defined in:
- lib/foam/otel/redaction.rb
Overview
The central, fail-closed redaction engine (BASE_PACKAGE_SPEC rule 14).
Two entry points:
* redact(value) — the public helper: mask a value the caller
KNOWS is sensitive (scalar → tail mask,
everything else → full [REDACTED]).
* mask_span_data / mask_log_record_data — the always-on secrets floor
applied to exported telemetry, run by the
redacting exporters (redacting_exporter.rb).
Ruby SDK constraint (see GOTCHAS.md): a Span freezes its attributes at finish (opentelemetry-sdk span.rb) BEFORE any SpanProcessor#on_finish runs, so the floor cannot be a processor — it runs at the exporter boundary, rebuilding the mutable SpanData/LogRecordData Structs.
Constant Summary collapse
- URL_ATTRIBUTES =
---- URL / query redaction ------------------------------------------- The instrumentation-set URL attributes (rack, http clients). Redact the query PER KEY so non-secret pairs survive:
?token=x&user=bobexports astoken=[REDACTED]&user=bob. %w[http.target url.query url.full http.url uri.query].freeze
Class Method Summary collapse
-
.auth_root?(lower) ⇒ Boolean
authmust hit authorization/authentication/x-auth-token and MISS author/author_id/authored_at. -
.card_root?(lower) ⇒ Boolean
cardmust hit card/card_number/credit_card and MISS dashboard/wildcard/postcard. -
.classify_key(key, config) ⇒ Object
---- key classification ---------------------------------------------- :secret wins over :pii when both claim a key (a value under a secret-shaped key is better tail-masked than fully hidden).
- .deep_mask(value, config, depth) ⇒ Object
-
.mask_attributes(attributes, config) ⇒ Object
---- the always-on floor over exported attributes -------------------- Returns a NEW attributes hash with the floor applied.
-
.mask_body(body, config) ⇒ Object
A log body is AnyValue: a free-text string (where raw tokens live — scan it), OR a structured map/array (where secret KEYS live — apply the floor recursively, depth-guarded).
-
.mask_by_value(value, config = nil, depth = 0) ⇒ Object
Unmatched key: the value-pattern pass still masks a secret-shaped token embedded in the value (a token under an innocent key name).
- .mask_log_record_data(record, config) ⇒ Object
-
.mask_metric_data(metric_data, config) ⇒ Object
The door-2 engine extension (fleet contract ruling: rule 14 on FOREIGN instruments).
- .mask_one(key, value, config) ⇒ Object
-
.mask_span_data(span_data, config) ⇒ Object
---- exporter-boundary masking (SpanData / LogRecordData) ------------- Rebuild the mutable Struct with masked attributes/events/body.
-
.mask_status(status) ⇒ Object
Rebuild a span Status with the value-pattern pass applied to its description; codes without a description are returned untouched.
- .match_any_substring?(lower, keys) ⇒ Boolean
- .normalize_key(key) ⇒ Object
-
.redact(value) ⇒ Object
---- public helper (section 0 helper set) ---------------------------- Mask a value the caller knows is sensitive.
-
.redact_query(query, config) ⇒ Object
Per-key query redaction, never re-encoding, never leaking on failure.
- .redact_url_value(value, config) ⇒ Object
-
.scalar_or_full(value) ⇒ Object
A matched secret key: tail-mask a scalar leaf, redact anything else in full (partial masking only helps where it hides the value).
-
.scan_value_patterns(text) ⇒ Object
One-pass gsub over the unioned value patterns, replacing only the matched token (keeps surrounding non-secret text intact).
-
.secret_key?(lower) ⇒ Boolean
lowerkeeps separators (for the boundary-guarded short roots);normfolds them away (for the distinctive bare roots). -
.tail_mask(str) ⇒ Object
---- the two mask shapes (rule 14) ----------------------------------- Secret-key + redact_keys matches on SCALAR leaves: eight mask chars, plus the last four when the value is long enough to make a tail safe.
- .url_attribute?(key) ⇒ Boolean
Class Method Details
.auth_root?(lower) ⇒ Boolean
auth must hit authorization/authentication/x-auth-token and MISS
author/author_id/authored_at.
135 136 137 138 |
# File 'lib/foam/otel/redaction.rb', line 135 def auth_root?(lower) lower.include?("authorization") || lower.include?("authentication") || /(?<![a-z0-9])auth(?![a-z0-9])/.match?(lower) end |
.card_root?(lower) ⇒ Boolean
card must hit card/card_number/credit_card and MISS
dashboard/wildcard/postcard.
142 143 144 145 146 |
# File 'lib/foam/otel/redaction.rb', line 142 def card_root?(lower) norm = normalize_key(lower) norm.include?("creditcard") || norm.include?("cardnumber") || /(?<![a-z0-9])card(?![a-z0-9])/.match?(lower) end |
.classify_key(key, config) ⇒ Object
---- key classification ---------------------------------------------- :secret wins over :pii when both claim a key (a value under a secret-shaped key is better tail-masked than fully hidden). Substring, case-insensitive, over-redaction-curated (constants.rb).
108 109 110 111 112 113 114 |
# File 'lib/foam/otel/redaction.rb', line 108 def classify_key(key, config) lower = key.downcase return :secret if secret_key?(lower) || match_any_substring?(lower, config.redact_keys) return :pii if match_any_substring?(lower, config.redact_pii_keys) nil end |
.deep_mask(value, config, depth) ⇒ Object
323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 |
# File 'lib/foam/otel/redaction.rb', line 323 def deep_mask(value, config, depth) return REDACTED if depth > MAX_REDACT_DEPTH case value when String scan_value_patterns(value) when Hash value.each_with_object({}) do |(key, val), out| case classify_key(key.to_s, config) when :pii then out[key] = REDACTED when :secret then out[key] = scalar_or_full(val) else out[key] = deep_mask(val, config, depth + 1) end end when Array value.map { |v| deep_mask(v, config, depth + 1) } else value end rescue StandardError REDACTED end |
.mask_attributes(attributes, config) ⇒ Object
---- the always-on floor over exported attributes -------------------- Returns a NEW attributes hash with the floor applied. Never raises; a key that fails to mask redacts in full rather than leaking.
57 58 59 60 61 62 63 64 65 |
# File 'lib/foam/otel/redaction.rb', line 57 def mask_attributes(attributes, config) return attributes unless attributes.is_a?(Hash) attributes.each_with_object({}) do |(key, value), out| out[key] = mask_one(key.to_s, value, config) rescue StandardError out[key] = REDACTED end end |
.mask_body(body, config) ⇒ Object
A log body is AnyValue: a free-text string (where raw tokens live — scan it), OR a structured map/array (where secret KEYS live — apply the floor recursively, depth-guarded). rule 14 / door-2 logs clause.
317 318 319 320 321 |
# File 'lib/foam/otel/redaction.rb', line 317 def mask_body(body, config) deep_mask(body, config, 0) rescue StandardError REDACTED end |
.mask_by_value(value, config = nil, depth = 0) ⇒ Object
Unmatched key: the value-pattern pass still masks a secret-shaped token embedded in the value (a token under an innocent key name). Hash/Array values ride the DEPTH-GUARDED deep_mask — never a raw recursion: an adversarial/cyclic structure (a cyclic Hash log attribute) is bounded to MAX_REDACT_DEPTH here, so it can never drive a downstream encoder into SystemStackError (rule 9) or leak a secret nested under an innocent key (rule 14).
96 97 98 99 100 101 102 |
# File 'lib/foam/otel/redaction.rb', line 96 def mask_by_value(value, config = nil, depth = 0) case value when String then scan_value_patterns(value) when Hash, Array then deep_mask(value, config || Foam::Otel.active_config, depth) else value end end |
.mask_log_record_data(record, config) ⇒ Object
270 271 272 273 274 275 276 277 |
# File 'lib/foam/otel/redaction.rb', line 270 def mask_log_record_data(record, config) masked = record.dup masked.attributes = mask_attributes(record.attributes, config) if record.attributes masked.body = mask_body(record.body, config) if record.body masked rescue StandardError, SystemStackError nil end |
.mask_metric_data(metric_data, config) ⇒ Object
The door-2 engine extension (fleet contract ruling: rule 14 on FOREIGN instruments). Door 1 masks metric attributes at capture (metrics.rb stringify) — foreign instrumentation data never passes those helpers, so the ingest metric path needs the floor at the exporter boundary. Rebuilds every data point as a COPY with masked attributes: the collected points are SHALLOW dups whose attributes hash is the LIVE aggregation key upstream (metrics-sdk aggregation/sum.rb #collect) — masking in place would corrupt the customer's aggregation state, so mask_attributes builds a fresh hash and the point struct is dup'd. Same two mask shapes, same curated roots, same value-pattern pass.
FAIL CLOSED (rule 14): returns NIL on a hard failure — the caller drops the metric loudly rather than exporting unmasked (never a partial item mixing masked and raw fields — fleet audit R6).
293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 |
# File 'lib/foam/otel/redaction.rb', line 293 def mask_metric_data(metric_data, config) masked = metric_data.dup points = metric_data.data_points if points masked.data_points = points.map do |point| copy = point.dup copy.attributes = mask_attributes(point.attributes || {}, config) # Exemplars carry filtered_attributes — the (often high-cardinality) # measurement attributes dropped from the point — and the OTLP # metrics exporter encodes them RAW, one field over from the point # attributes the floor just masked. Drop them: fail-closed, and no # observability cost for foam's own use (rule 14). copy.exemplars = nil if copy.respond_to?(:exemplars=) copy end end masked rescue StandardError, SystemStackError nil end |
.mask_one(key, value, config) ⇒ Object
67 68 69 70 71 72 73 74 75 76 77 78 |
# File 'lib/foam/otel/redaction.rb', line 67 def mask_one(key, value, config) case classify_key(key, config) when :pii then REDACTED when :secret then value.is_a?(Array) ? value.map { REDACTED } : scalar_or_full(value) else if url_attribute?(key) redact_url_value(value, config) else mask_by_value(value, config) end end end |
.mask_span_data(span_data, config) ⇒ Object
---- exporter-boundary masking (SpanData / LogRecordData) ------------- Rebuild the mutable Struct with masked attributes/events/body. A dup keeps foam's export copy independent of the span the tenant seam saw.
FAIL CLOSED (rule 14): on a hard failure these return NIL — never the raw payload. The redacting exporters drop a nil entry (with a loud [foam] warning) rather than export unmasked; "on hard failure return empty, never the payload".
219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/foam/otel/redaction.rb', line 219 def mask_span_data(span_data, config) masked = span_data.dup masked.attributes = mask_attributes(span_data.attributes, config) if span_data.attributes # Link attributes ride the floor too (rule 14): links are reachable on # BOTH doors (the get_tracer passthrough carries them; door-2 foreign # pipelines routinely emit them — batch/messaging instrumentation) and # the OTLP exporter encodes link.attributes RAW. A secret in a link # attribute passes NEITHER capture-time masking (no foam helper builds # links) NOR the point floor — mask it here, copy-not-mutate, exactly # as events are handled (Link freezes its attributes at construction). if span_data.links && !span_data.links.empty? masked.links = span_data.links.map do |link| next link unless link.respond_to?(:attributes) && link.attributes link.class.new(link.span_context, mask_attributes(link.attributes, config)) end end if span_data.events && !span_data.events.empty? masked.events = span_data.events.map do |event| next event unless event.respond_to?(:attributes) && event.attributes event.class.new(event.name, mask_attributes(event.attributes, config), event.) end end # The status DESCRIPTION is free text (record_exception sets it from the # exception message — exactly where a raw bearer token/header leaks). It # bypasses the attribute floor entirely, so the same secret masked in an # attribute would survive one field over. Run the value-pattern pass over # it (rule 14). A failure here bubbles to the fail-closed rescue below. masked.status = mask_status(span_data.status) if span_data.status masked rescue StandardError, SystemStackError nil end |
.mask_status(status) ⇒ Object
Rebuild a span Status with the value-pattern pass applied to its description; codes without a description are returned untouched. Status' constructor is private, so build through the public code factories.
257 258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/foam/otel/redaction.rb', line 257 def mask_status(status) return status unless status.respond_to?(:description) && status.description return status if status.description.empty? desc = scan_value_patterns(status.description.to_s) klass = status.class case status.code when klass::ERROR then klass.error(desc) when klass::OK then klass.ok(desc) else klass.unset(desc) end end |
.match_any_substring?(lower, keys) ⇒ Boolean
148 149 150 |
# File 'lib/foam/otel/redaction.rb', line 148 def match_any_substring?(lower, keys) keys.any? { |k| lower.include?(k) } end |
.normalize_key(key) ⇒ Object
129 130 131 |
# File 'lib/foam/otel/redaction.rb', line 129 def normalize_key(key) key.downcase.gsub(/[-_.\s]/, "") end |
.redact(value) ⇒ Object
---- public helper (section 0 helper set) ---------------------------- Mask a value the caller knows is sensitive. Scalars get the tail mask; anything structured redacts in full. Fail closed: never return the unredacted payload — empty string on hard failure.
31 32 33 34 35 36 37 38 39 40 |
# File 'lib/foam/otel/redaction.rb', line 31 def redact(value) case value when String, Numeric, Symbol then tail_mask(value.to_s) else REDACTED end rescue StandardError, SystemStackError # SystemStackError is not a StandardError: a value whose #to_s recurses # unboundedly must still degrade to empty here, never escape (rule 9). "" end |
.redact_query(query, config) ⇒ Object
Per-key query redaction, never re-encoding, never leaking on failure.
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/foam/otel/redaction.rb', line 187 def redact_query(query, config) return query if query.nil? || query.empty? query.split("&", -1).map do |pair| eq = pair.index("=") next pair if eq.nil? raw_key = pair[0...eq] decoded = begin URI.decode_www_form_component(raw_key).downcase rescue StandardError raw_key.downcase end if secret_key?(decoded) || match_any_substring?(decoded, config.redact_keys) || match_any_substring?(decoded, config.redact_pii_keys) "#{raw_key}=#{REDACTED}" else pair end end.join("&") rescue StandardError "" end |
.redact_url_value(value, config) ⇒ Object
171 172 173 174 175 176 177 178 179 180 181 182 183 184 |
# File 'lib/foam/otel/redaction.rb', line 171 def redact_url_value(value, config) return value unless value.is_a?(String) if value.include?("?") head, _, query = value.partition("?") return value if query.empty? "#{head}?#{redact_query(query, config)}" else redact_query(value, config) end rescue StandardError REDACTED end |
.scalar_or_full(value) ⇒ Object
A matched secret key: tail-mask a scalar leaf, redact anything else in full (partial masking only helps where it hides the value).
82 83 84 85 86 87 |
# File 'lib/foam/otel/redaction.rb', line 82 def scalar_or_full(value) case value when String, Numeric then tail_mask(value.to_s) else REDACTED end end |
.scan_value_patterns(text) ⇒ Object
One-pass gsub over the unioned value patterns, replacing only the matched token (keeps surrounding non-secret text intact). Used for attribute values and log bodies.
155 156 157 158 159 |
# File 'lib/foam/otel/redaction.rb', line 155 def scan_value_patterns(text) text.gsub(SECRET_VALUE_RE) { |m| tail_mask(m) } rescue StandardError REDACTED end |
.secret_key?(lower) ⇒ Boolean
lower keeps separators (for the boundary-guarded short roots); norm
folds them away (for the distinctive bare roots).
118 119 120 121 122 123 124 125 126 127 |
# File 'lib/foam/otel/redaction.rb', line 118 def secret_key?(lower) norm = normalize_key(lower) return true if BARE_SECRET_ROOTS.any? { |root| norm.include?(root) } return true if auth_root?(lower) || card_root?(lower) # Short/ambiguous roots as whole tokens (`user_cvv` hits, # `classname`/`consider` do not) — one precompiled boundary regex. return true if GUARDED_SECRET_RE.match?(lower) false end |
.tail_mask(str) ⇒ Object
---- the two mask shapes (rule 14) ----------------------------------- Secret-key + redact_keys matches on SCALAR leaves: eight mask chars, plus the last four when the value is long enough to make a tail safe. The mask body is always exactly eight chars — a secret's length is itself information (entropy-safe masking).
47 48 49 50 51 52 |
# File 'lib/foam/otel/redaction.rb', line 47 def tail_mask(str) s = str.to_s return MASK_BODY if s.length < TAIL_MIN_LENGTH "#{MASK_BODY}#{s[-4..]}" end |
.url_attribute?(key) ⇒ Boolean
167 168 169 |
# File 'lib/foam/otel/redaction.rb', line 167 def url_attribute?(key) URL_ATTRIBUTES.include?(key) end |