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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e10efb9aaacdda1498a19efaa4af8338afbb98d89880cc6f662aa99205aab59
4
- data.tar.gz: 436060cbcfc7ed33c171ab230a60d4f8b62a882ce46be22bcd81e9c497edbc84
3
+ metadata.gz: 180e8af99c1f5c9bfc35de221c4985fb3ebac7b52ddf6fc99d6552a52aaea836
4
+ data.tar.gz: 14a5d81c673af1a20a75e483e076d2c6b1820fa14e5a587e2a23451da3cccf77
5
5
  SHA512:
6
- metadata.gz: 528f57356698e39e691b08bcc0b6f00bc409ad133afca0ac0bdeeb2d0caffbc052a7a19d7fe63b0285230eb16cd26117ede4bc0824c7c0f31377a4a983ad7458
7
- data.tar.gz: 9e445e3ce3924ff924afd66e98c35b58fe536ae2d546a0f151d3d870f9359974b3bbfd781871fd39fef67d3d509573cab7c2ce573531067eedf3623943967f32
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
- - **Opt-in redaction (holistic redesign, 2026-07-26)**: foam does NO redaction by
244
- default with no `redact_keys`/`redact_pii_keys` every value is captured RAW
245
- (no secret floor, no value-pattern masking). When keys ARE configured, foam
246
- applies the key pass at the EXPORTER boundary a `RedactingSpanExporter`/
247
- `RedactingLogRecordExporter` that rebuilds the mutable `SpanData`/
248
- `LogRecordData` Structs with masked attributes/events/body before
249
- serialization. With no keys configured the exporter is a true pass-through.
250
- - **Divergence (NARROWED)**: when keys are configured foam's HELPERS apply the
251
- key pass AT CAPTURE (`api.rb` `stringify`/`set_attribute`/`log`; `metrics.rb`
252
- always did), so a tenant `additional_*` instance receives foam-helper data
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. The residual divergence is THIRD-PARTY INSTRUMENTATION attributes
255
- only: those freeze at finish before any processor runs, so a tenant span
256
- processor sees them as foam captured them and foam can only apply the key pass
257
- at the exporter boundary. Ruby offers no safe pre-freeze hook the
258
- `on_finishing` hook runs inside the span mutex, so a processor calling
259
- `set_attribute` there would deadlock. Foam's own export applies the configured
260
- key pass, and the exporters FAIL CLOSED once a pass has begun: a struct whose
261
- rebuild raises is DROPPED with a loud warning (`Redaction.mask_*` return nil on
262
- hard failure), never exported half-masked.
263
- - **Mitigation**: capture-time key pass in `lib/foam/otel/api.rb` +
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/tenant_seam_spec.rb` (tenant processors receive helper-set
266
- LISTED keys ALREADY-MASKED, free-text bodies RAW, the byte-identical
267
- with/without-tenant proofs for spans, logs, AND metrics),
268
- `spec/redacting_exporter_spec.rb` (opt-in masking on the wire; "a POISONED
269
- SpanData (dup raises) is DROPPED never exported raw"), `spec/redaction_spec.rb`
270
- (default zero-redaction + the two opt-in key lists; capture+exporter idempotence).
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 the key is in
627
- redact_keys" — pins that unlisted attributes pass through VERBATIM).
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: by default foam captures every
8
- value RAW** (no secret floor, no value-pattern masking); you enumerate the
9
- fields to mask via `redact_keys`/`redact_pii_keys`.
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: by default every value
79
- attributes, URLs/query strings, DB statement text, log bodies, LLM content —
80
- is captured RAW.** Pass `redact_keys`/`redact_pii_keys` to mask or erase
81
- specific fields (see below).
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 NO masking. Matches by case-insensitive substring, including keys nested in structured values and query-string keys. |
138
- | `redact_pii_keys:` | Array<String> | no | `[]` | Field names fully erased to `[REDACTED]` (no tail). Opt-in — empty (the default) means NO erasure. Foam ships no preset; this is your enumeration. |
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
- > Because the Ruby SDK freezes span attributes at finish, a tenant
292
- > processor sees third-party instrumentation attributes UNMASKED (for those,
293
- > foam's key pass runs at the exporter boundary GOTCHAS F1). With no keys
294
- > configured (the default) nothing is masked on either path.
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 and
333
- any per-tap `redact_keys`/`redact_pii_keys` you set (opt-in none by default;
334
- details in the Door 2 section).
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 ONLY keys that tap redacts — opt-in, none by default,
435
- scoped to that tap; `diagnostics:` is tap-scoped narration). Construction
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 tap's
457
- `redact_keys`/`redact_pii_keys` (if any) match over record attributes and keys
458
- nested in structured bodies; free-text bodies ride RAW (no value-pattern pass):
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: none identical to theirs) |
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, applied only for keys
148
- the customer lists in `redact_keys`/`redact_pii_keys`. An FDE can still clamp
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 (opt-in key-masking only),
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 redaction path, and shipping/sweeping both
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 the default is ZERO redaction** (owner ruling
293
- 2026-07-26, holistic-redesign decision log; `lib/foam/otel/redaction.rb`).
294
- With neither `redact_keys` nor `redact_pii_keys` set (or both empty) every
295
- value is captured RAW — attributes, headers, bodies, URL query params,
296
- `db.statement`, log bodies including secrets and PII. There is NO always-on
297
- secrets floor and NO value-pattern auto-masking; the package auto-masks
298
- nothing and ships no preset. The customer owns the privacy/legal posture; the
299
- FDE reactive clamp is the per-customer valve.
300
-
301
- **The retired floor**: earlier revisions shipped an always-on researched
302
- secrets floor in `constants.rb` (roots + value-pattern pass, sourced from the
303
- OSS-scrubber consensus raven-ruby, sentry, Elastic APM, Rails
304
- filter_parameters, gitleaks et al. modeled on the Node core). It was
305
- REMOVED by the 2026-07-26 ruling; the provenance record lives in this file's
306
- git history and the decision log. Do not reintroduce it without a new ruling.
307
-
308
- **The current engine** (`redaction.rb`): redaction runs ONLY over the keys
309
- the customer enumerates. `redact_keys` matches tail mask, shape-preserving
310
- (`********` + last 4 when 12 chars; the fixed 8-char body never reveals
311
- length; non-scalars redact in full); `redact_pii_keys` matches → full
312
- `[REDACTED]`, no tail. Matching is SUBSTRING, case-insensitive, over the
313
- lowercased key the customer's lists, nothing inferred. The pass runs at the
314
- exporter boundary (`redacting_exporter.rb` — the Ruby SDK freezes span
315
- attributes at finish before any `on_finish` processor runs, so the exporter
316
- boundary is the seam; see GOTCHAS) and is a true pass-through when no keys
317
- are configured. The explicit `redact(value)` helper always masks (calling it
318
- is the opt-in). Fail-closed for listed keys (mask on any error, cycle + depth
319
- guarded).
320
-
321
- Proven by `spec/redaction_spec.rb` (opt-in defaults nothing masked with no
322
- keys; both mask shapes; fail-closed) and `spec/redacting_exporter_spec.rb` +
323
- the conformance app (default-path RAW capture on the wire).
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