foam-otel 1.4.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: 180e8af99c1f5c9bfc35de221c4985fb3ebac7b52ddf6fc99d6552a52aaea836
4
- data.tar.gz: 14a5d81c673af1a20a75e483e076d2c6b1820fa14e5a587e2a23451da3cccf77
3
+ metadata.gz: 1e23dd37962a70ea36799a92d914fd2a38aa967a6cd1aface19bbaa322ab8966
4
+ data.tar.gz: f0018c27fb2dfff9316118438688a4a8ab2baea18810dbda522880361dfe6681
5
5
  SHA512:
6
- metadata.gz: a307d175e903abbbbd1c382e65694664f47aba18408fc7153f9a43e9fb72f8131fedcfc71a59e16ee4f7446dee0f3460233883f96a7048e1284fff75b54e16c7
7
- data.tar.gz: 7322e0a91e04092d494150ad9b003e4a6fd61b23ebc7468a8c715eec2d5ab8aac64d1edc4c9fb3cb1a844c3d3a56c167b9bbf05bed2bb1d4cb491eba20695f5d
6
+ metadata.gz: 9731cb1fae1b54f236dd583102b2348ab4b45bff38745f446c584151e594ea685f2689d6a7fb4807136372006b7b10c0284b2e7c140c0c9625edb5a820294c8a
7
+ data.tar.gz: 91744d9fb0fb671bce62de973eec96152b147742ee5948fedf607626dfd89b8c3c6ed055522f21c4bbfd7d65ce5b558da9e67605578e3573ec15f8b0d4efeecc
data/README.md CHANGED
@@ -142,8 +142,9 @@ API):** the allowlist must include BOTH headers —
142
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. |
143
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. |
144
144
  | `version:` | String | no | nil | `service.version`, verbatim (git SHA recommended). Missing → warns and continues. Never detected at runtime. |
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 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). |
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. |
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. |
147
148
  | `additional_span_processors:` | Array | no | `[]` | Tenant seam: constructed SpanProcessor instances added to foam's pipeline (additive; never replace foam's export). See coexistence. |
148
149
  | `additional_log_record_processors:` | Array | no | `[]` | Tenant seam, logs. |
149
150
  | `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
@@ -161,6 +162,22 @@ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: EN
161
162
  redact_pii_keys: %w[customer_email full_name]) # fully [REDACTED]
162
163
  ```
163
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
+
164
181
  ```ruby
165
182
  # Only if your legitimate telemetry collides with the generic secret
166
183
  # heuristics (e.g. base64 content-addressed ids masked as high-entropy
@@ -365,6 +382,58 @@ changed only by fleet ruling.
365
382
 
366
383
  ---
367
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
+
368
437
  ## The helpers
369
438
 
370
439
  All helpers never raise, and no-op silently before `init` and when disabled.
@@ -607,7 +676,9 @@ readers only: they never mutate their data, their resource, or their export.
607
676
  ### The three entries
608
677
 
609
678
  All three share one signature (`token:` and `environment:` required; the
610
- 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
611
682
  default, scoped to that tap, additive above the always-on credential floor,
612
683
  which every tap applies with zero configuration; `diagnostics:` is tap-scoped
613
684
  narration). Construction
@@ -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,
14
+ :redact_keys, :redact_pii_keys, :redact_detect, :ignored_outbound_hosts,
15
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,6 +29,7 @@ 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
34
  diagnostics: false, endpoint: FOAM_OTEL_ENDPOINT,
28
35
  secret_heuristics: true
@@ -37,7 +44,8 @@ module Foam
37
44
 
38
45
  def resolve_config(name:, environment:, version:, enabled:,
39
46
  redact_keys:, redact_pii_keys:, ignored_outbound_hosts:,
40
- diagnostics:, endpoint:, secret_heuristics: true)
47
+ diagnostics:, endpoint:, secret_heuristics: true, redact: nil)
48
+ keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
41
49
  Config.new(
42
50
  name: name,
43
51
  environment: environment,
@@ -47,9 +55,13 @@ module Foam
47
55
  # always-on credential floor (which is a module constant, NOT config
48
56
  # state — no config shape can alter it). Empty (the default) → no
49
57
  # customer redaction, raw capture above the floor. Stored lowercased
50
- # for the engine's case-insensitive substring match.
51
- redact_keys: downcase_list(redact_keys),
52
- 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,
53
65
  ignored_outbound_hosts: Array(ignored_outbound_hosts).map { |h| h.to_s.downcase }.freeze,
54
66
  diagnostics: diagnostics ? true : false,
55
67
  endpoint: endpoint,
@@ -69,21 +81,84 @@ module Foam
69
81
  Array(list).map { |k| k.to_s.downcase }.reject(&:empty?).uniq.freeze
70
82
  end
71
83
 
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
+
72
144
  # A second init() refreshes ONLY the redaction lists (and diagnostics,
73
145
  # and the heuristic-tier flag — redaction state) onto the EXISTING
74
146
  # config: enabled/endpoint/identity are process-global and immutable
75
147
  # after the first init (see init.rb — a second init must never flip
76
- # enabled and leave foam half-dark). Private: not public API surface
77
- # (pinned by export_surface_spec).
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).
78
151
  def refresh_post_init_config(existing, redact_keys:, redact_pii_keys:, diagnostics:,
79
- secret_heuristics: true)
152
+ secret_heuristics: true, redact: nil)
153
+ keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
80
154
  Config.new(
81
155
  name: existing.name,
82
156
  environment: existing.environment,
83
157
  version: existing.version,
84
158
  enabled: existing.enabled,
85
- redact_keys: downcase_list(redact_keys),
86
- redact_pii_keys: downcase_list(redact_pii_keys),
159
+ redact_keys: keys,
160
+ redact_pii_keys: pii,
161
+ redact_detect: detect,
87
162
  ignored_outbound_hosts: existing.ignored_outbound_hosts,
88
163
  diagnostics: diagnostics ? true : false,
89
164
  endpoint: existing.endpoint,
@@ -342,5 +342,74 @@ module Foam
342
342
  yourkey your_api_key your-api-key xxxxxxxx todo
343
343
  notreal loremipsum deadbeef 0000000000 1111111111
344
344
  ].freeze
345
+
346
+ # ---- THE PII DETECT TIER (fleet ruling 2026-07-28, FULLY OPT-IN) --------
347
+ # docs/decisions/redact-object-detect-design.md §3; frozen fixture:
348
+ # contract/pii-detect.json (entity names + placeholders byte-identical,
349
+ # gate-checked by spec/pii_detect_spec.rb; behavior proven against the
350
+ # fixture's positive/negative vectors). Nothing here runs unless the
351
+ # customer lists the entity in init's redact.detect (or a door-2 tap's
352
+ # redact:) — the empty/absent list is today's raw-by-default behavior,
353
+ # byte-identical. The engine (redaction.rb detect_pii) runs this tier
354
+ # immediately AFTER the value-pattern secret layer and BEFORE customer
355
+ # key masking, masking ONLY the matched character span with the entity's
356
+ # typed :placeholder (surrounding text preserved verbatim; emitted
357
+ # placeholders never re-match — fixture "placeholder_idempotence").
358
+ #
359
+ # Authoring rules mirror the secret layer's (design §V.5.2 / rule 49):
360
+ # compile-once at require (a bad pattern fails the require, loudly),
361
+ # bounded quantifiers only, no lookbehind, no atomic/possessive groups;
362
+ # :group names the span to mask (0 = the whole match; phone masks group 1
363
+ # behind a consumed non-digit boundary — never a lookbehind). Patterns
364
+ # are byte-consistent with the python core's PII_DETECT_ENTITIES; the one
365
+ # recorded portability translation is phone's `^` → `\A` (Ruby's ^ is a
366
+ # LINE anchor — same translation note as SECRET_HEURISTIC_H2). :filter
367
+ # (:luhn) names a mandatory post-match validator — a card-shaped digit
368
+ # string failing Luhn is NEVER masked (fixture rule "checksum"). NOT
369
+ # detectable by design: person names and free-text prose (server-side
370
+ # NER scope — the README states this plainly).
371
+ PII_DETECT_ENTITIES = {
372
+ "email" => {
373
+ placeholder: "[EMAIL]", group: 0,
374
+ regex: '\b[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9\-]{1,63}' \
375
+ '(?:\.[A-Za-z0-9\-]{1,63}){0,10}\.[A-Za-z]{2,24}\b',
376
+ }.freeze,
377
+ # Group 1 is the number; the leading alternation is a consumed boundary
378
+ # guard (no lookbehind — rule-49 discipline). Separators are space/dash
379
+ # only, so dotted versions (2024.10.05) and clock times (12:30:45)
380
+ # never trip.
381
+ "phone" => {
382
+ placeholder: "[PHONE]", group: 1,
383
+ regex: '(?:\A|[^0-9A-Za-z])((?:\+[0-9]{1,3}[ \-]?)?' \
384
+ '(?:\([0-9]{1,4}\)[ \-]?|[0-9]{1,4}[ \-])' \
385
+ '[0-9]{3,4}[ \-][0-9]{3,4})(?![0-9])',
386
+ }.freeze,
387
+ # Delimited 3-2-4 with a CONSISTENT delimiter (the \1 backreference —
388
+ # a one-char group, linear by construction) — an undelimited 9-digit
389
+ # run never matches. Area 000/666/9xx, group 00, and serial 0000 are
390
+ # structurally invalid SSNs and ride raw.
391
+ "ssn" => {
392
+ placeholder: "[SSN]", group: 0,
393
+ regex: '\b(?!000|666|9[0-9]{2})[0-9]{3}([ \-])' \
394
+ '(?!00)[0-9]{2}\1(?!0000)[0-9]{4}\b',
395
+ }.freeze,
396
+ # 13-19 digits in 4-4-4-rest grouping (space/dash/contiguous). The
397
+ # regex is deliberately loose on grouping; the LUHN filter is the
398
+ # normative gate (fixture "checksum" rule).
399
+ "credit_card" => {
400
+ placeholder: "[CREDIT_CARD]", group: 0, filter: :luhn,
401
+ regex: '\b[0-9]{4}(?:[ \-]?[0-9]{4}){2}[ \-]?[0-9]{1,7}\b',
402
+ }.freeze,
403
+ # IPv4 with per-octet range validation (999.1.1.1 and 4-digit octets
404
+ # ride raw), full 8-group IPv6, and ::-compressed IPv6.
405
+ "ip" => {
406
+ placeholder: "[IP]", group: 0,
407
+ regex: '\b(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}' \
408
+ '(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\b' \
409
+ '|\b(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\b' \
410
+ '|\b(?:[0-9A-Fa-f]{1,4}:){1,6}:' \
411
+ '(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5})?(?![0-9A-Fa-f:])',
412
+ }.freeze,
413
+ }.freeze
345
414
  end
346
415
  end
@@ -38,31 +38,38 @@ module Foam
38
38
  # Door-2 traces entry: a SpanProcessor-duck instance for THEIR
39
39
  # TracerProvider (their setup block / add_span_processor). Foam's copy
40
40
  # is stamped + masked at export; their spans are never touched.
41
+ # `redact:` is the grouped redaction object (fleet ruling 2026-07-28,
42
+ # same shape and union semantics as init's — secrets ∪ redact_keys,
43
+ # pii ∪ redact_pii_keys, detect = the opt-in PII detection tier),
44
+ # scoped to this tap like the legacy kwargs.
41
45
  def create_ingest_span_processor(token:, environment:, redact_keys: nil,
42
- redact_pii_keys: nil, diagnostics: false,
46
+ redact_pii_keys: nil, redact: nil, diagnostics: false,
43
47
  secret_heuristics: true)
44
48
  Ingest.build(:traces, token: token, environment: environment,
45
49
  redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
46
- diagnostics: diagnostics, secret_heuristics: secret_heuristics)
50
+ redact: redact, diagnostics: diagnostics,
51
+ secret_heuristics: secret_heuristics)
47
52
  end
48
53
 
49
54
  # Door-2 logs entry: a LogRecordProcessor duck for THEIR LoggerProvider.
50
55
  def create_ingest_log_record_processor(token:, environment:, redact_keys: nil,
51
- redact_pii_keys: nil, diagnostics: false,
56
+ redact_pii_keys: nil, redact: nil, diagnostics: false,
52
57
  secret_heuristics: true)
53
58
  Ingest.build(:logs, token: token, environment: environment,
54
59
  redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
55
- diagnostics: diagnostics, secret_heuristics: secret_heuristics)
60
+ redact: redact, diagnostics: diagnostics,
61
+ secret_heuristics: secret_heuristics)
56
62
  end
57
63
 
58
64
  # Door-2 metrics entry: a PeriodicMetricReader subclass for THEIR
59
65
  # MeterProvider#add_metric_reader (the back-filling attach point).
60
66
  def create_ingest_metric_reader(token:, environment:, redact_keys: nil,
61
- redact_pii_keys: nil, diagnostics: false,
67
+ redact_pii_keys: nil, redact: nil, diagnostics: false,
62
68
  secret_heuristics: true)
63
69
  Ingest.build(:metrics, token: token, environment: environment,
64
70
  redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
65
- diagnostics: diagnostics, secret_heuristics: secret_heuristics)
71
+ redact: redact, diagnostics: diagnostics,
72
+ secret_heuristics: secret_heuristics)
66
73
  end
67
74
  end
68
75
 
@@ -112,7 +119,7 @@ module Foam
112
119
 
113
120
  class << self
114
121
  def build(signal, token:, environment:, redact_keys:, redact_pii_keys:, diagnostics:,
115
- secret_heuristics: true)
122
+ secret_heuristics: true, redact: nil)
116
123
  # (1) the operator kill switch — read once at construction, exactly
117
124
  # like init (rule 19 / fleet audit R13). Trimmed + case-insensitive
118
125
  # so the fleet's one emergency switch behaves the same across
@@ -130,6 +137,12 @@ module Foam
130
137
  entry = ENTRY_NAMES.fetch(signal)
131
138
  validate_present!(entry, :token, token)
132
139
  validate_present!(entry, :environment, environment)
140
+ # The grouped `redact:` object validates HERE, in the loud step-2
141
+ # block (fleet ruling 2026-07-28, same rule-10 posture as init): an
142
+ # unknown field, a non-Array value, or an unknown detect entity
143
+ # raises ArgumentError at construction — never a degraded-to-inert
144
+ # boot the rescue below would swallow.
145
+ Foam::Otel.send(:parse_redact!, redact, context: "[foam] #{entry}")
133
146
  Foam::Otel.send(:warn_environment, environment)
134
147
 
135
148
  # (3) build. After validation NOTHING ever throws (rule 9): a broken
@@ -146,7 +159,7 @@ module Foam
146
159
  Foam::Otel.send(:warn_inert_per_signal_endpoints, endpoint)
147
160
  config = Foam::Otel.resolve_config(
148
161
  name: "", environment: environment.to_s, version: nil, enabled: true,
149
- redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
162
+ redact_keys: redact_keys, redact_pii_keys: redact_pii_keys, redact: redact,
150
163
  ignored_outbound_hosts: [], diagnostics: diagnostics ? true : false,
151
164
  endpoint: endpoint, secret_heuristics: secret_heuristics
152
165
  )
@@ -49,7 +49,7 @@ module Foam
49
49
  class << self
50
50
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
51
51
  def init(name:, environment:, enabled:, token: nil, version: nil,
52
- redact_keys: nil, redact_pii_keys: nil,
52
+ redact_keys: nil, redact_pii_keys: nil, redact: nil,
53
53
  additional_instrumentations: nil,
54
54
  additional_span_processors: nil,
55
55
  additional_log_record_processors: nil,
@@ -78,10 +78,13 @@ module Foam
78
78
 
79
79
  endpoint = resolve_endpoint
80
80
  warn_inert_per_signal_endpoints(endpoint)
81
+ # The grouped `redact:` object (fleet ruling 2026-07-28) resolves here
82
+ # too — an unknown field, a non-Array value, or an unknown detect
83
+ # entity raises INSIDE resolve_config, loudly, at boot (rule 10).
81
84
  config = resolve_config(
82
85
  name: name, environment: environment, version: version,
83
86
  enabled: effective_enabled,
84
- redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
87
+ redact_keys: redact_keys, redact_pii_keys: redact_pii_keys, redact: redact,
85
88
  ignored_outbound_hosts: ignored_outbound_hosts,
86
89
  diagnostics: diagnostics, endpoint: endpoint,
87
90
  secret_heuristics: secret_heuristics
@@ -105,7 +108,7 @@ module Foam
105
108
  if @initialized
106
109
  self.active_config = refresh_post_init_config(
107
110
  active_config, redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
108
- diagnostics: diagnostics, secret_heuristics: secret_heuristics
111
+ redact: redact, diagnostics: diagnostics, secret_heuristics: secret_heuristics
109
112
  )
110
113
  Diagnostics.warn("init already called — returning the existing instance (providers are process-global; " \
111
114
  "enabled and endpoint cannot change after the first init — only the redaction lists refresh)")
@@ -458,6 +458,127 @@ module Foam
458
458
  true
459
459
  end
460
460
 
461
+ # ---- THE OPT-IN PII DETECT TIER (fleet ruling 2026-07-28) --------------
462
+ # docs/decisions/redact-object-detect-design.md §3; fixture:
463
+ # contract/pii-detect.json. The THIRD value control at this seam, and the
464
+ # first that is FULLY OPT-IN: an entity from the frozen table
465
+ # (constants.rb PII_DETECT_ENTITIES) detects ONLY when its name is in the
466
+ # config's redact_detect list. It composes immediately AFTER the secret
467
+ # value layer and BEFORE customer key masking, masks ONLY the matched
468
+ # character span with the entity's TYPED placeholder ([EMAIL], [PHONE],
469
+ # [SSN], [CREDIT_CARD], [IP] — surrounding text preserved verbatim), and
470
+ # runs everywhere the secret layer runs — same exemption (metric
471
+ # datapoint attributes ride the value_scan: false path) and the same
472
+ # execution discipline: compile-once (per-Regexp timeout on Ruby >= 3.2),
473
+ # over-cap values are NEVER regex-scanned (the secret layer's anchor scan
474
+ # already made the oversize mask-whole/pass decision — detect adds
475
+ # nothing), a match flood masks the whole value, and ANY throw inside the
476
+ # detector masks the WHOLE value to [REDACTED] with a loud [foam]
477
+ # warning — never the raw value, never a crash (fixture "fail_closed").
478
+
479
+ # True only when the customer opted into at least one detect entity —
480
+ # the zero-cost gate for the byte-identical default path.
481
+ def detect_enabled?(config)
482
+ return false if config.nil?
483
+ return false unless config.respond_to?(:redact_detect)
484
+
485
+ list = config.redact_detect
486
+ !list.nil? && !list.empty?
487
+ rescue StandardError
488
+ false
489
+ end
490
+
491
+ # THE detect-tier entrypoint. Same guard shape as scan_value_secrets:
492
+ # scan a scrubbed COPY of an invalid-encoding value; when nothing
493
+ # matches, the ORIGINAL object rides through (raw capture stands,
494
+ # identity-preserving).
495
+ def detect_pii(text, config)
496
+ return text unless text.is_a?(String) && !text.empty?
497
+
498
+ entities = config.redact_detect
499
+ work = text.valid_encoding? ? text : scrub_utf8(text)
500
+ work = text unless work.is_a?(String)
501
+ result = detect_pii_unguarded(work, entities)
502
+ result.equal?(work) ? text : result
503
+ rescue StandardError, SystemStackError
504
+ # Fail closed (fixture "fail_closed"): a detector fault ships the
505
+ # mask, NEVER the raw value.
506
+ warn_secret_scan_once(:pii_fault, "PII detection failed for a value — masked whole (fail-closed)")
507
+ REDACTED
508
+ end
509
+
510
+ def detect_pii_unguarded(text, entities)
511
+ # Over-cap follows the secret layer's over-cap rule (fixture "caps"):
512
+ # NEVER regex-scanned. The secret layer's anchor scan already ran and
513
+ # decided mask-whole vs pass for oversized values; detect adds nothing.
514
+ return text if text.length > SECRET_VALUE_SCAN_CAP
515
+
516
+ spans = []
517
+ PII_DETECT_COMPILED.each do |name, entity|
518
+ next unless entities.include?(name)
519
+
520
+ group = entity[:group]
521
+ text.scan(entity[:re]) do
522
+ m = Regexp.last_match
523
+ cand = m[group]
524
+ next if cand.nil? || !detect_filter_ok?(entity[:filter], cand)
525
+
526
+ spans << [m.begin(group), m.end(group), entity[:placeholder]]
527
+ if spans.length > SECRET_MAX_MATCHES
528
+ # match flood ⇒ whole value masked (fail-closed, secret-layer parity)
529
+ warn_secret_scan_once(:pii_flood,
530
+ "PII detection exceeded the match-flood cap — masked whole (fail-closed)")
531
+ return REDACTED
532
+ end
533
+ end
534
+ end
535
+ return text if spans.empty?
536
+
537
+ apply_detect_spans(text, spans)
538
+ end
539
+
540
+ def detect_filter_ok?(filter, cand)
541
+ return true unless filter == :luhn
542
+
543
+ digits = cand.delete("^0-9")
544
+ digits.length.between?(13, 19) && luhn_valid?(digits)
545
+ end
546
+
547
+ # The Luhn checksum over a plain digit string. credit_card is masked
548
+ # ONLY when this passes (fixture "checksum" rule) — a card-shaped number
549
+ # failing Luhn (a tracking id, a serial) is never masked.
550
+ def luhn_valid?(digits)
551
+ sum = 0
552
+ digits.reverse.each_char.with_index do |ch, i|
553
+ d = ch.ord - 48
554
+ if i.odd?
555
+ d *= 2
556
+ d -= 9 if d > 9
557
+ end
558
+ sum += d
559
+ end
560
+ (sum % 10).zero?
561
+ end
562
+
563
+ # Replace each detected span with ITS entity's typed placeholder, left
564
+ # to right; overlaps resolved by earliest-start-longest-match (the
565
+ # secret layer's span-merge rule, fixture "span_masking"). Emitted
566
+ # placeholders are never rescanned — running detection twice is
567
+ # byte-identical (fixture "placeholder_idempotence").
568
+ def apply_detect_spans(text, spans)
569
+ spans.sort_by! { |s, e, _| [s, -e] }
570
+ out = +""
571
+ prev = 0
572
+ spans.each do |s, e, placeholder|
573
+ next if s < prev # overlapped by an earlier-start/longer match — dropped
574
+
575
+ out << text[prev...s] << placeholder
576
+ prev = e
577
+ end
578
+ out << text[prev..]
579
+ out
580
+ end
581
+
461
582
  # Rate-limited (once per process per class) scanner warnings — loud
462
583
  # (rule 15) but never a log flood an adversary controls.
463
584
  def warn_secret_scan_once(key, message)
@@ -525,14 +646,16 @@ module Foam
525
646
  end
526
647
 
527
648
  # A redact_keys match: the value-pattern scan runs FIRST (design §V.0 —
528
- # a shaped secret must never survive as a 4-char tail reveal); a V hit
529
- # is terminal for its span. Otherwise: tail-mask a scalar leaf, redact
530
- # anything else in full.
649
+ # a shaped secret must never survive as a 4-char tail reveal), then the
650
+ # opt-in detect tier (2026-07-28 ruling: detect sits BETWEEN the secret
651
+ # layer and customer key masking); a V or detect hit is terminal for its
652
+ # span. Otherwise: tail-mask a scalar leaf, redact anything else in full.
531
653
  def masked_value_for(value, config, value_scan)
532
654
  return value.map { REDACTED } if value.is_a?(Array)
533
655
 
534
656
  if value_scan && value.is_a?(String)
535
657
  scanned = scan_value_secrets(value, heuristics_enabled?(config))
658
+ scanned = detect_pii(scanned, config) if detect_enabled?(config)
536
659
  return scanned unless scanned == value
537
660
  end
538
661
  scalar_or_full(value)
@@ -566,12 +689,16 @@ module Foam
566
689
  # query AND fragment, per-pair value scan), (2) the whole-string
567
690
  # value-pattern scan for shaped secrets outside k=v pairs (a bearer
568
691
  # token in an exception message, a PEM block, a bare JWT). [REDACTED]
569
- # emitted by (1) never re-matches in (2).
692
+ # emitted by (1) never re-matches in (2). The OPT-IN detect tier (fleet
693
+ # ruling 2026-07-28) composes THIRD, after the secret layer — its typed
694
+ # placeholders never weaken (1)/(2), and with no opted-in entities it is
695
+ # a zero-cost no-op (the byte-identical default path).
570
696
  def redact_leaf_string(text, config)
571
697
  out = scan_value_secrets(redact_url_like(text, config), heuristics_enabled?(config))
572
- # Raw capture stands byte-identical: when neither pass changed
573
- # anything, the ORIGINAL object rides through (content- and
574
- # identity-preserving, pinned by the floor negative controls).
698
+ out = detect_pii(out, config) if detect_enabled?(config)
699
+ # Raw capture stands byte-identical: when no pass changed anything,
700
+ # the ORIGINAL object rides through (content- and identity-preserving,
701
+ # pinned by the floor negative controls).
575
702
  out == text ? text : out
576
703
  end
577
704
 
@@ -928,6 +1055,14 @@ module Foam
928
1055
  SECRET_H2_HEX_RE = build_secret_regexp(SECRET_HEURISTIC_H2[:hex_regex], false)
929
1056
  SECRET_PLACEHOLDER_RES = SECRET_VALUE_PLACEHOLDERS.map { |p| build_secret_regexp(p, true) }.freeze
930
1057
 
1058
+ # The detect tier's compiled view of the frozen fixture table (fleet
1059
+ # ruling 2026-07-28) — same compile-once discipline (and the same
1060
+ # per-Regexp timeout belt) as the secret rules above: a pattern that
1061
+ # fails to compile fails the require LOUDLY, never a silent skip.
1062
+ PII_DETECT_COMPILED = PII_DETECT_ENTITIES.transform_values do |entity|
1063
+ entity.merge(re: build_secret_regexp(entity[:regex], false)).freeze
1064
+ end.freeze
1065
+
931
1066
  # Every lowercase literal anchor across Tier 1 + the linear scans + H1 —
932
1067
  # the single pre-filter deciding whether ANY regex runs (and the whole
933
1068
  # decision for oversized values).
@@ -46,6 +46,21 @@ module Foam
46
46
  # exporter — OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE is now INERT and
47
47
  # loudly warned about (F-RB1, CWE-295). If a dashboard keyed off a raw
48
48
  # token value (it should not have), it will now see [REDACTED].
49
- VERSION = "1.4.0"
49
+ # 1.5.0: `redact:` OBJECT + OPT-IN PII DETECT TIER (fleet ruling
50
+ # 2026-07-28, docs/decisions/redact-object-detect-design.md; MINOR —
51
+ # additive, opt-in, zero default-path behavior change). init() and all
52
+ # three door-2 ingest entries gain ONE additive option, redact: — a Hash
53
+ # grouping the two key lists under intent-named fields (secrets: ==
54
+ # redact_keys, pii: == redact_pii_keys; the legacy kwargs live on
55
+ # unchanged and UNION when both are set) plus a new opt-in detect: list
56
+ # of PII entity names (contract/pii-detect.json: email, phone, ssn,
57
+ # credit_card, ip) whose value shapes are masked in every exported
58
+ # string with typed placeholders ([EMAIL], [PHONE], [SSN],
59
+ # [CREDIT_CARD], [IP]) — span-only, Luhn-gated credit_card, idempotent
60
+ # over placeholders, same caps/fail-closed discipline and the same
61
+ # metric-label exemption as the secret layer. Unknown redact fields,
62
+ # non-Array values, and unknown detect entities raise at init (loud at
63
+ # boot). With redact: absent, behavior is byte-identical to 1.4.0.
64
+ VERSION = "1.5.0"
50
65
  end
51
66
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foam-otel
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foam