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 +4 -4
- data/GOTCHAS.md +53 -0
- data/README.md +172 -13
- data/THIRD-PARTY-NOTICES +27 -0
- data/lib/foam/otel/config.rb +103 -17
- data/lib/foam/otel/constants.rb +279 -0
- data/lib/foam/otel/ingest.rb +43 -13
- data/lib/foam/otel/init.rb +39 -5
- data/lib/foam/otel/llm.rb +36 -8
- data/lib/foam/otel/metrics.rb +7 -1
- data/lib/foam/otel/pipelines.rb +16 -3
- data/lib/foam/otel/redaction.rb +699 -85
- data/lib/foam/otel/resource.rb +12 -3
- data/lib/foam/otel/version.rb +32 -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: 1e23dd37962a70ea36799a92d914fd2a38aa967a6cd1aface19bbaa322ab8966
|
|
4
|
+
data.tar.gz: f0018c27fb2dfff9316118438688a4a8ab2baea18810dbda522880361dfe6681
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
8
|
-
by default foam captures every value RAW** (no
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
| `
|
|
143
|
-
| `
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
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
|
data/lib/foam/otel/config.rb
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
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
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
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:
|
|
76
|
-
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
|