Module: Foam::Otel::Redaction
- Defined in:
- lib/foam/otel/redaction.rb
Overview
Opt-in, key-list redaction (holistic redesign, 2026-07-26). The package does NO redaction by default: with neither redact_keys nor redact_pii_keys set (or both empty) every value is captured RAW — there is NO always-on secret floor and NO value-pattern auto-masking. Redaction runs ONLY over the keys the customer enumerates:
* redact_keys — tail-mask matching scalar values (shape-preserving,
e.g. ********f456); non-scalars redact in full.
* redact_pii_keys — erase matching values in full ([REDACTED]).
Matching is SUBSTRING, case-insensitive, over the lowercased key.
Two entry points:
* redact(value) — the public helper: mask a value the caller
KNOWS is sensitive (scalar → tail mask,
everything else → full [REDACTED]). This is
unconditional — the caller opted in by
calling it.
* mask_span_data / mask_log_record_data / mask_metric_data — the
exporter-boundary pass that applies the
customer's key lists, run by the redacting
exporters (redacting_exporter.rb). A true
pass-through when no keys are configured.
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 key pass 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: with redact_keys=["token"],
?token=x&user=bobexports astoken=[REDACTED]&user=bob. Only keys the customer listed are ever touched. %w[http.target url.query url.full http.url uri.query].freeze
Class Method Summary collapse
-
.classify_key(key, config) ⇒ Object
---- key classification ---------------------------------------------- redact_keys (:mask) wins over redact_pii_keys (:erase) when both claim a key.
- .deep_mask(value, config, depth) ⇒ Object
-
.descend(value, config = nil, depth = 0) ⇒ Object
Unmatched key: the value itself is captured RAW (no value-pattern auto-masking).
-
.mask_attributes(attributes, config) ⇒ Object
---- the customer key pass over exported attributes ------------------ Returns a NEW attributes hash with the customer's key lists applied, or the same hash untouched when no keys are configured.
-
.mask_body(body, config) ⇒ Object
A log body is AnyValue: a free-text string is captured RAW (no value-pattern masking), a structured map/array DESCENDS so a listed key nested inside is honored (depth-guarded).
- .mask_log_record_data(record, config) ⇒ Object
-
.mask_metric_data(metric_data, config) ⇒ Object
Door 1 masks metric attributes at capture; foreign (door-2) instrument data never passes those helpers, so the ingest metric path applies the key lists here.
- .mask_one(key, value, config) ⇒ Object
-
.mask_span_data(span_data, config) ⇒ Object
---- exporter-boundary key pass (SpanData / LogRecordData) ------------ Rebuild the mutable Struct with the customer's key lists applied.
- .match_any_substring?(lower, keys) ⇒ Boolean
-
.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
-
.redaction_configured?(config) ⇒ Boolean
True when the customer enumerated at least one key to redact.
-
.scalar_or_full(value) ⇒ Object
A redact_keys match: tail-mask a scalar leaf, redact anything else in full (partial masking only helps where it hides the value).
-
.scrub_utf8(value) ⇒ Object
---- encoding hygiene (NOT redaction) --------------------------------- OTLP protobuf requires valid UTF-8, and the upstream Ruby exporter's encode failure drops the WHOLE batch — one invalid-encoding attribute from a helper call would silently kill every healthy span/log/metric batched beside it (the rule-15 silent-loss shape; observed live: the conformance /hostile route's bad-UTF-8 attribute dropping the /health span's batch).
-
.tail_mask(str) ⇒ Object
---- the two mask shapes --------------------------------------------- A redact_keys match on a SCALAR leaf: 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
.classify_key(key, config) ⇒ Object
---- key classification ---------------------------------------------- redact_keys (:mask) wins over redact_pii_keys (:erase) when both claim a key. Substring, case-insensitive.
163 164 165 166 167 168 169 |
# File 'lib/foam/otel/redaction.rb', line 163 def classify_key(key, config) lower = key.downcase return :mask if match_any_substring?(lower, config.redact_keys) return :erase if match_any_substring?(lower, config.redact_pii_keys) nil end |
.deep_mask(value, config, depth) ⇒ Object
319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 |
# File 'lib/foam/otel/redaction.rb', line 319 def deep_mask(value, config, depth) return REDACTED if depth > MAX_REDACT_DEPTH case value when Hash value.each_with_object({}) do |(key, val), out| case classify_key(key.to_s, config) when :erase then out[key] = REDACTED when :mask 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 |
.descend(value, config = nil, depth = 0) ⇒ Object
Unmatched key: the value itself is captured RAW (no value-pattern auto-masking). Structured values still DESCEND so a redact_keys/ redact_pii_keys key NESTED under an innocent key is honored — bounded to MAX_REDACT_DEPTH so an adversarial/cyclic structure can never drive a downstream encoder into SystemStackError (rule 9).
153 154 155 156 157 158 |
# File 'lib/foam/otel/redaction.rb', line 153 def descend(value, config = nil, depth = 0) case value when Hash, Array then deep_mask(value, config || Foam::Otel.active_config, depth) else value end end |
.mask_attributes(attributes, config) ⇒ Object
---- the customer key pass over exported attributes ------------------ Returns a NEW attributes hash with the customer's key lists applied, or the same hash untouched when no keys are configured. Never raises; a key that fails to mask redacts in full rather than leaking.
113 114 115 116 117 118 119 120 121 122 |
# File 'lib/foam/otel/redaction.rb', line 113 def mask_attributes(attributes, config) return attributes unless attributes.is_a?(Hash) return attributes unless redaction_configured?(config) 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 is captured RAW (no value-pattern masking), a structured map/array DESCENDS so a listed key nested inside is honored (depth-guarded). Pass-through when no keys are configured.
311 312 313 314 315 316 317 |
# File 'lib/foam/otel/redaction.rb', line 311 def mask_body(body, config) return body unless redaction_configured?(config) deep_mask(body, config, 0) rescue StandardError REDACTED end |
.mask_log_record_data(record, config) ⇒ Object
265 266 267 268 269 270 271 272 273 274 |
# File 'lib/foam/otel/redaction.rb', line 265 def mask_log_record_data(record, config) return record unless redaction_configured?(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
Door 1 masks metric attributes at capture; foreign (door-2) instrument data never passes those helpers, so the ingest metric path applies the key lists here. Rebuilds every data point as a COPY (the collected points' attributes hash is the LIVE aggregation key upstream — masking in place would corrupt the customer's aggregation state). A true pass-through when no keys are configured.
FAIL CLOSED: returns NIL on a hard failure — the caller drops the metric loudly rather than exporting a half-masked point.
285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 |
# File 'lib/foam/otel/redaction.rb', line 285 def mask_metric_data(metric_data, config) return metric_data unless redaction_configured?(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 OTLP exporter encodes RAW, # one field over from the point attributes the key pass just masked. # Once redaction is configured, drop them: fail-closed, no # observability cost for foam's own use. copy.exemplars = nil if copy.respond_to?(:exemplars=) copy end end masked rescue StandardError, SystemStackError nil end |
.mask_one(key, value, config) ⇒ Object
124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/foam/otel/redaction.rb', line 124 def mask_one(key, value, config) return value unless redaction_configured?(config) case classify_key(key, config) when :erase then REDACTED when :mask then value.is_a?(Array) ? value.map { REDACTED } : scalar_or_full(value) else if url_attribute?(key) redact_url_value(value, config) else descend(value, config) end end end |
.mask_span_data(span_data, config) ⇒ Object
---- exporter-boundary key pass (SpanData / LogRecordData) ------------ Rebuild the mutable Struct with the customer's key lists applied. A dup keeps foam's export copy independent of the span the tenant seam saw. With no keys configured this is a true PASS-THROUGH: the original struct is returned untouched (ZERO redaction — raw capture).
FAIL CLOSED: on a hard failure these return NIL — never the raw payload once a key pass has begun. The redacting exporters drop a nil entry (with a loud [foam] warning) rather than export a half-masked record.
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 |
# File 'lib/foam/otel/redaction.rb', line 237 def mask_span_data(span_data, config) return span_data unless redaction_configured?(config) masked = span_data.dup masked.attributes = mask_attributes(span_data.attributes, config) if span_data.attributes # Link attributes ride the key pass too: links are reachable on BOTH # doors and the OTLP exporter encodes link.attributes RAW. 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 masked rescue StandardError, SystemStackError nil end |
.match_any_substring?(lower, keys) ⇒ Boolean
171 172 173 174 175 |
# File 'lib/foam/otel/redaction.rb', line 171 def match_any_substring?(lower, keys) return false if keys.nil? keys.any? { |k| lower.include?(k) } 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.
45 46 47 48 49 50 51 52 53 54 |
# File 'lib/foam/otel/redaction.rb', line 45 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.
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 |
# File 'lib/foam/otel/redaction.rb', line 204 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 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
188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/foam/otel/redaction.rb', line 188 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 |
.redaction_configured?(config) ⇒ Boolean
True when the customer enumerated at least one key to redact. With neither list set the package does ZERO redaction (raw capture).
99 100 101 102 103 104 105 106 107 |
# File 'lib/foam/otel/redaction.rb', line 99 def redaction_configured?(config) return false if config.nil? keys = config.redact_keys pii = config.redact_pii_keys (!keys.nil? && !keys.empty?) || (!pii.nil? && !pii.empty?) rescue StandardError false end |
.scalar_or_full(value) ⇒ Object
A redact_keys match: tail-mask a scalar leaf, redact anything else in full (partial masking only helps where it hides the value).
141 142 143 144 145 146 |
# File 'lib/foam/otel/redaction.rb', line 141 def scalar_or_full(value) case value when String, Numeric then tail_mask(value.to_s) else REDACTED end end |
.scrub_utf8(value) ⇒ Object
---- encoding hygiene (NOT redaction) --------------------------------- OTLP protobuf requires valid UTF-8, and the upstream Ruby exporter's encode failure drops the WHOLE batch — one invalid-encoding attribute from a helper call would silently kill every healthy span/log/metric batched beside it (the rule-15 silent-loss shape; observed live: the conformance /hostile route's bad-UTF-8 attribute dropping the /health span's batch). Foam scrubs String values to valid UTF-8 AT CAPTURE on its helper surface: invalid bytes become U+FFFD — content otherwise untouched (this is byte-validity, not masking; raw capture stands).
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/foam/otel/redaction.rb', line 65 def scrub_utf8(value) case value when String if value.encoding == Encoding::UTF_8 value.valid_encoding? ? value : value.scrub("�") elsif value.encoding == Encoding::ASCII_8BIT # binary payloads carry no declared charset — treat as UTF-8 bytes value.dup.force_encoding(Encoding::UTF_8).scrub("�") else value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) end when Array value.map { |entry| scrub_utf8(entry) } else value end rescue StandardError, SystemStackError "" # a value that cannot be made encodable degrades, never poisons a batch end |
.tail_mask(str) ⇒ Object
---- the two mask shapes --------------------------------------------- A redact_keys match on a SCALAR leaf: 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).
90 91 92 93 94 95 |
# File 'lib/foam/otel/redaction.rb', line 90 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
184 185 186 |
# File 'lib/foam/otel/redaction.rb', line 184 def url_attribute?(key) URL_ATTRIBUTES.include?(key) end |