foam-otel 1.3.0 → 1.5.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: 82f1791c9e7d9929a655760c4e2f67295bb70f55dee75a2738bfcae33f5658f6
4
- data.tar.gz: 16917b6e7a4e1dc1f0c6160f7026d4fe90c6213a16fa0664807e459359c5ea92
3
+ metadata.gz: 1e23dd37962a70ea36799a92d914fd2a38aa967a6cd1aface19bbaa322ab8966
4
+ data.tar.gz: f0018c27fb2dfff9316118438688a4a8ab2baea18810dbda522880361dfe6681
5
5
  SHA512:
6
- metadata.gz: 38df8e89604a42d3db4698f5f12934f2bf3e25e288124caf10694a0e698e50f3a732fdd9f1dd2455fb52af90777cba00a3e825f23b967817d6fbda25b567fbdf
7
- data.tar.gz: 5034a35f4d3a7c9d1f18fb0e7adc6691e75aacf0130eb74312ce14ad0ab902e787a25b75af45d6f618cd70a13df12b8323fc172d1cb0abee2950991bb839db6d
6
+ metadata.gz: 9731cb1fae1b54f236dd583102b2348ab4b45bff38745f446c584151e594ea685f2689d6a7fb4807136372006b7b10c0284b2e7c140c0c9625edb5a820294c8a
7
+ data.tar.gz: 91744d9fb0fb671bce62de973eec96152b147742ee5948fedf607626dfd89b8c3c6ed055522f21c4bbfd7d65ce5b558da9e67605578e3573ec15f8b0d4efeecc
data/GOTCHAS.md CHANGED
@@ -582,6 +582,59 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
582
582
  rack end-to-end with real `traceparent`+`baggage` headers, the no-op and
583
583
  never-raise cases.
584
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
+
585
638
  ## General gotchas (applicable to Ruby)
586
639
 
587
640
  - **G1 — init after target import / pre-init no-op**: the API's proxy providers
data/README.md CHANGED
@@ -4,12 +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 above one always-on exception:
8
- by default foam captures every value RAW** (no value-pattern masking, no PII
9
- preset) **except the credential floor** — a fixed list of credential/secret
10
- header and field NAMES that is always masked to `[REDACTED]` (see "The
11
- default credential denylist" below); you enumerate any further fields to mask
12
- 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`.
13
16
 
14
17
  Built to `docs/BASE_PACKAGE_SPEC.md`. This README is the manual you integrate
15
18
  from.
@@ -139,14 +142,16 @@ API):** the allowlist must include BOTH headers —
139
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. |
140
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. |
141
144
  | `version:` | String | no | nil | `service.version`, verbatim (git SHA recommended). Missing → warns and continues. Never detected at runtime. |
142
- | `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 floorempty (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). |
143
- | `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. |
145
+ | `redact:` | Hash | no | nil | The grouped redaction object (1.5.0, the preferred surface): `{ secrets: [...], pii: [...], detect: [...] }`symbol or string keys. `secrets:` has `redact_keys` semantics (tail mask) and `pii:` has `redact_pii_keys` semantics (full `[REDACTED]`); when both a legacy option and its `redact` field are set, the lists UNION. `detect:` is the opt-in PII detection tier's entity list value-shape detection with typed placeholders (see "PII detection (opt-in)" below). An unknown field, a non-Array value, or an unknown `detect` entity name raises at boot. Absent (the default) behavior byte-identical to before. |
146
+ | `redact_keys:` | Array<String> | no | `[]` | Alias the preferred spelling is `redact: { secrets: [...] }` (same semantics; the lists union when both are set). 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). |
147
+ | `redact_pii_keys:` | Array<String> | no | `[]` | Alias — the preferred spelling is `redact: { pii: [...] }` (same semantics; the lists union when both are set). 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. |
144
148
  | `additional_span_processors:` | Array | no | `[]` | Tenant seam: constructed SpanProcessor instances added to foam's pipeline (additive; never replace foam's export). See coexistence. |
145
149
  | `additional_log_record_processors:` | Array | no | `[]` | Tenant seam, logs. |
146
150
  | `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
147
151
  | `additional_instrumentations:` | Array | no | `[]` | Constructed tier-2 instrumentation instances to register (fault-isolated: one that throws is skipped with a `[foam]` warning). |
148
152
  | `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. |
149
153
  | `diagnostics:` | Boolean | no | false | Verbose `[foam]` self-reporting of init/health. Warnings and errors are always loud regardless. |
154
+ | `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). |
150
155
 
151
156
  Examples for the remaining options (each is runnable as-is):
152
157
 
@@ -157,6 +162,32 @@ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: EN
157
162
  redact_pii_keys: %w[customer_email full_name]) # fully [REDACTED]
158
163
  ```
159
164
 
165
+ ```ruby
166
+ # The grouped redact object (1.5.0, preferred — the flat options above are
167
+ # aliases and UNION with it when both are set), plus the opt-in PII
168
+ # detection tier (typed placeholders; see "PII detection (opt-in)"):
169
+ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
170
+ redact: {
171
+ secrets: %w[internal_ref voucher_code], # == redact_keys (tail mask)
172
+ pii: %w[customer_email full_name], # == redact_pii_keys (full [REDACTED])
173
+ detect: %w[email phone ssn credit_card ip], # value-shape detection → [EMAIL], [PHONE], …
174
+ })
175
+ # An unknown field, a non-Array value, or an unknown detect entity raises at boot:
176
+ # Foam::Otel.init(..., redact: { detect: ["name"] })
177
+ # => raises ArgumentError: Foam::Otel.init redact: unknown detect entity "name" — the valid
178
+ # entity names are exactly {email, phone, ssn, credit_card, ip} (contract/pii-detect.json)
179
+ ```
180
+
181
+ ```ruby
182
+ # Only if your legitimate telemetry collides with the generic secret
183
+ # heuristics (e.g. base64 content-addressed ids masked as high-entropy
184
+ # tokens): disable the HEURISTIC tier only. The named provider patterns and
185
+ # the credential floor stay on — they have no off switch. Logs one loud
186
+ # [foam] line.
187
+ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
188
+ secret_heuristics: false)
189
+ ```
190
+
160
191
  ```ruby
161
192
  # Constructed tier-2 instrumentation instances (fault-isolated; one that
162
193
  # throws is skipped with a [foam] warning, never a crashed boot):
@@ -242,10 +273,11 @@ floor name in `redact_keys` does NOT downgrade it to a tail mask — the floor
242
273
  is terminal. `enabled: false` stays fully inert exactly as before (nothing
243
274
  exports at all — the floor masks data that leaves; inert mode has none).
244
275
 
245
- **Everything else stays RAW.** The floor matches NAMES only it never scans
246
- values: bodies, query values, SQL text, LLM content, and PII under unlisted
247
- names export byte-identical raw, exactly as documented everywhere else in
248
- this README.
276
+ **Everything else stays RAW above the floor and the value layer.** The
277
+ floor matches NAMES only, never values. Credential-SHAPED value spans are the
278
+ value-pattern secret layer's job (next section); UNSHAPED values bodies,
279
+ query values, SQL text, LLM content, and PII under unlisted names — export
280
+ byte-identical raw, exactly as documented everywhere else in this README.
249
281
 
250
282
  **Why.** Coverage-over-masking remains the mission, but raw `Authorization`
251
283
  headers, cookies, and API keys on the wire are a breach in waiting for every
@@ -278,6 +310,130 @@ concept in every core — the floor itself has NO init surface anywhere):
278
310
 
279
311
  ---
280
312
 
313
+ ## The value-pattern secret layer (always-on)
314
+
315
+ The credential floor masks by NAME and is deliberately value-shape-blind — a
316
+ live AWS key under the field name `note` would sail past it. Foam telemetry is
317
+ read downstream by LLMs, so a leaked credential is exfiltratable by prompt
318
+ injection; "no leakage at all" is the bar. The value-pattern secret layer is
319
+ the second always-on control (fleet ruling 2026-07-27,
320
+ `docs/decisions/security-fixes-design.md`):
321
+
322
+ **What it masks.** Credential-SHAPED value spans, regardless of the field
323
+ name they hide under: AWS access-key ids and keyword-anchored AWS secrets,
324
+ GCP API keys, Azure client/storage secrets, GitHub tokens and fine-grained
325
+ PATs, GitLab tokens, Slack tokens + webhooks, Stripe secret keys
326
+ (publishable `pk_` keys are deliberately NOT masked), JWTs, PEM private-key
327
+ blocks (`-----BEGIN … PRIVATE KEY` — a truncated block masks to the end of
328
+ the value, fail-closed; `PUBLIC KEY`/`CERTIFICATE` blocks never match) and
329
+ PuTTY PPK keys, `scheme://user:pass@` URI credentials (the whole userinfo,
330
+ username included), `Bearer`/`Basic` auth values, and Anthropic/OpenAI API
331
+ keys. The matched span becomes the literal `[REDACTED]` — no tail, no
332
+ prefix, zero reconstructable bytes; surrounding text keeps its diagnostic
333
+ value (`conn to postgres://[REDACTED]@db:5432 refused`).
334
+
335
+ **The heuristic tier** additionally catches the long tail: a
336
+ keyword-anchored generic pass (`password = hunter2SecretXyz99` inside a log
337
+ body masks its value — gitleaks-derived, entropy-gated, with placeholder/
338
+ stopword/key-context false-positive suppression) and a whole-value
339
+ high-entropy base64 token pass (detect-secrets-derived; pure hex and UUIDs
340
+ are deliberately exempt — trace ids and digests are telemetry's ambient
341
+ vocabulary). Only this tier is disableable: `secret_heuristics: false`
342
+ (one loud `[foam]` line; the named patterns above are not configurable).
343
+
344
+ **Where it runs.** Every value-bearing string on every export path, both
345
+ doors: span/event/link attribute values, `status.message`, exception
346
+ messages, log bodies (string and structured leaves), SQL (`db.statement`),
347
+ LLM content (BEFORE serialization and again at the boundary), URL query AND
348
+ fragment pair values, baggage-derived attributes, exemplar
349
+ `filtered_attributes`, and resource identity strings (once, at init).
350
+ Metric datapoint attributes (metric labels) are the one exempt surface —
351
+ low-cardinality by construction; a value mask there would break your
352
+ aggregation dimensions.
353
+
354
+ **Bounded by design.** Compile-once rules, a literal-anchor pre-filter (no
355
+ anchor in the value → zero regexes run), a 256 KiB value cap (an oversized
356
+ value with an anchor hit anywhere masks WHOLE, fail-closed; without one it
357
+ passes untouched), a match-flood cap, a 256-pair query/fragment tokenizer
358
+ cap, and a per-Regexp timeout on Ruby ≥ 3.2. ANY scanner fault masks the
359
+ value rather than shipping it raw.
360
+
361
+ **Provenance.** The ruleset derives from MIT/Apache-licensed OSS secret
362
+ scanners (gitleaks, detect-secrets, secretlint) — see `THIRD-PARTY-NOTICES`.
363
+ Rules are frozen module constants, byte-consistent across the foam cores,
364
+ changed only by fleet ruling.
365
+
366
+ > **Migration note — 1.4.0 (minor): value-pattern secret masking + redaction
367
+ > coverage fix + TLS pin.** foam now masks credential-SHAPED values
368
+ > (AWS/GCP/Azure keys, GitHub/GitLab tokens, Slack, Stripe, Anthropic/OpenAI
369
+ > keys, JWTs, private-key blocks, `user:pass@` URI credentials, bearer/basic
370
+ > tokens) to `[REDACTED]` in every exported string value — regardless of
371
+ > field name — plus a generic keyword+entropy heuristic you can disable with
372
+ > `secret_heuristics: false` (the named patterns and the credential floor
373
+ > cannot be disabled). Redaction now also reaches URL FRAGMENTS (OAuth
374
+ > implicit-flow tokens, hash-router params), bare `k=v` pair lists, nested
375
+ > attribute containers (fail-closed), and structured LLM content BEFORE
376
+ > serialization. Query/fragment tokenizing is capped at 256 pairs (the
377
+ > overflow remainder masks whole). Metric labels are not value-scanned. TLS
378
+ > peer verification is now PINNED on every exporter:
379
+ > `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE` is INERT and loudly warned about.
380
+ > If a dashboard keyed off a raw token value (it should not have), it will
381
+ > now see `[REDACTED]`.
382
+
383
+ ---
384
+
385
+ ## PII detection (opt-in)
386
+
387
+ The floor and the secret layer protect credentials. PII in VALUES — an email
388
+ address inside a log line, a card number inside an exception message — still
389
+ exports RAW by default, because coverage-over-masking is the mission and only
390
+ you know your privacy posture. As of 1.5.0 you can opt into value-shape PII
391
+ detection per entity, via `redact: { detect: [...] }` (fleet ruling
392
+ 2026-07-28; frozen fixture: `contract/pii-detect.json` — the entity names and
393
+ placeholders are byte-identical in every foam core and gate-checked in CI by
394
+ `spec/pii_detect_spec.rb`). Nothing detects unless you list the entity —
395
+ an empty/absent `detect` list is exactly today's behavior.
396
+
397
+ **The exact entity set** (these five, nothing else — an unknown name raises
398
+ at boot):
399
+
400
+ | Entity | Placeholder | Catches |
401
+ | --- | --- | --- |
402
+ | `email` | `[EMAIL]` | `john.smith+test@example.co.uk` — requires a real TLD (`user@localhost` rides raw) |
403
+ | `phone` | `[PHONE]` | `+1 (415) 555-0142`, `415-555-0199` — separated groups; bare digit runs, dotted versions (`2024.10.05`) and clock times ride raw |
404
+ | `ssn` | `[SSN]` | `536-90-4399`, `536 90 4399` — delimited 3-2-4 only; structurally invalid SSNs (`000-…`, group `00`, serial `0000`) and undelimited runs ride raw |
405
+ | `credit_card` | `[CREDIT_CARD]` | 13–19 digit PANs (spaced/dashed/contiguous) that pass **Luhn** — a card-shaped tracking id failing the checksum is never masked |
406
+ | `ip` | `[IP]` | IPv4 (octet-validated — `999.1.1.1` and `10.4.1.2000` ride raw) and IPv6, `::`-compressed included |
407
+
408
+ **How it masks.** Only the matched character span is replaced with the
409
+ entity's typed placeholder — surrounding text keeps its diagnostic value
410
+ (`login from [IP] flagged`). It runs everywhere the value-pattern secret
411
+ layer runs (span/event/link attributes, `status.message`, log bodies, URL
412
+ query and fragment leaves, nested structures, both doors), immediately AFTER
413
+ the secret layer (a shaped credential still becomes `[REDACTED]` — detect
414
+ never weakens it) and BEFORE your key lists; metric datapoint attributes stay
415
+ exempt exactly like the secret layer. Detection is idempotent (placeholders
416
+ never re-match), rides the same execution caps and fail-closed discipline
417
+ (any detector fault masks the whole value to `[REDACTED]`, loudly), and the
418
+ patterns follow the same bounded-quantifier authoring rules.
419
+
420
+ **What it can NOT do — read this before relying on it.** This is value-SHAPE
421
+ detection, nothing more: **person names and free-text prose are NOT
422
+ detectable** — recognizing "the patient, John Smith, reported…" takes
423
+ server-side NER, which is platform scope, not an SDK regex. Field-NAME-keyed
424
+ PII (`patient_name`) is what `redact: { pii: [...] }` is for; enumerate those
425
+ keys yourself. Foam still ships no PII preset — the detect list is your
426
+ explicit, audited enumeration.
427
+
428
+ ```ruby
429
+ # Door-2 taps take the same redact object, scoped to that tap:
430
+ processor = Foam::Otel.create_ingest_span_processor(
431
+ token: ENV.fetch("FOAM_OTEL_TOKEN"), environment: ENV.fetch("APP_ENV"),
432
+ redact: { pii: %w[customer_email], detect: %w[email credit_card] })
433
+ ```
434
+
435
+ ---
436
+
281
437
  ## The helpers
282
438
 
283
439
  All helpers never raise, and no-op silently before `init` and when disabled.
@@ -520,7 +676,9 @@ readers only: they never mutate their data, their resource, or their export.
520
676
  ### The three entries
521
677
 
522
678
  All three share one signature (`token:` and `environment:` required; the
523
- redaction kwargs are the only CUSTOMER keys that tap redacts — opt-in, none by
679
+ redaction kwargs the grouped `redact:` object and its legacy aliases
680
+ `redact_keys:`/`redact_pii_keys:`, union semantics exactly as on `init` —
681
+ are the only CUSTOMER redaction that tap applies — opt-in, none by
524
682
  default, scoped to that tap, additive above the always-on credential floor,
525
683
  which every tap applies with zero configuration; `diagnostics:` is tap-scoped
526
684
  narration). Construction
@@ -668,6 +826,7 @@ end
668
826
  | `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. |
669
827
  | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | INERT — as above (warns when set). |
670
828
  | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | INERT — as above (warns when set). |
829
+ | `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. |
671
830
  | every other `OTEL_*` | INERT — `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES` never override the init-declared identity; sampler/exporter/span-limit vars are ignored. |
672
831
 
673
832
  ---
data/THIRD-PARTY-NOTICES CHANGED
@@ -20,6 +20,33 @@ filter_parameters (MIT). Mask FORMATS are facts, not copyrightable; no code
20
20
  was copied. None of these libraries is a runtime dependency of foam-otel.
21
21
  Provenance: RESEARCH.md.
22
22
 
23
+ The VALUE-PATTERN SECRET LAYER ruleset (lib/foam/otel/constants.rb —
24
+ SECRET_VALUE_RULES, SECRET_HEURISTIC_H1/H2, SECRET_VALUE_PLACEHOLDERS,
25
+ SECRET_VALUE_STOPWORDS, SECRET_KEY_CONTEXT_ALLOWLIST; fleet ruling 2026-07-27,
26
+ docs/decisions/security-fixes-design.md CONTRACT V) is likewise COPIED
27
+ KNOWLEDGE derived from permissively-licensed open-source secret scanners, not
28
+ vendored code (rule 38). Regex facts about public token formats are not
29
+ copyrightable; the safe posture is followed anyway — every pattern derives
30
+ from the MIT/Apache source text below or from the vendor's own public token
31
+ format. None of these tools is a runtime dependency of foam-otel.
32
+
33
+ gitleaks — MIT — https://github.com/gitleaks/gitleaks (commit b58d3f1)
34
+ (aws/gcp/azure/github/gitlab/slack/stripe/jwt/openai token
35
+ shapes; the generic-api-key H1 template; the placeholder +
36
+ stopword + key-context allowlists)
37
+ detect-secrets — Apache-2.0 — https://github.com/Yelp/detect-secrets (commit 5e14193)
38
+ (the Base64HighEntropyString H2 entropy tier; several
39
+ provider prefixes and the all-numeric entropy penalty)
40
+ secretlint — MIT — https://github.com/secretlint/secretlint (commit 7da613e)
41
+ (bounded-JS-regex authoring discipline; the DB
42
+ connection-string / URI-userinfo shape; the anthropic key
43
+ shape)
44
+
45
+ trufflehog (AGPL-3.0) was consulted ONLY as a provider-coverage CHECKLIST —
46
+ NO pattern is transcribed from its source. Recorded deliberate: the AGPL
47
+ GCP service-account JSON pattern is NOT adopted; that secret is covered by
48
+ the private-key-block linear scan over its embedded PEM instead.
49
+
23
50
  Direct runtime dependencies (name version — license — repository):
24
51
 
25
52
  opentelemetry-api 1.11.0 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby
@@ -11,11 +11,17 @@ module Foam
11
11
  # loop guard and the redacting exporters can read one authoritative value.
12
12
  Config = Struct.new(
13
13
  :name, :environment, :version, :enabled,
14
- :redact_keys, :redact_pii_keys, :ignored_outbound_hosts,
15
- :diagnostics, :endpoint,
14
+ :redact_keys, :redact_pii_keys, :redact_detect, :ignored_outbound_hosts,
15
+ :diagnostics, :endpoint, :secret_heuristics,
16
16
  keyword_init: true
17
17
  )
18
18
 
19
+ # The exact field names the grouped `redact:` option accepts (fleet ruling
20
+ # 2026-07-28, docs/decisions/redact-object-detect-design.md §2): `secrets`
21
+ # unions with the legacy redact_keys, `pii` with redact_pii_keys, `detect`
22
+ # opts into the PII detection tier. Anything else raises at boot.
23
+ REDACT_OPTION_FIELDS = %w[secrets pii detect].freeze
24
+
19
25
  class << self
20
26
  # The inert config the helpers read before init() runs (everything a
21
27
  # no-op needs: empty redaction lists, export disabled).
@@ -23,8 +29,10 @@ module Foam
23
29
  Config.new(
24
30
  name: "", environment: "", version: nil, enabled: false,
25
31
  redact_keys: [].freeze, redact_pii_keys: [].freeze,
32
+ redact_detect: [].freeze,
26
33
  ignored_outbound_hosts: [].freeze,
27
- diagnostics: false, endpoint: FOAM_OTEL_ENDPOINT
34
+ diagnostics: false, endpoint: FOAM_OTEL_ENDPOINT,
35
+ secret_heuristics: true
28
36
  ).freeze
29
37
  end
30
38
 
@@ -36,7 +44,8 @@ module Foam
36
44
 
37
45
  def resolve_config(name:, environment:, version:, enabled:,
38
46
  redact_keys:, redact_pii_keys:, ignored_outbound_hosts:,
39
- diagnostics:, endpoint:)
47
+ diagnostics:, endpoint:, secret_heuristics: true, redact: nil)
48
+ keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
40
49
  Config.new(
41
50
  name: name,
42
51
  environment: environment,
@@ -46,12 +55,23 @@ module Foam
46
55
  # always-on credential floor (which is a module constant, NOT config
47
56
  # state — no config shape can alter it). Empty (the default) → no
48
57
  # customer redaction, raw capture above the floor. Stored lowercased
49
- # for the engine's case-insensitive substring match.
50
- redact_keys: downcase_list(redact_keys),
51
- redact_pii_keys: downcase_list(redact_pii_keys),
58
+ # for the engine's case-insensitive substring match. The grouped
59
+ # `redact:` object (2026-07-28 ruling) UNIONS with the legacy flat
60
+ # lists; redact.detect is the opt-in PII detection tier's entity
61
+ # list — empty (the default) means zero PII detection.
62
+ redact_keys: keys,
63
+ redact_pii_keys: pii,
64
+ redact_detect: detect,
52
65
  ignored_outbound_hosts: Array(ignored_outbound_hosts).map { |h| h.to_s.downcase }.freeze,
53
66
  diagnostics: diagnostics ? true : false,
54
- endpoint: endpoint
67
+ endpoint: endpoint,
68
+ # The value-pattern layer's HEURISTIC tier opt-out (security-fixes-
69
+ # design §V.8): false disables H1/H2 ONLY — the named Tier-1
70
+ # patterns, the credential floor and the coverage contract are
71
+ # module-constant machinery no config shape can narrow. Anything
72
+ # but literal false means ON (default-on preserves the fleet's
73
+ # no-leakage bar; disabling is an explicit, audited decision).
74
+ secret_heuristics: secret_heuristics == false ? false : true
55
75
  ).freeze
56
76
  end
57
77
 
@@ -61,22 +81,88 @@ module Foam
61
81
  Array(list).map { |k| k.to_s.downcase }.reject(&:empty?).uniq.freeze
62
82
  end
63
83
 
64
- # A second init() refreshes ONLY the redaction lists (and diagnostics)
65
- # onto the EXISTING config: enabled/endpoint/identity are process-global
66
- # and immutable after the first init (see init.rb — a second init must
67
- # never flip enabled and leave foam half-dark). Private: not public API
68
- # surface (pinned by export_surface_spec).
69
- def refresh_post_init_config(existing, redact_keys:, redact_pii_keys:, diagnostics:)
84
+ # ---- the grouped `redact:` object (fleet ruling 2026-07-28) -----------
85
+ # Parse + validate the object into its three raw lists. LOUD AT BOOT
86
+ # (rule 10, exactly like the name/environment validation): a non-Hash
87
+ # redact, an unknown field, a non-Array field value, or an unknown
88
+ # detect entity name raises ArgumentError here — never a silent skip,
89
+ # never a dark boot. Symbol AND string keys/entities are accepted
90
+ # (design §2 Ruby row); entities are coerced with the same downcase
91
+ # normalization the legacy lists use, then validated against the frozen
92
+ # fixture entity names (contract/pii-detect.json — the ONLY valid
93
+ # spellings). Called by both doors: init() via resolve_config /
94
+ # refresh_post_init_config, and the ingest factory's step-2 validation
95
+ # (with the entry name as context).
96
+ def parse_redact!(redact, context: "Foam::Otel.init")
97
+ return { secrets: nil, pii: nil, detect: nil } if redact.nil?
98
+
99
+ unless redact.is_a?(Hash)
100
+ raise ArgumentError, "#{context} redact: must be a Hash with fields drawn from " \
101
+ "{#{REDACT_OPTION_FIELDS.join(', ')}} (e.g. redact: { detect: [\"email\"] }), " \
102
+ "got #{redact.class}"
103
+ end
104
+ out = { secrets: nil, pii: nil, detect: nil }
105
+ redact.each do |key, value|
106
+ field = key.to_s
107
+ unless REDACT_OPTION_FIELDS.include?(field)
108
+ raise ArgumentError, "#{context} redact: unknown field #{key.inspect} — the valid fields are " \
109
+ "exactly {#{REDACT_OPTION_FIELDS.join(', ')}}"
110
+ end
111
+ unless value.is_a?(Array)
112
+ raise ArgumentError, "#{context} redact: #{field} must be an Array of strings, got #{value.class}"
113
+ end
114
+ out[field.to_sym] = Array(out[field.to_sym]) + value
115
+ end
116
+ validate_detect_entities!(out[:detect], context)
117
+ out
118
+ end
119
+
120
+ # Fixture rule "unknown_entity": the valid names are exactly the keys of
121
+ # contract/pii-detect.json's `entities` — anything else raises at init.
122
+ def validate_detect_entities!(detect, context)
123
+ return if detect.nil?
124
+
125
+ valid = PII_DETECT_ENTITIES.keys
126
+ detect.each do |entity|
127
+ next if valid.include?(entity.to_s.downcase)
128
+
129
+ raise ArgumentError, "#{context} redact: unknown detect entity #{entity.inspect} — the valid entity " \
130
+ "names are exactly {#{valid.join(', ')}} (contract/pii-detect.json)"
131
+ end
132
+ end
133
+
134
+ # Legacy flat lists ∪ the grouped object's fields (design §2: the legacy
135
+ # aliases live on unchanged; when both are set, the lists UNION), all
136
+ # three normalized exactly as the legacy options always were.
137
+ def merged_redact_lists(redact_keys, redact_pii_keys, redact, context: "Foam::Otel.init")
138
+ parsed = parse_redact!(redact, context: context)
139
+ [downcase_list(Array(redact_keys) + Array(parsed[:secrets])),
140
+ downcase_list(Array(redact_pii_keys) + Array(parsed[:pii])),
141
+ downcase_list(parsed[:detect])]
142
+ end
143
+
144
+ # A second init() refreshes ONLY the redaction lists (and diagnostics,
145
+ # and the heuristic-tier flag — redaction state) onto the EXISTING
146
+ # config: enabled/endpoint/identity are process-global and immutable
147
+ # after the first init (see init.rb — a second init must never flip
148
+ # enabled and leave foam half-dark). The `redact:` object refreshes
149
+ # exactly like the legacy lists (design §2 second-init row). Private:
150
+ # not public API surface (pinned by export_surface_spec).
151
+ def refresh_post_init_config(existing, redact_keys:, redact_pii_keys:, diagnostics:,
152
+ secret_heuristics: true, redact: nil)
153
+ keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
70
154
  Config.new(
71
155
  name: existing.name,
72
156
  environment: existing.environment,
73
157
  version: existing.version,
74
158
  enabled: existing.enabled,
75
- redact_keys: downcase_list(redact_keys),
76
- redact_pii_keys: downcase_list(redact_pii_keys),
159
+ redact_keys: keys,
160
+ redact_pii_keys: pii,
161
+ redact_detect: detect,
77
162
  ignored_outbound_hosts: existing.ignored_outbound_hosts,
78
163
  diagnostics: diagnostics ? true : false,
79
- endpoint: existing.endpoint
164
+ endpoint: existing.endpoint,
165
+ secret_heuristics: secret_heuristics == false ? false : true
80
166
  ).freeze
81
167
  end
82
168
  end