foam-otel 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/GOTCHAS.md +97 -28
- data/README.md +202 -22
- data/RESEARCH.md +62 -36
- data/THIRD-PARTY-NOTICES +27 -0
- data/lib/foam/otel/api.rb +21 -19
- data/lib/foam/otel/config.rb +26 -13
- data/lib/foam/otel/constants.rb +313 -10
- data/lib/foam/otel/ingest.rb +29 -12
- data/lib/foam/otel/init.rb +34 -3
- data/lib/foam/otel/llm.rb +36 -8
- data/lib/foam/otel/metrics.rb +7 -1
- data/lib/foam/otel/pipelines.rb +105 -15
- data/lib/foam/otel/redacting_exporter.rb +19 -16
- data/lib/foam/otel/redaction.rb +710 -110
- data/lib/foam/otel/resource.rb +12 -3
- data/lib/foam/otel/version.rb +30 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 180e8af99c1f5c9bfc35de221c4985fb3ebac7b52ddf6fc99d6552a52aaea836
|
|
4
|
+
data.tar.gz: 14a5d81c673af1a20a75e483e076d2c6b1820fa14e5a587e2a23451da3cccf77
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a307d175e903abbbbd1c382e65694664f47aba18408fc7153f9a43e9fb72f8131fedcfc71a59e16ee4f7446dee0f3460233883f96a7048e1284fff75b54e16c7
|
|
7
|
+
data.tar.gz: 7322e0a91e04092d494150ad9b003e4a6fd61b23ebc7468a8c715eec2d5ab8aac64d1edc4c9fb3cb1a844c3d3a56c167b9bbf05bed2bb1d4cb491eba20695f5d
|
data/GOTCHAS.md
CHANGED
|
@@ -240,34 +240,48 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
|
|
|
240
240
|
`@attributes = validated_attributes(@attributes).freeze` then sets `@ended`
|
|
241
241
|
(opentelemetry-sdk `trace/span.rb:277,280`); `to_span_data` returns the frozen
|
|
242
242
|
`@attributes` by reference (`trace/span.rb:296-307`).
|
|
243
|
-
- **
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
`
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
243
|
+
- **Redaction posture (holistic redesign 2026-07-26, amended same day by the
|
|
244
|
+
credential-floor ruling)**: foam does NO customer redaction by default — with
|
|
245
|
+
no `redact_keys`/`redact_pii_keys` every value is captured RAW (no
|
|
246
|
+
value-pattern masking) EXCEPT the ALWAYS-ON CREDENTIAL FLOOR: the frozen
|
|
247
|
+
header/key name list of `contract/credential-denylist.json` masks to
|
|
248
|
+
`[REDACTED]` unconditionally, with no off switch (README "The default
|
|
249
|
+
credential denylist"; design doc `docs/decisions/credential-denylist-design.md`).
|
|
250
|
+
The floor + any configured keys run at the EXPORTER boundary — a
|
|
251
|
+
`RedactingSpanExporter`/`RedactingLogRecordExporter` that rebuilds the
|
|
252
|
+
mutable `SpanData`/`LogRecordData` Structs with masked
|
|
253
|
+
attributes/events/body before serialization. The pass runs unconditionally
|
|
254
|
+
(the floor has no off state); values under unmatched names ride through raw.
|
|
255
|
+
- **Divergence (CLOSED in 1.3.0)**: foam's HELPERS apply the floor + key pass
|
|
256
|
+
AT CAPTURE (`api.rb` `stringify`/`set_attribute`/`log`; `metrics.rb` always
|
|
257
|
+
did), so a tenant `additional_*` instance receives foam-helper data
|
|
253
258
|
ALREADY-MASKED (rule 18 C.3) — the pass is idempotent, so the wire value is
|
|
254
|
-
unchanged.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
`
|
|
259
|
-
`
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
259
|
+
unchanged. THIRD-PARTY INSTRUMENTATION attributes freeze at finish before
|
|
260
|
+
any processor runs (no safe pre-freeze hook — `on_finishing` runs inside
|
|
261
|
+
the span mutex, so a processor calling `set_attribute` there would
|
|
262
|
+
deadlock), so they CANNOT be masked in place — instead the guarded tenant
|
|
263
|
+
wrappers (`pipelines.rb` `GuardedSpanProcessor#on_finish` /
|
|
264
|
+
`GuardedLogRecordProcessor#on_emit`) hand tenants a READ-ONLY MASKED VIEW
|
|
265
|
+
(`MaskedSpanView`/`MaskedLogRecordView`, served through the same
|
|
266
|
+
`Redaction.mask_span_data`/`mask_attributes`/`mask_body` pass foam's export
|
|
267
|
+
applies) — a tenant never sees a raw floor (or configured-key) value, and a
|
|
268
|
+
span whose masked view cannot be built is WITHHELD loudly, never handed
|
|
269
|
+
over raw. Foam's own export applies the same pass, and the exporters FAIL
|
|
270
|
+
CLOSED once the pass has begun: a struct whose rebuild raises is DROPPED
|
|
271
|
+
with a loud warning (`Redaction.mask_*` return nil on hard failure), never
|
|
272
|
+
exported half-masked.
|
|
273
|
+
- **Mitigation**: capture-time pass in `lib/foam/otel/api.rb` + the masked
|
|
274
|
+
tenant view in `lib/foam/otel/pipelines.rb` +
|
|
264
275
|
`lib/foam/otel/redacting_exporter.rb` + `lib/foam/otel/redaction.rb`.
|
|
265
|
-
- **Test**: `spec/
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
`spec/
|
|
269
|
-
|
|
270
|
-
|
|
276
|
+
- **Test**: `spec/credential_floor_spec.rb` + `spec/credential_floor_wire_spec.rb`
|
|
277
|
+
(the floor per signal/door; the tenant masked-view regression gate — a
|
|
278
|
+
third-party-set `authorization` attribute never reaches a tenant raw),
|
|
279
|
+
`spec/tenant_seam_spec.rb` (tenant processors receive helper-set LISTED keys
|
|
280
|
+
ALREADY-MASKED, free-text bodies RAW, the byte-identical with/without-tenant
|
|
281
|
+
proofs for spans, logs, AND metrics), `spec/redacting_exporter_spec.rb`
|
|
282
|
+
(masking on the wire; "a POISONED SpanData (dup raises) is DROPPED — never
|
|
283
|
+
exported raw"), `spec/redaction_spec.rb` (raw-above-the-floor defaults + the
|
|
284
|
+
two opt-in key lists; capture+exporter idempotence).
|
|
271
285
|
|
|
272
286
|
## F2: OTEL_SDK_DISABLED and OTEL_PROPAGATORS are only honored by SDK.configure
|
|
273
287
|
|
|
@@ -568,6 +582,59 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
|
|
|
568
582
|
rack end-to-end with real `traceparent`+`baggage` headers, the no-op and
|
|
569
583
|
never-raise cases.
|
|
570
584
|
|
|
585
|
+
## F13: The value-pattern secret layer + coverage fix + TLS pin (1.4.0, security ruling 2026-07-27)
|
|
586
|
+
|
|
587
|
+
The credential floor masks by NAME and is value-shape-blind; a live secret
|
|
588
|
+
under an innocuous key (`note`, `detail`) sailed past it. Foam telemetry is
|
|
589
|
+
read downstream by LLMs, so ONE leaked credential is prompt-injection
|
|
590
|
+
exfiltratable — the value-pattern secret layer is the required second control
|
|
591
|
+
(`docs/decisions/security-fixes-design.md` CONTRACT V).
|
|
592
|
+
|
|
593
|
+
- **What / where**: `Redaction.scan_value_secrets` masks credential-SHAPED
|
|
594
|
+
spans (`SECRET_VALUE_RULES` + the PEM/PuTTY linear scans + the H1/H2
|
|
595
|
+
heuristics) to `[REDACTED]` in EVERY leaf string the engine walks —
|
|
596
|
+
attribute values, `status.message`, exception events, log bodies (string +
|
|
597
|
+
structured leaves), SQL, LLM content (BEFORE serialization, F-RB2), URL
|
|
598
|
+
query AND fragment pair values (Contract C1), resource identity strings
|
|
599
|
+
(once at build), exemplar `filtered_attributes`. It rides the SAME exporter-
|
|
600
|
+
boundary seam as the floor — one pass, ordered floor → C1 tokenizer → value
|
|
601
|
+
scan → user keys, each hit terminal (`redaction.rb` `mask_one`/
|
|
602
|
+
`redact_leaf_string`). METRIC DATAPOINT ATTRIBUTES are the one mandated
|
|
603
|
+
exemption (`metrics.rb`/`mask_metric_data` pass `value_scan: false`) —
|
|
604
|
+
low-cardinality by construction; a value mask there would break aggregation
|
|
605
|
+
dimensions.
|
|
606
|
+
- **Non-disableable Tier 1**: the rules are frozen module constants, not
|
|
607
|
+
Config — no config path can narrow them (same posture as the floor). ONLY
|
|
608
|
+
the H1/H2 heuristic tier honors `secret_heuristics: false`, logged loud at
|
|
609
|
+
init.
|
|
610
|
+
- **ReDoS-bounded / fail-closed**: compile-once, a lowercase-literal anchor
|
|
611
|
+
pre-filter (no anchor → zero regexes), a 256 KiB value cap (oversize +
|
|
612
|
+
anchor ⇒ whole-value mask, never regex-scanned), a match-flood cap, a
|
|
613
|
+
256-pair query/fragment cap, a per-Regexp `timeout:` on Ruby ≥ 3.2. ANY
|
|
614
|
+
scanner fault (timeout included) masks the value, never a raw pass-through.
|
|
615
|
+
- **F-RB1 TLS pin (CWE-295)**: the vendored OTLP exporter honored
|
|
616
|
+
`OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE` as a PRESENCE check — one env var
|
|
617
|
+
would silently strip cert verification off the token-bearing export
|
|
618
|
+
connection. Every exporter constructor (door 1 `pipelines.rb`, door 2
|
|
619
|
+
`ingest.rb`) now pins `ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER`; the var
|
|
620
|
+
is INERT and warned about (`init.rb` `warn_inert_ssl_verify_none`). There is
|
|
621
|
+
no foam option to weaken it.
|
|
622
|
+
- **Mitigation**: `lib/foam/otel/constants.rb` (the frozen ruleset),
|
|
623
|
+
`lib/foam/otel/redaction.rb` (the engine + coverage tokenizer),
|
|
624
|
+
`lib/foam/otel/pipelines.rb` + `lib/foam/otel/ingest.rb` (the TLS pin),
|
|
625
|
+
`lib/foam/otel/llm.rb` (C4 pre-serialization masking).
|
|
626
|
+
- **Sources**: `docs/decisions/security-fixes-design.md`;
|
|
627
|
+
gitleaks/detect-secrets/secretlint provenance in `THIRD-PARTY-NOTICES`;
|
|
628
|
+
vendored exporter presence-check at
|
|
629
|
+
opentelemetry-exporter-otlp `exporter.rb:39-47`.
|
|
630
|
+
- **Test**: `spec/value_secret_layer_spec.rb` (the per-rule corpus, caps,
|
|
631
|
+
precedence, coverage contract, off-surface pin, structural gates),
|
|
632
|
+
`spec/security_regressions_spec.rb` (F-RB1 + F-RB2 verbatim repros),
|
|
633
|
+
`spec/vuln_suite_spec.rb` (the adversarial exfil/ReDoS/bypass suite +
|
|
634
|
+
mutation spot-checks).
|
|
635
|
+
|
|
636
|
+
---
|
|
637
|
+
|
|
571
638
|
## General gotchas (applicable to Ruby)
|
|
572
639
|
|
|
573
640
|
- **G1 — init after target import / pre-init no-op**: the API's proxy providers
|
|
@@ -623,8 +690,10 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
|
|
|
623
690
|
mitigation (foam never rewrites or drops metric dimensions — rule 4/26);
|
|
624
691
|
the mitigation is the README metrics warning with the classic bad example —
|
|
625
692
|
*Documented* (README "Metrics" section). *Test*: `spec/metrics_spec.rb`
|
|
626
|
-
("captures a metric attribute RAW by default; masks it only when
|
|
627
|
-
redact_keys" — pins that unlisted attributes pass
|
|
693
|
+
("captures a NON-floor metric attribute RAW by default; masks it only when
|
|
694
|
+
the key is in redact_keys" — pins that unlisted, non-floor attributes pass
|
|
695
|
+
through VERBATIM; the credential floor's frozen names are the one always-on
|
|
696
|
+
exception, `spec/credential_floor_wire_spec.rb`).
|
|
628
697
|
- **G8 — never-ended spans leak memory**: a span started and never ended holds
|
|
629
698
|
memory and context forever (and never exports — the batch processor only
|
|
630
699
|
sees FINISHED spans, `on_finish`). `Foam::Otel.span {}` always ends the span
|
data/README.md
CHANGED
|
@@ -4,9 +4,15 @@ Foam's OpenTelemetry core for Ruby services. A thin, safe wrapper over the
|
|
|
4
4
|
official OpenTelemetry libraries: foam owns the pipeline (providers, batch
|
|
5
5
|
processors, OTLP export to the foam fleet endpoint), turns on automatic
|
|
6
6
|
tier-1/2 instrumentation, and hands you a small set of never-throw helpers —
|
|
7
|
-
and nothing else. **Redaction is fully opt-in
|
|
8
|
-
value RAW** (no
|
|
9
|
-
|
|
7
|
+
and nothing else. **Redaction is fully opt-in above two always-on exceptions:
|
|
8
|
+
by default foam captures every value RAW** (no PII preset) **except (1) the
|
|
9
|
+
credential floor** — a fixed list of credential/secret header and field NAMES
|
|
10
|
+
always masked to `[REDACTED]` (see "The default credential denylist" below) —
|
|
11
|
+
**and (2) the value-pattern secret layer** — credential-SHAPED value spans
|
|
12
|
+
(cloud keys, tokens, JWTs, private-key blocks, `user:pass@` URLs…) masked to
|
|
13
|
+
`[REDACTED]` regardless of field name (see "The value-pattern secret layer"
|
|
14
|
+
below); you enumerate any further fields to mask via
|
|
15
|
+
`redact_keys`/`redact_pii_keys`.
|
|
10
16
|
|
|
11
17
|
Built to `docs/BASE_PACKAGE_SPEC.md`. This README is the manual you integrate
|
|
12
18
|
from.
|
|
@@ -75,10 +81,12 @@ drop-in upstream metrics gem, so foam ships its own collector),
|
|
|
75
81
|
**session stitching** (inbound `baggage: session.id` from the foam browser
|
|
76
82
|
package is stamped onto every span and log record), and the **LLM shims**
|
|
77
83
|
(below). Every inbound request, DB call, HTTP call, job, log line, and model
|
|
78
|
-
call now flows to foam. **Redaction is opt-in
|
|
79
|
-
attributes, URLs/query strings, DB statement text, log
|
|
80
|
-
is captured RAW
|
|
81
|
-
|
|
84
|
+
call now flows to foam. **Redaction is opt-in above the credential floor: by
|
|
85
|
+
default every value — attributes, URLs/query strings, DB statement text, log
|
|
86
|
+
bodies, LLM content — is captured RAW, except values whose NAME is on the
|
|
87
|
+
always-on credential denylist (masked `[REDACTED]`; see "The default
|
|
88
|
+
credential denylist" below).** Pass `redact_keys`/`redact_pii_keys` to mask
|
|
89
|
+
or erase further specific fields (see below).
|
|
82
90
|
|
|
83
91
|
> **Sidekiq trace shape** (GOTCHAS G16): the bundled instrumentation keeps
|
|
84
92
|
> its upstream default `propagation_style: :link` — a performed job runs as
|
|
@@ -134,14 +142,15 @@ API):** the allowlist must include BOTH headers —
|
|
|
134
142
|
| `enabled:` | Boolean | yes | — | The config switch. `false` = fully inert (no SDK, no providers, no network, helpers no-op). `true` = export, in EVERY environment. No default — you write the logic. |
|
|
135
143
|
| `token:` | String | when enabled | — | `Authorization: Bearer` for export. Required (and validated) only when `enabled: true`; wired explicitly from your secret source — never an env fallback read by the gem. **Fleet convention: read it from the `FOAM_OTEL_TOKEN` env var.** Blank while enabled → raises at boot. |
|
|
136
144
|
| `version:` | String | no | nil | `service.version`, verbatim (git SHA recommended). Missing → warns and continues. Never detected at runtime. |
|
|
137
|
-
| `redact_keys:` | Array<String> | no | `[]` | The ONLY field names tail-masked (shape-preserving, e.g. `********f456`). Opt-in — empty (the default) means
|
|
138
|
-
| `redact_pii_keys:` | Array<String> | no | `[]` | Field names fully erased to `[REDACTED]` (no tail). Opt-in — empty (the default) means
|
|
145
|
+
| `redact_keys:` | Array<String> | no | `[]` | The ONLY field names tail-masked (shape-preserving, e.g. `********f456`). Opt-in and ADDITIVE on top of the always-on credential floor — empty (the default) means no masking beyond the floor. Matches by case-insensitive substring, including keys nested in structured values and query-string keys. A key that is also on the floor stays fully `[REDACTED]` (the floor wins; it is never downgraded to a tail). |
|
|
146
|
+
| `redact_pii_keys:` | Array<String> | no | `[]` | Field names fully erased to `[REDACTED]` (no tail). Opt-in and additive above the floor — empty (the default) means no erasure beyond the floor. Foam ships no PII preset; this is your enumeration. |
|
|
139
147
|
| `additional_span_processors:` | Array | no | `[]` | Tenant seam: constructed SpanProcessor instances added to foam's pipeline (additive; never replace foam's export). See coexistence. |
|
|
140
148
|
| `additional_log_record_processors:` | Array | no | `[]` | Tenant seam, logs. |
|
|
141
149
|
| `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
|
|
142
150
|
| `additional_instrumentations:` | Array | no | `[]` | Constructed tier-2 instrumentation instances to register (fault-isolated: one that throws is skipped with a `[foam]` warning). |
|
|
143
151
|
| `ignored_outbound_hosts:` | Array<String> | no | `[]` | Hosts whose outbound calls produce no spans — EXTENDS the built-in export-loop guard. For a co-resident agent's intake or a tenant exporter's endpoint. Applies to the clients whose official instrumentation supports host suppression: **Net::HTTP and Excon**. Faraday/HTTP (httprb)/HTTPX have no upstream `untraced_hosts` option — foam warns loudly at init when one of those is bundled (GOTCHAS G13); foam's own export loop is guarded for every client regardless. |
|
|
144
152
|
| `diagnostics:` | Boolean | no | false | Verbose `[foam]` self-reporting of init/health. Warnings and errors are always loud regardless. |
|
|
153
|
+
| `secret_heuristics:` | Boolean | no | true | The value-pattern secret layer's HEURISTIC tier (generic keyword+entropy detection, see "The value-pattern secret layer"). `false` disables ONLY the heuristics — the named provider patterns, the credential floor and the redaction-coverage contract have no off switch. Disabling logs one loud `[foam]` line at init (an explicit, audited decision for telemetry whose legitimate values collide with the heuristics). |
|
|
145
154
|
|
|
146
155
|
Examples for the remaining options (each is runnable as-is):
|
|
147
156
|
|
|
@@ -152,6 +161,16 @@ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: EN
|
|
|
152
161
|
redact_pii_keys: %w[customer_email full_name]) # fully [REDACTED]
|
|
153
162
|
```
|
|
154
163
|
|
|
164
|
+
```ruby
|
|
165
|
+
# Only if your legitimate telemetry collides with the generic secret
|
|
166
|
+
# heuristics (e.g. base64 content-addressed ids masked as high-entropy
|
|
167
|
+
# tokens): disable the HEURISTIC tier only. The named provider patterns and
|
|
168
|
+
# the credential floor stay on — they have no off switch. Logs one loud
|
|
169
|
+
# [foam] line.
|
|
170
|
+
Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
|
|
171
|
+
secret_heuristics: false)
|
|
172
|
+
```
|
|
173
|
+
|
|
155
174
|
```ruby
|
|
156
175
|
# Constructed tier-2 instrumentation instances (fault-isolated; one that
|
|
157
176
|
# throws is skipped with a [foam] warning, never a crashed boot):
|
|
@@ -193,6 +212,159 @@ override is the operator-level `OTEL_EXPORTER_OTLP_ENDPOINT` env var (below).
|
|
|
193
212
|
|
|
194
213
|
---
|
|
195
214
|
|
|
215
|
+
## The default credential denylist (the always-on floor)
|
|
216
|
+
|
|
217
|
+
**Fleet ruling 2026-07-26** (binding design:
|
|
218
|
+
`docs/decisions/credential-denylist-design.md`; frozen fixture:
|
|
219
|
+
`contract/credential-denylist.json`). This is the ONE exception to foam's
|
|
220
|
+
raw-by-default capture, and it has **no off switch** — no init option, no env
|
|
221
|
+
var, and no door-2 parameter can disable, shrink, or re-spell it.
|
|
222
|
+
|
|
223
|
+
**What is masked.** The VALUE of every header/attribute/field whose NAME is on
|
|
224
|
+
the frozen list, in every signal (spans — events and links included — logs,
|
|
225
|
+
metrics), on door 1, on all three door-2 ingest taps, and in the view handed
|
|
226
|
+
to tenant `additional_*` processors:
|
|
227
|
+
|
|
228
|
+
- **(a) the seven credential headers**, matched as header names wherever
|
|
229
|
+
headers are captured — including the semconv
|
|
230
|
+
`http.request.header.<name>` / `http.response.header.<name>` span-attribute
|
|
231
|
+
forms: `authorization`, `proxy-authorization`, `cookie`, `set-cookie`,
|
|
232
|
+
`x-api-key`, `x-auth-token`, `www-authenticate`;
|
|
233
|
+
- **(b) the frozen 52-entry key list** of
|
|
234
|
+
`contract/credential-denylist.json` (the deduplicated union of those seven,
|
|
235
|
+
sentry-python's default denylists, and foam's documented reference roots —
|
|
236
|
+
`password`, `token`, `secret`, `api_key`, `ssn`, `jwt`, `private_key`,
|
|
237
|
+
`connect.sid`, `phpsessid`, …), matched as attribute/field names at every
|
|
238
|
+
depth the engine walks, and per-key in URL query strings
|
|
239
|
+
(`?token=x&user=bob` → `token=[REDACTED]&user=bob`).
|
|
240
|
+
|
|
241
|
+
**The matching rule.** Case-insensitive, dash/underscore-normalized **EXACT
|
|
242
|
+
name-equality — never substring**: `authorization` masks the header/field
|
|
243
|
+
`authorization` (any casing, `-`≡`_`) and nothing else — `authorization_url`,
|
|
244
|
+
`x_api_key_id`, `secretary`, and `session_count` all ride RAW. Dotted entries
|
|
245
|
+
match verbatim and whole (`connect.sid` masks; `user.session` does not). This
|
|
246
|
+
is deliberately STRICTER matching than your own `redact_keys` lists, which
|
|
247
|
+
keep their documented substring behavior.
|
|
248
|
+
|
|
249
|
+
**The mask.** The full literal `[REDACTED]` — no tail, no length
|
|
250
|
+
preservation. These are credentials, not debug aids. Semconv header
|
|
251
|
+
attributes (string arrays) mask per element, preserving arity.
|
|
252
|
+
|
|
253
|
+
**Precedence.** The floor runs unconditionally BEFORE your
|
|
254
|
+
`redact_keys`/`redact_pii_keys`, which stay purely additive on top. Listing a
|
|
255
|
+
floor name in `redact_keys` does NOT downgrade it to a tail mask — the floor
|
|
256
|
+
is terminal. `enabled: false` stays fully inert exactly as before (nothing
|
|
257
|
+
exports at all — the floor masks data that leaves; inert mode has none).
|
|
258
|
+
|
|
259
|
+
**Everything else stays RAW — above the floor and the value layer.** The
|
|
260
|
+
floor matches NAMES only, never values. Credential-SHAPED value spans are the
|
|
261
|
+
value-pattern secret layer's job (next section); UNSHAPED values — bodies,
|
|
262
|
+
query values, SQL text, LLM content, and PII under unlisted names — export
|
|
263
|
+
byte-identical raw, exactly as documented everywhere else in this README.
|
|
264
|
+
|
|
265
|
+
**Why.** Coverage-over-masking remains the mission, but raw `Authorization`
|
|
266
|
+
headers, cookies, and API keys on the wire are a breach in waiting for every
|
|
267
|
+
customer at once — the fleet ruling carves out exactly this list (and only
|
|
268
|
+
this list) as non-negotiable. The list is byte-identical in every foam core
|
|
269
|
+
and gate-checked in CI against the fleet fixture
|
|
270
|
+
(`spec/credential_floor_spec.rb`).
|
|
271
|
+
|
|
272
|
+
**Fleet param-name canon** (the customer options above the floor, identical
|
|
273
|
+
concept in every core — the floor itself has NO init surface anywhere):
|
|
274
|
+
|
|
275
|
+
| Concept | js/otel | js/browser | python | ruby | java |
|
|
276
|
+
|---|---|---|---|---|---|
|
|
277
|
+
| secret keys (tail-mask) | `redactKeys` | `redactKeys` | `redact_keys` | `redact_keys:` | `.redactKeys(String…)` |
|
|
278
|
+
| PII keys (full `[REDACTED]`) | `redactPiiKeys` | `redactPiiKeys` | `redact_pii_keys` | `redact_pii_keys:` | `.redactPiiKeys(String…)` |
|
|
279
|
+
|
|
280
|
+
> **Migration note — 1.3.0 (minor): always-on credential masking.** As of
|
|
281
|
+
> this version foam masks, by default and in every signal, the VALUES of a
|
|
282
|
+
> fixed list of credential/secret header and field NAMES (`authorization`,
|
|
283
|
+
> `cookie`, `set-cookie`, `proxy-authorization`, `x-api-key`, `x-auth-token`,
|
|
284
|
+
> `www-authenticate`, and the 52-name key list in
|
|
285
|
+
> `contract/credential-denylist.json` — Sentry-parity plus foam's documented
|
|
286
|
+
> roots) to the literal `[REDACTED]`. Matching is exact name-equality,
|
|
287
|
+
> case-insensitive, dash/underscore-insensitive — never substring:
|
|
288
|
+
> `authorization_url` is untouched. Everything else still exports RAW exactly
|
|
289
|
+
> as before; `redact_keys`/`redact_pii_keys` are unchanged and additive.
|
|
290
|
+
> There is no off switch — if a dashboard keyed off a raw credential value
|
|
291
|
+
> (it should not have), it will now see `[REDACTED]`. Customers who
|
|
292
|
+
> previously received these header/field values raw stop receiving them.
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## The value-pattern secret layer (always-on)
|
|
297
|
+
|
|
298
|
+
The credential floor masks by NAME and is deliberately value-shape-blind — a
|
|
299
|
+
live AWS key under the field name `note` would sail past it. Foam telemetry is
|
|
300
|
+
read downstream by LLMs, so a leaked credential is exfiltratable by prompt
|
|
301
|
+
injection; "no leakage at all" is the bar. The value-pattern secret layer is
|
|
302
|
+
the second always-on control (fleet ruling 2026-07-27,
|
|
303
|
+
`docs/decisions/security-fixes-design.md`):
|
|
304
|
+
|
|
305
|
+
**What it masks.** Credential-SHAPED value spans, regardless of the field
|
|
306
|
+
name they hide under: AWS access-key ids and keyword-anchored AWS secrets,
|
|
307
|
+
GCP API keys, Azure client/storage secrets, GitHub tokens and fine-grained
|
|
308
|
+
PATs, GitLab tokens, Slack tokens + webhooks, Stripe secret keys
|
|
309
|
+
(publishable `pk_` keys are deliberately NOT masked), JWTs, PEM private-key
|
|
310
|
+
blocks (`-----BEGIN … PRIVATE KEY` — a truncated block masks to the end of
|
|
311
|
+
the value, fail-closed; `PUBLIC KEY`/`CERTIFICATE` blocks never match) and
|
|
312
|
+
PuTTY PPK keys, `scheme://user:pass@` URI credentials (the whole userinfo,
|
|
313
|
+
username included), `Bearer`/`Basic` auth values, and Anthropic/OpenAI API
|
|
314
|
+
keys. The matched span becomes the literal `[REDACTED]` — no tail, no
|
|
315
|
+
prefix, zero reconstructable bytes; surrounding text keeps its diagnostic
|
|
316
|
+
value (`conn to postgres://[REDACTED]@db:5432 refused`).
|
|
317
|
+
|
|
318
|
+
**The heuristic tier** additionally catches the long tail: a
|
|
319
|
+
keyword-anchored generic pass (`password = hunter2SecretXyz99` inside a log
|
|
320
|
+
body masks its value — gitleaks-derived, entropy-gated, with placeholder/
|
|
321
|
+
stopword/key-context false-positive suppression) and a whole-value
|
|
322
|
+
high-entropy base64 token pass (detect-secrets-derived; pure hex and UUIDs
|
|
323
|
+
are deliberately exempt — trace ids and digests are telemetry's ambient
|
|
324
|
+
vocabulary). Only this tier is disableable: `secret_heuristics: false`
|
|
325
|
+
(one loud `[foam]` line; the named patterns above are not configurable).
|
|
326
|
+
|
|
327
|
+
**Where it runs.** Every value-bearing string on every export path, both
|
|
328
|
+
doors: span/event/link attribute values, `status.message`, exception
|
|
329
|
+
messages, log bodies (string and structured leaves), SQL (`db.statement`),
|
|
330
|
+
LLM content (BEFORE serialization and again at the boundary), URL query AND
|
|
331
|
+
fragment pair values, baggage-derived attributes, exemplar
|
|
332
|
+
`filtered_attributes`, and resource identity strings (once, at init).
|
|
333
|
+
Metric datapoint attributes (metric labels) are the one exempt surface —
|
|
334
|
+
low-cardinality by construction; a value mask there would break your
|
|
335
|
+
aggregation dimensions.
|
|
336
|
+
|
|
337
|
+
**Bounded by design.** Compile-once rules, a literal-anchor pre-filter (no
|
|
338
|
+
anchor in the value → zero regexes run), a 256 KiB value cap (an oversized
|
|
339
|
+
value with an anchor hit anywhere masks WHOLE, fail-closed; without one it
|
|
340
|
+
passes untouched), a match-flood cap, a 256-pair query/fragment tokenizer
|
|
341
|
+
cap, and a per-Regexp timeout on Ruby ≥ 3.2. ANY scanner fault masks the
|
|
342
|
+
value rather than shipping it raw.
|
|
343
|
+
|
|
344
|
+
**Provenance.** The ruleset derives from MIT/Apache-licensed OSS secret
|
|
345
|
+
scanners (gitleaks, detect-secrets, secretlint) — see `THIRD-PARTY-NOTICES`.
|
|
346
|
+
Rules are frozen module constants, byte-consistent across the foam cores,
|
|
347
|
+
changed only by fleet ruling.
|
|
348
|
+
|
|
349
|
+
> **Migration note — 1.4.0 (minor): value-pattern secret masking + redaction
|
|
350
|
+
> coverage fix + TLS pin.** foam now masks credential-SHAPED values
|
|
351
|
+
> (AWS/GCP/Azure keys, GitHub/GitLab tokens, Slack, Stripe, Anthropic/OpenAI
|
|
352
|
+
> keys, JWTs, private-key blocks, `user:pass@` URI credentials, bearer/basic
|
|
353
|
+
> tokens) to `[REDACTED]` in every exported string value — regardless of
|
|
354
|
+
> field name — plus a generic keyword+entropy heuristic you can disable with
|
|
355
|
+
> `secret_heuristics: false` (the named patterns and the credential floor
|
|
356
|
+
> cannot be disabled). Redaction now also reaches URL FRAGMENTS (OAuth
|
|
357
|
+
> implicit-flow tokens, hash-router params), bare `k=v` pair lists, nested
|
|
358
|
+
> attribute containers (fail-closed), and structured LLM content BEFORE
|
|
359
|
+
> serialization. Query/fragment tokenizing is capped at 256 pairs (the
|
|
360
|
+
> overflow remainder masks whole). Metric labels are not value-scanned. TLS
|
|
361
|
+
> peer verification is now PINNED on every exporter:
|
|
362
|
+
> `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE` is INERT and loudly warned about.
|
|
363
|
+
> If a dashboard keyed off a raw token value (it should not have), it will
|
|
364
|
+
> now see `[REDACTED]`.
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
196
368
|
## The helpers
|
|
197
369
|
|
|
198
370
|
All helpers never raise, and no-op silently before `init` and when disabled.
|
|
@@ -288,10 +460,13 @@ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: FO
|
|
|
288
460
|
> Ruby note: when you configure `redact_keys`/`redact_pii_keys`, foam's HELPERS
|
|
289
461
|
> apply the key pass at capture, so a tenant processor receives foam-helper data
|
|
290
462
|
> (span/log helper attributes, log bodies, metric labels) ALREADY masked.
|
|
291
|
-
>
|
|
292
|
-
> processor
|
|
293
|
-
>
|
|
294
|
-
>
|
|
463
|
+
> The Ruby SDK freezes span attributes at finish, so historically a tenant
|
|
464
|
+
> processor saw third-party instrumentation attributes UNMASKED — as of 1.3.0
|
|
465
|
+
> that gap is closed: tenant span/log processors are handed a READ-ONLY MASKED
|
|
466
|
+
> VIEW (the credential floor plus your configured keys, the same pass foam's
|
|
467
|
+
> own export applies — GOTCHAS F1). A tenant never sees a raw floor value.
|
|
468
|
+
> With no keys configured (the default) only the credential floor is masked
|
|
469
|
+
> on either path.
|
|
295
470
|
>
|
|
296
471
|
> Loop-guard note: `ignored_outbound_hosts` suppresses spans for tenant/agent
|
|
297
472
|
> egress made through **Net::HTTP or Excon**. A tenant exporter using
|
|
@@ -329,9 +504,10 @@ end
|
|
|
329
504
|
|
|
330
505
|
**Outcome**: their pipeline keeps working exactly as before — their data,
|
|
331
506
|
their resource, their export, byte-identical — and foam ALSO receives the
|
|
332
|
-
signal, carrying THEIR resource identity plus foam's export-time stamp
|
|
333
|
-
|
|
334
|
-
|
|
507
|
+
signal, carrying THEIR resource identity plus foam's export-time stamp, the
|
|
508
|
+
always-on credential floor applied to foam's copy, and any per-tap
|
|
509
|
+
`redact_keys`/`redact_pii_keys` you set (opt-in — none by default; details in
|
|
510
|
+
the Door 2 section).
|
|
335
511
|
|
|
336
512
|
**Unhappy paths**:
|
|
337
513
|
|
|
@@ -431,8 +607,10 @@ readers only: they never mutate their data, their resource, or their export.
|
|
|
431
607
|
### The three entries
|
|
432
608
|
|
|
433
609
|
All three share one signature (`token:` and `environment:` required; the
|
|
434
|
-
redaction kwargs are the
|
|
435
|
-
scoped to that tap
|
|
610
|
+
redaction kwargs are the only CUSTOMER keys that tap redacts — opt-in, none by
|
|
611
|
+
default, scoped to that tap, additive above the always-on credential floor,
|
|
612
|
+
which every tap applies with zero configuration; `diagnostics:` is tap-scoped
|
|
613
|
+
narration). Construction
|
|
436
614
|
validates loudly at boot and NEVER
|
|
437
615
|
throws afterwards — a failure inside a tap can never break their pipeline.
|
|
438
616
|
Fleet canon mapping (pinned by `spec/export_surface_spec.rb`):
|
|
@@ -453,9 +631,10 @@ their_tracer_provider.add_span_processor(processor)
|
|
|
453
631
|
# raises ArgumentError at construction for a blank token/environment
|
|
454
632
|
```
|
|
455
633
|
|
|
456
|
-
**Logs** — a LogRecordProcessor for THEIR LoggerProvider. The
|
|
457
|
-
`redact_keys`/`redact_pii_keys` (if any) match over
|
|
458
|
-
nested in structured bodies; free-text bodies ride
|
|
634
|
+
**Logs** — a LogRecordProcessor for THEIR LoggerProvider. The credential
|
|
635
|
+
floor and the tap's `redact_keys`/`redact_pii_keys` (if any) match over
|
|
636
|
+
record attributes and keys nested in structured bodies; free-text bodies ride
|
|
637
|
+
RAW (no value-pattern pass):
|
|
459
638
|
|
|
460
639
|
```ruby
|
|
461
640
|
provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: their_resource)
|
|
@@ -499,7 +678,7 @@ in THEIR pipeline. Three layers keep it closed:
|
|
|
499
678
|
| | their pipeline | foam's copy |
|
|
500
679
|
| --- | --- | --- |
|
|
501
680
|
| `service.name` / `service.version` / `service.instance.id` | theirs | theirs, untouched (the data carries THEIR identity) |
|
|
502
|
-
| all attributes / events / bodies | raw, byte-identical | your per-tap `redact_keys`/`redact_pii_keys` applied (default:
|
|
681
|
+
| all attributes / events / bodies | raw, byte-identical | the credential floor + your per-tap `redact_keys`/`redact_pii_keys` applied (default: floor only — everything off the frozen list identical to theirs) |
|
|
503
682
|
| `deployment.environment.name` | theirs (if any) | the tap's `environment:` argument (stamped at export time) |
|
|
504
683
|
| `foam.ingest.tier` | absent | `"external"` (the wire-contract marker) |
|
|
505
684
|
| `telemetry.distro.name` / `telemetry.distro.version` | absent | `"foam"` / the gem version (support reads the tap's presence and version from telemetry alone) |
|
|
@@ -576,6 +755,7 @@ end
|
|
|
576
755
|
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | INERT — foam wires ONE resolved endpoint into all three exporters explicitly, so per-signal endpoint vars never redirect (or split) foam's export. Set → loud `[foam]` warning that it is inert. |
|
|
577
756
|
| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | INERT — as above (warns when set). |
|
|
578
757
|
| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | INERT — as above (warns when set). |
|
|
758
|
+
| `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE` | INERT — foam pins TLS peer verification (`ssl_verify_mode: VERIFY_PEER`) on every exporter constructor, both doors, so this vendored-exporter knob can never strip certificate verification off the credential-bearing export connection (1.4.0 security fix, CWE-295). Set → loud `[foam]` warning that it is inert; fix the trust chain (CA bundle) instead. |
|
|
579
759
|
| every other `OTEL_*` | INERT — `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES` never override the init-declared identity; sampler/exporter/span-limit vars are ignored. |
|
|
580
760
|
|
|
581
761
|
---
|
data/RESEARCH.md
CHANGED
|
@@ -144,8 +144,11 @@ statement posture is **captured RAW, never suppressed** (2026-07-26 ruling —
|
|
|
144
144
|
coverage is the mission), and the upstream `-pg`/`-mysql2`/`-redis`/`-mongo`
|
|
145
145
|
gems all DEFAULT to `db_statement: :obfuscate` — a sanitizer — so foam
|
|
146
146
|
explicitly activates them with `db_statement: :include` (GOTCHAS F9; pinned
|
|
147
|
-
by `spec/floor_spec.rb`). Redaction stays fully opt-in
|
|
148
|
-
|
|
147
|
+
by `spec/floor_spec.rb`). Redaction stays fully opt-in above the always-on
|
|
148
|
+
credential floor (the frozen NAME list of
|
|
149
|
+
`contract/credential-denylist.json` — statement TEXT is never scanned),
|
|
150
|
+
applied only for keys the customer lists in
|
|
151
|
+
`redact_keys`/`redact_pii_keys`. An FDE can still clamp
|
|
149
152
|
`db_statement: :omit`/`:obfuscate` per customer via the standard
|
|
150
153
|
`OTEL_RUBY_INSTRUMENTATION_<NAME>_CONFIG_OPTS` env var when a compliance
|
|
151
154
|
context calls for it — per-customer tightening (rule 18 / Area 4), not the
|
|
@@ -210,7 +213,8 @@ foam ships the logs pipeline + `log()` helper (trace-correlated) — the mechani
|
|
|
210
213
|
rule 20 requires. The stdlib **Logger bridge is LANDED as foam's own code**
|
|
211
214
|
(`lib/foam/otel/logger_bridge.rb`, console-logs ruling 2026-07-26): one
|
|
212
215
|
prepend on `Logger#add` turns every message a host logger accepts into a
|
|
213
|
-
trace-correlated OTel log record — body RAW (
|
|
216
|
+
trace-correlated OTel log record — body RAW (name-keyed masking only: the
|
|
217
|
+
always-on credential floor plus any customer keys; free text never scanned),
|
|
214
218
|
severity mapped, loop-guarded (the OTel SDK's internal logger is skipped by
|
|
215
219
|
identity + a thread-local re-entrancy flag), gated on foam owning the logs
|
|
216
220
|
slot, and never able to break the host's log call (GOTCHAS F10; tests
|
|
@@ -226,7 +230,8 @@ The official contrib `opentelemetry-instrumentation-logger 0.4.0` bridges the
|
|
|
226
230
|
same `Logger#add` seam and is deliberately NOT bundled — census disposition
|
|
227
231
|
EXCLUDED/superseded (§3): foam's bridge additionally holds the lazy-block
|
|
228
232
|
contract, the OTel-internal-logger identity guard, the re-entrancy flag, the
|
|
229
|
-
logs-slot gate, and the opt-in
|
|
233
|
+
logs-slot gate, and the redaction path (credential floor + opt-in keys), and
|
|
234
|
+
shipping/sweeping both
|
|
230
235
|
would double-export every host log line (rule 12). init's sweep pins the
|
|
231
236
|
contrib gem disabled even when an FDE bundles it (`spec/floor_spec.rb`).
|
|
232
237
|
|
|
@@ -289,38 +294,59 @@ https://opentelemetry.io/docs/specs/semconv/gen-ai/aws-bedrock/ ; traceloop Ruby
|
|
|
289
294
|
|
|
290
295
|
## 6. Redaction design record (rule 14, as amended 2026-07-26)
|
|
291
296
|
|
|
292
|
-
**Redaction is FULLY OPT-IN
|
|
293
|
-
2026-07-26, holistic-redesign decision log
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
`
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
297
|
+
**Redaction is FULLY OPT-IN above the CREDENTIAL FLOOR** (owner ruling
|
|
298
|
+
2026-07-26, holistic-redesign decision log, AMENDED the same day by the
|
|
299
|
+
credential-floor fleet ruling — `docs/decisions/credential-denylist-design.md`;
|
|
300
|
+
`lib/foam/otel/redaction.rb`). With neither `redact_keys` nor
|
|
301
|
+
`redact_pii_keys` set (or both empty) every value is captured RAW —
|
|
302
|
+
attributes, bodies, URL query params, `db.statement`, log bodies — including
|
|
303
|
+
PII, EXCEPT the one always-on exception: the CREDENTIAL FLOOR masks the
|
|
304
|
+
values of the frozen credential/secret header + key NAME list
|
|
305
|
+
(`contract/credential-denylist.json`: the seven headers + 52 keys, constants
|
|
306
|
+
`CREDENTIAL_HEADER_DENYLIST`/`CREDENTIAL_KEY_DENYLIST` in `constants.rb`) to
|
|
307
|
+
the literal `[REDACTED]`, in every signal, on every door, with NO off switch.
|
|
308
|
+
There is still NO value-pattern auto-masking and NO PII preset; above the
|
|
309
|
+
floor the customer owns the privacy/legal posture; the FDE reactive clamp is
|
|
310
|
+
the per-customer valve.
|
|
311
|
+
|
|
312
|
+
**The retired floor vs the credential floor**: earlier revisions shipped an
|
|
313
|
+
always-on researched secrets floor in `constants.rb` (roots + value-pattern
|
|
314
|
+
pass, sourced from the OSS-scrubber consensus — raven-ruby, sentry, Elastic
|
|
315
|
+
APM, Rails filter_parameters, gitleaks et al. — modeled on the Node core). It
|
|
316
|
+
was REMOVED by the 2026-07-26 zero-default ruling; the provenance record
|
|
317
|
+
lives in this file's git history and the decision log. The SAME-DAY
|
|
318
|
+
credential-floor ruling is the new fleet ruling that reintroduces exactly ONE
|
|
319
|
+
always-on list — the frozen credential denylist (name-match only, never
|
|
320
|
+
value patterns; rule-14 reference roots absorbed as entries). Its content
|
|
321
|
+
changes only by a new fleet ruling and is CI-gated bit-for-bit against the
|
|
322
|
+
fleet fixture (`spec/credential_floor_spec.rb`).
|
|
323
|
+
|
|
324
|
+
**The current engine** (`redaction.rb`): the floor predicate
|
|
325
|
+
(`floor_kind`/`floor_match?`) runs FIRST and is TERMINAL — canon-exact
|
|
326
|
+
matching (trim, ASCII-lowercase, `-`→`_` fold; NEVER substring:
|
|
327
|
+
`authorization` never masks `authorization_url`), including the semconv
|
|
328
|
+
`http.{request,response}.header.<name>` prefix peel. Customer redaction then
|
|
329
|
+
runs ONLY over the keys the customer enumerates. `redact_keys` matches → tail
|
|
330
|
+
mask, shape-preserving (`********` + last 4 when ≥ 12 chars; the fixed 8-char
|
|
331
|
+
body never reveals length; non-scalars redact in full); `redact_pii_keys`
|
|
332
|
+
matches → full `[REDACTED]`, no tail. CUSTOMER matching is SUBSTRING,
|
|
333
|
+
case-insensitive, over the lowercased key — the customer's lists, nothing
|
|
334
|
+
inferred (the FLOOR deliberately does NOT ride the substring matcher — it
|
|
335
|
+
would over-match). The pass runs at the exporter boundary
|
|
336
|
+
(`redacting_exporter.rb` — the Ruby SDK freezes span attributes at finish
|
|
337
|
+
before any `on_finish` processor runs, so the exporter boundary is the seam;
|
|
338
|
+
see GOTCHAS) and runs UNCONDITIONALLY (the floor has no off state); values
|
|
339
|
+
under unmatched names ride through raw, and tenant `additional_*` processors
|
|
340
|
+
receive the masked VIEW (`pipelines.rb`, GOTCHAS F1). The explicit
|
|
341
|
+
`redact(value)` helper always masks (calling it is the opt-in). Fail-closed
|
|
342
|
+
for floor + listed keys (mask on any error, cycle + depth guarded).
|
|
343
|
+
|
|
344
|
+
Proven by `spec/credential_floor_spec.rb` + `spec/credential_floor_wire_spec.rb`
|
|
345
|
+
(the floor: list identity gate, matcher, every signal, doors 1+2, tenant
|
|
346
|
+
view, negative controls), `spec/redaction_spec.rb` (raw-above-the-floor
|
|
347
|
+
defaults — nothing else masked with no keys; both mask shapes; fail-closed)
|
|
348
|
+
and `spec/redacting_exporter_spec.rb` + the conformance app (default-path RAW
|
|
349
|
+
capture on the wire, floor names excepted).
|
|
324
350
|
|
|
325
351
|
---
|
|
326
352
|
|