foam-otel 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82f1791c9e7d9929a655760c4e2f67295bb70f55dee75a2738bfcae33f5658f6
4
- data.tar.gz: 16917b6e7a4e1dc1f0c6160f7026d4fe90c6213a16fa0664807e459359c5ea92
3
+ metadata.gz: 180e8af99c1f5c9bfc35de221c4985fb3ebac7b52ddf6fc99d6552a52aaea836
4
+ data.tar.gz: 14a5d81c673af1a20a75e483e076d2c6b1820fa14e5a587e2a23451da3cccf77
5
5
  SHA512:
6
- metadata.gz: 38df8e89604a42d3db4698f5f12934f2bf3e25e288124caf10694a0e698e50f3a732fdd9f1dd2455fb52af90777cba00a3e825f23b967817d6fbda25b567fbdf
7
- data.tar.gz: 5034a35f4d3a7c9d1f18fb0e7adc6691e75aacf0130eb74312ce14ad0ab902e787a25b75af45d6f618cd70a13df12b8323fc172d1cb0abee2950991bb839db6d
6
+ metadata.gz: a307d175e903abbbbd1c382e65694664f47aba18408fc7153f9a43e9fb72f8131fedcfc71a59e16ee4f7446dee0f3460233883f96a7048e1284fff75b54e16c7
7
+ data.tar.gz: 7322e0a91e04092d494150ad9b003e4a6fd61b23ebc7468a8c715eec2d5ab8aac64d1edc4c9fb3cb1a844c3d3a56c167b9bbf05bed2bb1d4cb491eba20695f5d
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.
@@ -147,6 +150,7 @@ API):** the allowlist must include BOTH headers —
147
150
  | `additional_instrumentations:` | Array | no | `[]` | Constructed tier-2 instrumentation instances to register (fault-isolated: one that throws is skipped with a `[foam]` warning). |
148
151
  | `ignored_outbound_hosts:` | Array<String> | no | `[]` | Hosts whose outbound calls produce no spans — EXTENDS the built-in export-loop guard. For a co-resident agent's intake or a tenant exporter's endpoint. Applies to the clients whose official instrumentation supports host suppression: **Net::HTTP and Excon**. Faraday/HTTP (httprb)/HTTPX have no upstream `untraced_hosts` option — foam warns loudly at init when one of those is bundled (GOTCHAS G13); foam's own export loop is guarded for every client regardless. |
149
152
  | `diagnostics:` | Boolean | no | false | Verbose `[foam]` self-reporting of init/health. Warnings and errors are always loud regardless. |
153
+ | `secret_heuristics:` | Boolean | no | true | The value-pattern secret layer's HEURISTIC tier (generic keyword+entropy detection, see "The value-pattern secret layer"). `false` disables ONLY the heuristics — the named provider patterns, the credential floor and the redaction-coverage contract have no off switch. Disabling logs one loud `[foam]` line at init (an explicit, audited decision for telemetry whose legitimate values collide with the heuristics). |
150
154
 
151
155
  Examples for the remaining options (each is runnable as-is):
152
156
 
@@ -157,6 +161,16 @@ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: EN
157
161
  redact_pii_keys: %w[customer_email full_name]) # fully [REDACTED]
158
162
  ```
159
163
 
164
+ ```ruby
165
+ # Only if your legitimate telemetry collides with the generic secret
166
+ # heuristics (e.g. base64 content-addressed ids masked as high-entropy
167
+ # tokens): disable the HEURISTIC tier only. The named provider patterns and
168
+ # the credential floor stay on — they have no off switch. Logs one loud
169
+ # [foam] line.
170
+ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
171
+ secret_heuristics: false)
172
+ ```
173
+
160
174
  ```ruby
161
175
  # Constructed tier-2 instrumentation instances (fault-isolated; one that
162
176
  # throws is skipped with a [foam] warning, never a crashed boot):
@@ -242,10 +256,11 @@ floor name in `redact_keys` does NOT downgrade it to a tail mask — the floor
242
256
  is terminal. `enabled: false` stays fully inert exactly as before (nothing
243
257
  exports at all — the floor masks data that leaves; inert mode has none).
244
258
 
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.
259
+ **Everything else stays RAW above the floor and the value layer.** The
260
+ floor matches NAMES only, never values. Credential-SHAPED value spans are the
261
+ value-pattern secret layer's job (next section); UNSHAPED values bodies,
262
+ query values, SQL text, LLM content, and PII under unlisted names — export
263
+ byte-identical raw, exactly as documented everywhere else in this README.
249
264
 
250
265
  **Why.** Coverage-over-masking remains the mission, but raw `Authorization`
251
266
  headers, cookies, and API keys on the wire are a breach in waiting for every
@@ -278,6 +293,78 @@ concept in every core — the floor itself has NO init surface anywhere):
278
293
 
279
294
  ---
280
295
 
296
+ ## The value-pattern secret layer (always-on)
297
+
298
+ The credential floor masks by NAME and is deliberately value-shape-blind — a
299
+ live AWS key under the field name `note` would sail past it. Foam telemetry is
300
+ read downstream by LLMs, so a leaked credential is exfiltratable by prompt
301
+ injection; "no leakage at all" is the bar. The value-pattern secret layer is
302
+ the second always-on control (fleet ruling 2026-07-27,
303
+ `docs/decisions/security-fixes-design.md`):
304
+
305
+ **What it masks.** Credential-SHAPED value spans, regardless of the field
306
+ name they hide under: AWS access-key ids and keyword-anchored AWS secrets,
307
+ GCP API keys, Azure client/storage secrets, GitHub tokens and fine-grained
308
+ PATs, GitLab tokens, Slack tokens + webhooks, Stripe secret keys
309
+ (publishable `pk_` keys are deliberately NOT masked), JWTs, PEM private-key
310
+ blocks (`-----BEGIN … PRIVATE KEY` — a truncated block masks to the end of
311
+ the value, fail-closed; `PUBLIC KEY`/`CERTIFICATE` blocks never match) and
312
+ PuTTY PPK keys, `scheme://user:pass@` URI credentials (the whole userinfo,
313
+ username included), `Bearer`/`Basic` auth values, and Anthropic/OpenAI API
314
+ keys. The matched span becomes the literal `[REDACTED]` — no tail, no
315
+ prefix, zero reconstructable bytes; surrounding text keeps its diagnostic
316
+ value (`conn to postgres://[REDACTED]@db:5432 refused`).
317
+
318
+ **The heuristic tier** additionally catches the long tail: a
319
+ keyword-anchored generic pass (`password = hunter2SecretXyz99` inside a log
320
+ body masks its value — gitleaks-derived, entropy-gated, with placeholder/
321
+ stopword/key-context false-positive suppression) and a whole-value
322
+ high-entropy base64 token pass (detect-secrets-derived; pure hex and UUIDs
323
+ are deliberately exempt — trace ids and digests are telemetry's ambient
324
+ vocabulary). Only this tier is disableable: `secret_heuristics: false`
325
+ (one loud `[foam]` line; the named patterns above are not configurable).
326
+
327
+ **Where it runs.** Every value-bearing string on every export path, both
328
+ doors: span/event/link attribute values, `status.message`, exception
329
+ messages, log bodies (string and structured leaves), SQL (`db.statement`),
330
+ LLM content (BEFORE serialization and again at the boundary), URL query AND
331
+ fragment pair values, baggage-derived attributes, exemplar
332
+ `filtered_attributes`, and resource identity strings (once, at init).
333
+ Metric datapoint attributes (metric labels) are the one exempt surface —
334
+ low-cardinality by construction; a value mask there would break your
335
+ aggregation dimensions.
336
+
337
+ **Bounded by design.** Compile-once rules, a literal-anchor pre-filter (no
338
+ anchor in the value → zero regexes run), a 256 KiB value cap (an oversized
339
+ value with an anchor hit anywhere masks WHOLE, fail-closed; without one it
340
+ passes untouched), a match-flood cap, a 256-pair query/fragment tokenizer
341
+ cap, and a per-Regexp timeout on Ruby ≥ 3.2. ANY scanner fault masks the
342
+ value rather than shipping it raw.
343
+
344
+ **Provenance.** The ruleset derives from MIT/Apache-licensed OSS secret
345
+ scanners (gitleaks, detect-secrets, secretlint) — see `THIRD-PARTY-NOTICES`.
346
+ Rules are frozen module constants, byte-consistent across the foam cores,
347
+ changed only by fleet ruling.
348
+
349
+ > **Migration note — 1.4.0 (minor): value-pattern secret masking + redaction
350
+ > coverage fix + TLS pin.** foam now masks credential-SHAPED values
351
+ > (AWS/GCP/Azure keys, GitHub/GitLab tokens, Slack, Stripe, Anthropic/OpenAI
352
+ > keys, JWTs, private-key blocks, `user:pass@` URI credentials, bearer/basic
353
+ > tokens) to `[REDACTED]` in every exported string value — regardless of
354
+ > field name — plus a generic keyword+entropy heuristic you can disable with
355
+ > `secret_heuristics: false` (the named patterns and the credential floor
356
+ > cannot be disabled). Redaction now also reaches URL FRAGMENTS (OAuth
357
+ > implicit-flow tokens, hash-router params), bare `k=v` pair lists, nested
358
+ > attribute containers (fail-closed), and structured LLM content BEFORE
359
+ > serialization. Query/fragment tokenizing is capped at 256 pairs (the
360
+ > overflow remainder masks whole). Metric labels are not value-scanned. TLS
361
+ > peer verification is now PINNED on every exporter:
362
+ > `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE` is INERT and loudly warned about.
363
+ > If a dashboard keyed off a raw token value (it should not have), it will
364
+ > now see `[REDACTED]`.
365
+
366
+ ---
367
+
281
368
  ## The helpers
282
369
 
283
370
  All helpers never raise, and no-op silently before `init` and when disabled.
@@ -668,6 +755,7 @@ end
668
755
  | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | INERT — foam wires ONE resolved endpoint into all three exporters explicitly, so per-signal endpoint vars never redirect (or split) foam's export. Set → loud `[foam]` warning that it is inert. |
669
756
  | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | INERT — as above (warns when set). |
670
757
  | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | INERT — as above (warns when set). |
758
+ | `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE` | INERT — foam pins TLS peer verification (`ssl_verify_mode: VERIFY_PEER`) on every exporter constructor, both doors, so this vendored-exporter knob can never strip certificate verification off the credential-bearing export connection (1.4.0 security fix, CWE-295). Set → loud `[foam]` warning that it is inert; fix the trust chain (CA bundle) instead. |
671
759
  | every other `OTEL_*` | INERT — `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES` never override the init-declared identity; sampler/exporter/span-limit vars are ignored. |
672
760
 
673
761
  ---
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
@@ -12,7 +12,7 @@ module Foam
12
12
  Config = Struct.new(
13
13
  :name, :environment, :version, :enabled,
14
14
  :redact_keys, :redact_pii_keys, :ignored_outbound_hosts,
15
- :diagnostics, :endpoint,
15
+ :diagnostics, :endpoint, :secret_heuristics,
16
16
  keyword_init: true
17
17
  )
18
18
 
@@ -24,7 +24,8 @@ module Foam
24
24
  name: "", environment: "", version: nil, enabled: false,
25
25
  redact_keys: [].freeze, redact_pii_keys: [].freeze,
26
26
  ignored_outbound_hosts: [].freeze,
27
- diagnostics: false, endpoint: FOAM_OTEL_ENDPOINT
27
+ diagnostics: false, endpoint: FOAM_OTEL_ENDPOINT,
28
+ secret_heuristics: true
28
29
  ).freeze
29
30
  end
30
31
 
@@ -36,7 +37,7 @@ module Foam
36
37
 
37
38
  def resolve_config(name:, environment:, version:, enabled:,
38
39
  redact_keys:, redact_pii_keys:, ignored_outbound_hosts:,
39
- diagnostics:, endpoint:)
40
+ diagnostics:, endpoint:, secret_heuristics: true)
40
41
  Config.new(
41
42
  name: name,
42
43
  environment: environment,
@@ -51,7 +52,14 @@ module Foam
51
52
  redact_pii_keys: downcase_list(redact_pii_keys),
52
53
  ignored_outbound_hosts: Array(ignored_outbound_hosts).map { |h| h.to_s.downcase }.freeze,
53
54
  diagnostics: diagnostics ? true : false,
54
- endpoint: endpoint
55
+ endpoint: endpoint,
56
+ # The value-pattern layer's HEURISTIC tier opt-out (security-fixes-
57
+ # design §V.8): false disables H1/H2 ONLY — the named Tier-1
58
+ # patterns, the credential floor and the coverage contract are
59
+ # module-constant machinery no config shape can narrow. Anything
60
+ # but literal false means ON (default-on preserves the fleet's
61
+ # no-leakage bar; disabling is an explicit, audited decision).
62
+ secret_heuristics: secret_heuristics == false ? false : true
55
63
  ).freeze
56
64
  end
57
65
 
@@ -61,12 +69,14 @@ module Foam
61
69
  Array(list).map { |k| k.to_s.downcase }.reject(&:empty?).uniq.freeze
62
70
  end
63
71
 
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:)
72
+ # A second init() refreshes ONLY the redaction lists (and diagnostics,
73
+ # and the heuristic-tier flag redaction state) onto the EXISTING
74
+ # config: enabled/endpoint/identity are process-global and immutable
75
+ # 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).
78
+ def refresh_post_init_config(existing, redact_keys:, redact_pii_keys:, diagnostics:,
79
+ secret_heuristics: true)
70
80
  Config.new(
71
81
  name: existing.name,
72
82
  environment: existing.environment,
@@ -76,7 +86,8 @@ module Foam
76
86
  redact_pii_keys: downcase_list(redact_pii_keys),
77
87
  ignored_outbound_hosts: existing.ignored_outbound_hosts,
78
88
  diagnostics: diagnostics ? true : false,
79
- endpoint: existing.endpoint
89
+ endpoint: existing.endpoint,
90
+ secret_heuristics: secret_heuristics == false ? false : true
80
91
  ).freeze
81
92
  end
82
93
  end
@@ -132,5 +132,215 @@ module Foam
132
132
  REDACTED = "[REDACTED]"
133
133
  MASK_BODY = "********" # always exactly eight chars — never length-revealing
134
134
  TAIL_MIN_LENGTH = 12 # below this, no tail is safe
135
+
136
+ # ---- THE VALUE-PATTERN SECRET LAYER (fleet ruling 2026-07-27) -----------
137
+ # docs/decisions/security-fixes-design.md CONTRACT V: the always-on second
138
+ # control the credential floor needs — the floor masks by NAME and is
139
+ # value-shape-blind by design, so a credential-SHAPED value under an
140
+ # innocuous key would still export raw (X1 amplifier; foam telemetry is
141
+ # read downstream by LLMs, so a leaked credential is exfiltratable by
142
+ # prompt injection). These constants are the frozen ruleset the central
143
+ # engine (redaction.rb scan_value_secrets) consults DIRECTLY — module-level
144
+ # frozen constants, never Config state, same structural non-disableability
145
+ # as the floor (design §V.8: Tier 1 has NO off switch; only the H1/H2
146
+ # heuristic tier honors the documented secret_heuristics: false opt-out).
147
+ #
148
+ # Licensing/provenance (design §V.1): every pattern derives from MIT
149
+ # (gitleaks b58d3f1, secretlint 7da613e) or Apache-2.0 (detect-secrets
150
+ # 5e14193) sources — see THIRD-PARTY-NOTICES; AGPL trufflehog was used as
151
+ # a coverage CHECKLIST only, no pattern transcribed. FLEET-FIXTURE NOTE:
152
+ # the design mandates a shared, byte-identity-gated
153
+ # contract/secret-patterns.json these constants mirror; that fixture is
154
+ # authored fleet-side (outside ruby/foam-otel's edit lane) — these
155
+ # constants faithfully implement design §V.2–V.4 and are this core's
156
+ # authoritative copy until the shared fixture lands and
157
+ # spec/value_secret_layer_spec.rb's identity gate flips from lint-only to
158
+ # byte-identity.
159
+
160
+ # Execution bounds (design §V.5 / C-caps). All overflow behaviors are
161
+ # FAIL-CLOSED: mask, never raw-pass, never crash.
162
+ SECRET_SCAN_CAPS = {
163
+ value_scan_cap: 262_144, # 256 KiB (design §V.5.4) — oversize ⇒ anchor-only decision
164
+ pattern_max_matches_per_value: 1000, # match flood ⇒ whole value masked
165
+ pem_block_cap: 16_384, # BEGIN…END search window; truncated ⇒ mask to end of value
166
+ h2_token_min: 24,
167
+ h2_token_max: 512,
168
+ query_pair_cap: 256, # C1.3 — overflow remainder masked; never an unbounded split (F-JS2 class)
169
+ key_len_cap: 256, # C-caps — skip super-linear key work past this (F-PY2 class)
170
+ key_token_cap: 32, # C-caps — matched keys are short
171
+ }.freeze
172
+
173
+ # Tier-1 named-provider patterns (design §V.2), byte-consistent with the
174
+ # python core's SECRET_VALUE_RULES. Each rule: lowercase literal :anchors
175
+ # (the regex NEVER runs unless an anchor occurs case-insensitively in the
176
+ # value — the gitleaks keywords pre-filter and the engine's linearity
177
+ # guarantee), a :regex whose group 1 is the secret span to mask, an
178
+ # optional :entropy floor over the candidate, and an optional :filter
179
+ # post-check (:digit / :classmix). No lookbehind, no possessive groups,
180
+ # bounded quantifiers (design §V.5.2 authoring rules — linted by
181
+ # spec/value_secret_layer_spec.rb). private-key-block and putty-private-key
182
+ # are LINEAR SCANS in the engine (design §V.2 normative algorithm), not
183
+ # table regexes.
184
+ SECRET_VALUE_RULES = [
185
+ {
186
+ id: "aws-access-key-id", ci: false, group: 1,
187
+ regex: '\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA)[A-Z2-7]{16})\b',
188
+ anchors: %w[akia asia abia acca agpa aida aroa aipa anpa anva a3t].freeze,
189
+ }.freeze,
190
+ {
191
+ id: "aws-secret-key", ci: true, group: 1, entropy: 3.5,
192
+ regex: '\baws[a-z0-9_ .\-]{0,25}?(?:key|secret|token)[a-z0-9_ .\-]{0,15}?[\'"]?\s{0,3}' \
193
+ '(?:=|:|=>|:=|,)\s{0,3}[\'"]?([A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])',
194
+ anchors: %w[aws].freeze,
195
+ }.freeze,
196
+ {
197
+ id: "gcp-api-key", ci: false, group: 1,
198
+ regex: '\b(AIza[0-9A-Za-z_\-]{35})(?![0-9A-Za-z_\-])',
199
+ anchors: %w[aiza].freeze,
200
+ }.freeze,
201
+ {
202
+ id: "azure-client-secret", ci: false, group: 1, entropy: 3.0,
203
+ regex: '(?:^|[\s\'"`>=:(,])([A-Za-z0-9_~.]{3}[0-9]Q~[A-Za-z0-9_~.\-]{31,34})(?![A-Za-z0-9_~.\-])',
204
+ anchors: %w[q~].freeze,
205
+ }.freeze,
206
+ {
207
+ id: "azure-storage-key", ci: true, group: 1,
208
+ regex: '\baccount[_\-]?key[\'"]?\s{0,3}[:=]\s{0,3}[\'"]?([A-Za-z0-9+/]{86}==)',
209
+ anchors: %w[accountkey account_key account-key].freeze,
210
+ }.freeze,
211
+ {
212
+ id: "github-token", ci: false, group: 1,
213
+ regex: '\b((?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36})(?![A-Za-z0-9_])',
214
+ anchors: %w[ghp_ gho_ ghu_ ghs_ ghr_].freeze,
215
+ }.freeze,
216
+ {
217
+ id: "github-fine-grained-pat", ci: false, group: 1,
218
+ regex: '\b(github_pat_[A-Za-z0-9_]{82})(?![A-Za-z0-9_])',
219
+ anchors: %w[github_pat_].freeze,
220
+ }.freeze,
221
+ {
222
+ id: "gitlab-token", ci: false, group: 1,
223
+ regex: '\b(glpat-[0-9A-Za-z_\-]{20,300}(?:\.[0-9a-z]{2,9})?|gl(?:ptt|rt|cbt)-[0-9A-Za-z_\-]{20,300}|' \
224
+ 'GR1348941[0-9A-Za-z_\-]{20})(?![0-9A-Za-z_\-.])',
225
+ anchors: %w[glpat- glptt- glrt- glcbt- gr1348941].freeze,
226
+ }.freeze,
227
+ {
228
+ id: "slack-token", ci: false, group: 1,
229
+ regex: '\b(xox[abopsr]-(?:[0-9]{4,20}-){1,4}[0-9A-Za-z]{8,64}|xapp-[0-9]-[0-9A-Za-z_]{8,32}-[0-9]{8,16}-' \
230
+ '[0-9a-f]{32,96}|xoxe(?:\.xox[bp])?-[0-9]-[0-9A-Za-z\-]{80,250})(?![0-9A-Za-z\-])',
231
+ anchors: %w[xox xapp-].freeze,
232
+ }.freeze,
233
+ {
234
+ id: "slack-webhook", ci: true, group: 1,
235
+ regex: '(https://hooks\.slack\.com/(?:services|workflows|triggers)/[A-Za-z0-9+/]{20,120})(?![A-Za-z0-9+/])',
236
+ anchors: %w[hooks.slack.com].freeze,
237
+ }.freeze,
238
+ {
239
+ id: "stripe-secret-key", ci: false, group: 1,
240
+ regex: '\b((?:sk|rk)_(?:test|live|prod)_[a-zA-Z0-9]{10,99})(?![a-zA-Z0-9])',
241
+ anchors: %w[sk_test sk_live sk_prod rk_test rk_live rk_prod].freeze,
242
+ }.freeze,
243
+ {
244
+ id: "jwt", ci: false, group: 1,
245
+ regex: '\b(ey[A-Za-z0-9]{17,512}\.ey[A-Za-z0-9/_\-]{17,8192}\.(?:[A-Za-z0-9/_\-]{10,1024}={0,2})?)' \
246
+ '(?![A-Za-z0-9/_\-.=])',
247
+ anchors: %w[eyj].freeze,
248
+ }.freeze,
249
+ {
250
+ id: "uri-userinfo-credentials", ci: true, group: 1,
251
+ regex: '\b[a-z][a-z0-9+.\-]{0,30}://([^/\s:@?#]{1,128}:[^/\s@]{1,256})@',
252
+ anchors: %w[://].freeze,
253
+ }.freeze,
254
+ {
255
+ id: "bearer-token", ci: true, group: 1, filter: :digit,
256
+ regex: '\bbearer\s{1,4}([A-Za-z0-9._~+/=\-]{16,512})(?![A-Za-z0-9._~+/=\-])',
257
+ anchors: %w[bearer].freeze,
258
+ }.freeze,
259
+ {
260
+ id: "basic-auth-value", ci: true, group: 1, filter: :classmix,
261
+ regex: '\bbasic\s{1,4}([A-Za-z0-9+/]{12,512}={0,2})(?![A-Za-z0-9+/=])',
262
+ anchors: %w[basic].freeze,
263
+ }.freeze,
264
+ {
265
+ id: "anthropic-api-key", ci: false, group: 1,
266
+ regex: '\b(sk-ant-(?:admin|api)0[0-9]-[A-Za-z0-9_\-]{80,120}AA)(?![A-Za-z0-9_\-])',
267
+ anchors: %w[sk-ant-].freeze,
268
+ }.freeze,
269
+ {
270
+ id: "openai-api-key", ci: false, group: 1,
271
+ regex: '\b(sk-(?:proj|svcacct|admin)-[A-Za-z0-9_\-]{40,120}T3BlbkFJ[A-Za-z0-9_\-]{40,120}|' \
272
+ 'sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20})(?![A-Za-z0-9_\-])',
273
+ anchors: %w[t3blbkfj].freeze,
274
+ }.freeze,
275
+ ].freeze
276
+
277
+ # Anchors for the two linear-scan rules (design §V.2: private-key-block —
278
+ # BEGIN…PRIVATE KEY blocks incl. RSA/EC/DSA/OPENSSH/ENCRYPTED/PGP, truncated
279
+ # block ⇒ mask to end of value, PUBLIC KEY/CERTIFICATE never match; and
280
+ # putty-private-key — the PPK header carries Private-Lines inline ⇒ whole
281
+ # value masked). Folded into the single anchor pre-filter so an OVERSIZED
282
+ # value carrying only a PEM block still fails closed.
283
+ SECRET_LINEAR_SCAN_ANCHORS = ["-----begin", "putty-user-key-file"].freeze
284
+
285
+ # Tier-2 heuristics (design §V.3) — the ONLY value-layer piece with the
286
+ # documented opt-out (secret_heuristics: false). H1 = gitleaks
287
+ # generic-api-key (MIT): keyword-anchored assignment, entropy-gated,
288
+ # four-layer FP suppression. H2 = detect-secrets Base64HighEntropyString
289
+ # (Apache-2.0): whole-value single token only.
290
+ SECRET_HEURISTIC_H1 = {
291
+ id: "generic-api-key", ci: true, group: 1, entropy: 3.5, entropy_hex: 3.0,
292
+ regex: '[\w.\-]{0,50}?(?:access|auth|api|credential|creds|key|passw(?:or)?d|secret|token)' \
293
+ '(?:[ \t\w.\-]{0,20})[\s\'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[`\'"\s=]{0,5}' \
294
+ '([\w.=\-]{10,150}|[a-z0-9][a-z0-9+/]{11,149}={0,3})(?:[`\'"\s;]|\\\\[nr]|$)',
295
+ anchors: %w[access auth api credential creds key passw secret token].freeze,
296
+ }.freeze
297
+
298
+ # H2 anchors are \A/\z (whole string) — Ruby's ^/$ are LINE anchors, so the
299
+ # fixture's ^…$ whole-value semantics translate to \A…\z here (recorded
300
+ # portability translation, design §V.2 conventions).
301
+ SECRET_HEURISTIC_H2 = {
302
+ id: "high-entropy-token", entropy: 4.5,
303
+ token_regex: '\A[A-Za-z0-9+/_\-]{24,512}={0,2}\z',
304
+ uuid_regex: '\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z',
305
+ hex_regex: '\A[0-9a-fA-F]+\z',
306
+ }.freeze
307
+
308
+ # Placeholder shapes (design §V.4, gitleaks global allowlist): a HEURISTIC
309
+ # candidate matching any of these whole is never masked (env refs, template
310
+ # vars, printf specs, repeated fillers). Applied to H1/H2 candidates only —
311
+ # NEVER to Tier-1 matches (no Tier-1 value allowlist: a value allowlist is
312
+ # a bypass shape; AKIAIOSFODNN7EXAMPLE is deliberately a POSITIVE).
313
+ SECRET_VALUE_PLACEHOLDERS = [
314
+ '\A(?:true|false|null|nil|none|undefined)\z',
315
+ '\A(.)\1{3,}\z',
316
+ '\A\$[A-Za-z_][A-Za-z0-9_]*\z',
317
+ '\A\$\{[^}]{0,64}\}\z',
318
+ '\A\{\{[^}]{0,64}\}\}\z',
319
+ '\A%[A-Za-z_]{1,64}%\z',
320
+ '\A@[A-Za-z_]{1,64}@\z',
321
+ '\A%[sdifv]\z',
322
+ '\A<[^>]{0,64}>\z',
323
+ ].freeze
324
+
325
+ # Key-context allowlist (design §V.3 / H1): when the matched assignment's
326
+ # KEY text is one of these non-secret names, the H1 candidate is NOT
327
+ # masked. Vendored subset of the gitleaks generic-rule allowlist,
328
+ # byte-consistent with the python core.
329
+ SECRET_KEY_CONTEXT_ALLOWLIST = %w[
330
+ api_version apiversion key_id keyid public_key publickey
331
+ csrf_token primary_key partition_key sort_key idempotency_key
332
+ cache_key routing_key aggregate_key
333
+ ].freeze
334
+
335
+ # Stopwords (design §V.4, from the gitleaks curated list): a heuristic
336
+ # candidate CONTAINING any of these is rejected as a placeholder false
337
+ # positive. Applied to H1/H2 candidates only — never to Tier-1 matches.
338
+ # Byte-consistent with the python core's vendored subset; the full
339
+ # 1,436-entry list ships fleet-side in contract/secret-patterns.json.
340
+ SECRET_VALUE_STOPWORDS = %w[
341
+ example placeholder redacted changeme changeit
342
+ yourkey your_api_key your-api-key xxxxxxxx todo
343
+ notreal loremipsum deadbeef 0000000000 1111111111
344
+ ].freeze
135
345
  end
136
346
  end