bug_bunny 5.1.0 → 5.1.1

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: 877e5f5961f53d587b2bc67e6a8c39d247b996e1272ddad076236633df65f267
4
- data.tar.gz: 6928594ae30624f1dccd2841594bee4b0faed40ee0cd238a69387399559a757b
3
+ metadata.gz: dd173a6f940a79457235d63629891426cba1f94a8381c19a219e38f275fe96dd
4
+ data.tar.gz: 78f927d9ed2ef4d04bf5b3059a3b3d54b93974ee11a8d7235ade9ae82095a51b
5
5
  SHA512:
6
- metadata.gz: fea6d7206396aae6ff2b35277e651ed944bee590f74200a392f2fb962af8757f56057b6d637434a91603d272b59b6215b459b330e98999d37f72d1c2ad6af763
7
- data.tar.gz: b9d531b234ec094ab7127ea5921506d92a32e57961463904e6a62e158ce8a8e3020368ff1c6006cb30ae62a5beea942f3c004ce33e91362c910de2e87165aa6e
6
+ metadata.gz: 188ad45954c08cf19dda7625236fdc8da934676de77a6af7136547893f2fc8ae0ad782aa890782dbd59e16b39b90d8fa8dec3f9b1c9a5c3b4b4fd22db5c77f8f
7
+ data.tar.gz: 87bf5385a97fea06f86fccb396ade8ce2602ec831181e4a2ba7893711a784eccb4d922ca4303137f0e25f7a625d88d015e61858f4f48209fa89624ae02a92cea
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.1.1] - 2026-07-31
4
+
5
+ ### Correcciones
6
+ - **`safe_log` redacta credenciales embebidas en el VALOR, no solo por nombre de clave (#61):** el filtro por-clave solo ve el NOMBRE, así que una credencial en texto libre pasaba entera al log. El caso canónico es el `message` de una excepción inesperada, que llega como `reason=`/`error_message=` —nombres no sensibles— y puede arrastrar `Authorization: "Bearer …"`. Se agrega `Observability.redact_value`, aplicada a todo valor no numérico que `safe_log` serializa, con tres reglas: esquemas de auth HTTP (`Bearer`/`Basic`), key sensible dentro del texto (conserva el nombre de la key y filtra solo el valor) y credenciales en URL (`user:pass@host`). Cubre las variantes con separador y camelCase (`access_token=`, `user_password=`, `accessToken=`), igual que el matching por substring de `sensitive_key?`. No-breaking: no cambia firmas ni el formato `k=v`, y un log sin credenciales sale idéntico. Riesgo asumido: sobre-redacción de contenido diagnóstico anclado a marcadores de credencial (un `token=missing` sale filtrado). — @gedera
7
+ - **Un valor `Hash` sigue siendo JSON parseable después de redactar:** se agrega `Observability.redact_structure`, que redacta la estructura **antes** de serializar (recorre `Hash` anidado y `Array`) en vez de aplicar el regex sobre el JSON ya serializado, que colapsaba el par `"token": "abc"` y dejaba el campo sin parsear — el secreto desaparecía, pero quien consume el log perdía el objeto entero. De paso, las keys internas del `Hash` ahora también pasan por `sensitive_key?`. — @gedera
8
+
9
+ ### Documentación
10
+ - La lista de claves sensibles **deja de replicarse**: el docblock de `Error#raw_response` y `docs/errors/` apuntan a `Observability::SENSITIVE_KEYS` como canónica, en vez de duplicarla. La copia venía desincronizada (incluía `pass` bare, que está excluido a propósito para no filtrar `passport_number`, y le faltaban `authorization`, `credential`, `private_key`, `csrf`, `session_id`). `docs/glossary/`, README y `skill/` describen ahora las dos capas de redacción. — @Pslp
11
+
3
12
  ## [5.1.0] - 2026-07-22
4
13
 
5
14
  ### Nuevas funcionalidades
data/README.md CHANGED
@@ -365,7 +365,10 @@ BugBunny measures and emits durations automatically — **there is no need to wr
365
365
  | `consumer.message_processed` | `duration_s` | Message processing (router + controller + reply). |
366
366
  | `consumer.execution_error` | `duration_s` | Elapsed time until the error. |
367
367
 
368
- Sensitive keys (`password`, `token`, `secret`, `api_key`, `authorization`, etc.) are automatically filtered to `[FILTERED]` across all log output.
368
+ Credentials are redacted to `[FILTERED]` across all log output, in two layers:
369
+
370
+ - **By key name** — sensitive keys (`password`, `token`, `secret`, `api_key`, `authorization`, etc.) are matched as substrings, so variants like `user_password` or `accessToken` are covered too.
371
+ - **By value content** — a credential embedded in free text is redacted even when the key name is *not* sensitive. The canonical case: an unexpected exception whose `message` carries `Authorization: "Bearer …"` and reaches the log as `reason=`. Covers HTTP auth schemes, a sensitive key inside the text, and credentials in a URL. `Hash` values are redacted **before** being serialized, so the field stays valid JSON.
369
372
 
370
373
  ---
371
374
 
@@ -429,20 +432,31 @@ end
429
432
 
430
433
  Artefactos de detalle (modelo `dev-*`, RFC-001). El README indexa; no duplica.
431
434
 
435
+ Anclado a `v5.1.1`.
436
+
432
437
  | Capa | Artefacto | Estado |
433
438
  |---|---|---|
434
- | Datos | — | n/a — gema sin DB (sin schema/models) |
435
439
  | Glosario | [docs/glossary/glossary.md](docs/glossary/glossary.md) | parcial, acreta por PR |
436
440
  | Comportamiento | [docs/behavior/behavior.md](docs/behavior/behavior.md) | completa — 6 flujos (backfill on-demand) |
437
- | Operaciones / Interfaz / Topología | | F2 no implementado (dev-structure) ver nota |
441
+ | Configuración | [docs/config/configuracion.md](docs/config/configuracion.md) | §a-§e/§i estructura + §f/§g/§h enrichcompleta |
442
+ | Dependencias consumidas | [docs/consumed/rabbitmq.md](docs/consumed/rabbitmq.md) | §a/§b/§d estructura + §c/§e enrich |
443
+ | Errores | [docs/errors/errors.md](docs/errors/errors.md) | §a/§b/§d completas; §c política inferida (verificación humana pendiente) |
444
+ | Test | [docs/test/testing.md](docs/test/testing.md) | §a-§h — 16 unit / 7 integration |
445
+ | Release | [docs/release/release.md](docs/release/release.md) | completa (régimen gema: build→publish; §e/f/g n/a) |
446
+ | Datos | — | n/a — gema sin DB (sin schema/models) |
447
+ | Eventos | — | n/a — la gema **es** el transporte; no declara catálogo de eventos de dominio propio |
448
+ | Seguridad | — | n/a — sin authn/authz propias; el guard anti-RCE (403) se documenta en `docs/errors/` |
449
+ | Operaciones / Interfaz / Topología | — | pendiente — ver nota |
438
450
 
439
- **Coexistencia transitoria con destino pendiente (RFC-008 §2 — interim de migración):** mientras la capa de detalle destino (operaciones/interfaz/topología) esté declarada pero **no implementada** (dev-structure F1, F2 del plan), permanecen embebidos/cruzados, bajo el interim normado:
451
+ **Coexistencia transitoria con destino pendiente (RFC-008 §2 — interim de migración):** mientras las capas `docs/api/` (operaciones), `docs/interface/` y `docs/topology/` no estén generadas para este repo, el contrato que les correspondería permanece embebido:
440
452
 
441
- - **En este README:** el contrato (jerarquía de excepciones, API de configuración, modos de entrega).
453
+ - **En este README:** la jerarquía de excepciones, la API de configuración y los modos de entrega.
442
454
  - **En `skill/SKILL.md`:** además el diagrama de arquitectura (flujo RPC).
443
455
  - **Guías how-to** (`skill/references/*.md`, pre-estándar): el README las enlaza pese a la regla "no referenciar `skill/` desde el README" — destino futuro `docs/howto/`.
444
456
 
445
- Por RFC-008 §2: no se fabrica la capa, no se borra contrato sin destino, no se duplica; migra cuando F2 entregue, mismo PR. Estado transitorio declarado, no excepción permanente. Origen del gap (resuelto, normado): [sequre/ai_knowledge#95](https://github.com/sequre/ai_knowledge/issues/95).
457
+ Por RFC-008 §2: no se fabrica la capa, no se borra contrato sin destino, no se duplica; migra cuando se generen, mismo PR. Estado transitorio declarado, no excepción permanente. Origen del gap (resuelto, normado): [sequre/ai_knowledge#95](https://github.com/sequre/ai_knowledge/issues/95).
458
+
459
+ > **Nota de alcance (2026-07-31):** la justificación original de este interim era que el generador no implementaba esas capas. Ya las implementa, así que el pendiente es de **este repo**, no de tooling — en particular `docs/interface/` (RFC-004), que es donde correspondería registrar la API pública de la gema. Queda como trabajo propio, fuera del alcance de este release.
446
460
 
447
461
  How-to (pre-estándar):
448
462
  [Routing](skill/references/routing.md) ·
@@ -1,10 +1,11 @@
1
1
  # Errores — bug_bunny
2
2
 
3
3
  > meta: artefacto errores · RFC-020 · generado `arch-structure` (§a/§b/§d) +
4
- > `arch-enrich` (§c) · anclado a `d0533bf`, `lib/bug_bunny/exception.rb`,
5
- > `remote_error.rb`, `middleware/raise_error.rb`, `controller.rb`, `consumer.rb`
6
- > · fecha 2026-06-30 · cobertura: §a/§b/§d completas (estructura); §c política
7
- > completa (enrich, **inferida** de HTTP/AMQP — verificación humana pendiente).
4
+ > `arch-enrich` (§c) · anclado a `5697541`, `lib/bug_bunny/exception.rb`,
5
+ > `remote_error.rb`, `middleware/raise_error.rb`, `controller.rb`, `consumer.rb`,
6
+ > `observability.rb` · fecha 2026-07-31 · cobertura: §a/§b/§d completas
7
+ > (estructura); §c política completa (enrich, **inferida** de HTTP/AMQP —
8
+ > verificación humana pendiente).
8
9
 
9
10
  ## 1. Resumen
10
11
 
@@ -156,8 +157,13 @@ parsea el body, devuelve `parsed['errors']` por convención o el cuerpo completo
156
157
 
157
158
  > **Seguridad (cruza RFC-017):** `raw_response` puede contener datos sensibles en
158
159
  > `details`. La gema lo entrega crudo a propósito; **sanitizar antes de cualquier
159
- > sink** (Sentry/logs) filtrando `password|pass|passwd|secret|token|api_key|auth`
160
- > `[FILTERED]` es responsabilidad del consumidor (`exception.rb:25-30`).
160
+ > sink** (Sentry/logs) es responsabilidad del consumidor (`exception.rb:25-34`).
161
+ > La lista canónica de claves sensibles es `Observability::SENSITIVE_KEYS`
162
+ > (`observability.rb:13-16`) y **no se replica acá** — replicarla la desincroniza
163
+ > (ojo: `pass` bare NO está en la lista, a propósito, para no filtrar
164
+ > `passport_number`). Para sanear se reusa `Observability.sensitive_key?` (filtra
165
+ > por NOMBRE de clave) y `Observability.redact_structure` (recorre la estructura
166
+ > y además redacta credenciales embebidas en el VALOR → `[FILTERED]`).
161
167
 
162
168
  ## 3. Inferencias
163
169
 
@@ -1,6 +1,6 @@
1
1
  # Glosario — bug_bunny
2
2
 
3
- > meta: artefacto `glosario` · RFC-009 (binding opcional, r: §2 materialización no-tabular) · generado dev-enrich (siembra) · anclado a `a5cdb10` · cobertura: parcial, acreta por PR
3
+ > meta: artefacto `glosario` · RFC-009 (binding opcional, r: §2 materialización no-tabular) · generado dev-enrich (siembra) · anclado a `5697541` · fecha 2026-07-31 · cobertura: parcial, acreta por PR
4
4
 
5
5
  ## 1. Resumen
6
6
 
@@ -127,7 +127,7 @@ Cadena transversal que corre antes del dispatch al controller (tracing, auth, lo
127
127
  - `lib/bug_bunny/consumer_middleware.rb` — `BugBunny::ConsumerMiddleware`
128
128
 
129
129
  ## Observability
130
- Mixin de logging estructurado `key=value` que implementa OTel semantic conventions for messaging; `safe_log` nunca lanza; filtra claves sensibles a `[FILTERED]`.
130
+ Mixin de logging estructurado `key=value` que implementa OTel semantic conventions for messaging; `safe_log` nunca lanza; redacta a `[FILTERED]` en dos capas — por **NOMBRE de clave** (`sensitive_key?`, substring sobre `SENSITIVE_KEYS`) y por **CONTENIDO del valor** (`redact_value`, para la credencial embebida en texto libre que la capa de clave no ve; `redact_structure` para un `Hash`, redactado antes de serializar para no romper el JSON).
131
131
 
132
132
  **Binding:**
133
133
  - `lib/bug_bunny/observability.rb` — `BugBunny::Observability`
@@ -2,8 +2,9 @@
2
2
 
3
3
  > meta: artefacto release · RFC-014 (`accepted`) · generado por `arch-structure`
4
4
  > + `arch-enrich` (híbrido RFC-014 §2; nació como piloto manual #51, re-anclado
5
- > a la RFC vigente) · anclado a `.github/workflows/release.yml`, `*.gemspec`,
6
- > `lib/bug_bunny/version.rb`, `CHANGELOG.md`, git · fecha 2026-06-30 · cobertura:
5
+ > a la RFC vigente) · anclado a `5697541`
6
+ > (`.github/workflows/release.yml`, `*.gemspec`, `lib/bug_bunny/version.rb`,
7
+ > `CHANGELOG.md`) · fecha 2026-07-31 · cobertura:
7
8
  > completa (régimen gema: build→publish, §e/f/g n/a).
8
9
 
9
10
  ## 1. Resumen
@@ -20,9 +21,9 @@ out-of-repo).
20
21
 
21
22
  ### a. Hecho verificable
22
23
 
23
- - **Convención de versión:** SemVer `vX.X.X`. Actual: **4.19.0**.
24
- - **Source of truth:** tag remoto (`v4.19.0`) + **triple mirror**
25
- `lib/bug_bunny/version.rb` (`VERSION = '4.19.0'`) ← `bug_bunny.gemspec:7`
24
+ - **Convención de versión:** SemVer `vX.X.X`. Actual: **5.1.1**.
25
+ - **Source of truth:** tag remoto (`v5.1.1`) + **triple mirror**
26
+ `lib/bug_bunny/version.rb` (`VERSION = '5.1.1'`) ← `bug_bunny.gemspec:7`
26
27
  (`spec.version = BugBunny::VERSION`).
27
28
  - **Changelog canónico:** `CHANGELOG.md` único.
28
29
  - **Patrón de trigger:** `gema-tag` (patrón 1).
@@ -33,7 +34,7 @@ out-of-repo).
33
34
 
34
35
  - **Convención:** SemVer `vX.X.X` (**con `v`** — distinto al servicio).
35
36
  - **Source of truth:** tag remoto canónico (`git tag --sort=-v:refname` →
36
- `v4.19.0`).
37
+ `v5.1.1`).
37
38
  - **Mirror:** `lib/bug_bunny/version.rb` (`VERSION`), leído por
38
39
  `bug_bunny.gemspec:7` (`spec.version = BugBunny::VERSION`).
39
40
  `required_ruby_version >= 2.6.0` (`bug_bunny.gemspec:17`).
@@ -58,8 +59,8 @@ out-of-repo).
58
59
  `on: push: tags: ['v*']` → `ruby/setup-ruby@v1` → `gem build *.gemspec` +
59
60
  `gem push *.gem` (auth `secrets.RUBYGEMS_API_KEY`). Auditable y versionado con
60
61
  el código; se ancla a `file:line`, no se referencia como caja negra.
61
- - **Consumo:** los servicios la pinnean por versión (`gem "bug_bunny", "~> 4.19.0"`)
62
- desde RubyGems — **no** git-source.
62
+ - **Consumo:** los servicios la pinnean por versión (`gem "bug_bunny", "~> X.Y.0"`,
63
+ hoy `~> 5.1.0`) desde RubyGems — **no** git-source.
63
64
 
64
65
  ### e. Deploy / publish
65
66
 
@@ -79,12 +80,19 @@ procedimiento per-repo porque no vive acá. Una versión yankeada se anotaría e
79
80
 
80
81
  ### h. Dependencias de deploy inter-servicio
81
82
 
82
- - **Consumidores** (cruza RFC-018): servicios del fleet la pinnean
83
- `~> 4.19.0` (semántica minor-compatible). Un cambio de contrato del gem
84
- (ej. el behavior-change de `4.18.0` `Bunny::Exception` `CommunicationError`)
85
- obliga a los consumidores a migrar; el `CHANGELOG.md` lo documenta como
86
- breaking note. **Orden de deploy:** los consumidores adoptan al hacer `bundle
87
- update bug_bunny` no hay deploy coordinado (cada servicio elige cuándo).
83
+ - **Consumidores** (cruza RFC-018): servicios del fleet la pinnean con la forma
84
+ `~> X.Y.0` **patch-compatible**, no minor: `~> 5.1.0` resuelve
85
+ `>= 5.1.0, < 5.2.0`, así que un minor **no entra** sin editar el `Gemfile`.
86
+ Consecuencia operativa: un fix publicado como **patch** lo toman con
87
+ `bundle update bug_bunny`; uno publicado como **minor** requiere tocar el pin
88
+ en cada consumidor. Estado hoy: `box_manager_service` y `box_radius_manager`
89
+ en `~> 5.1.0`; `box_acs_manager` y `box_cluster_manager` todavía en
90
+ `~> 4.19.0` (bumps pendientes). Un cambio de contrato del gem (ej. el
91
+ behavior-change de `4.18.0` — `Bunny::Exception` → `CommunicationError`, o la
92
+ eliminación de `BugBunny::SecurityError` en `5.0.0`) obliga a los consumidores
93
+ a migrar; el `CHANGELOG.md` lo documenta como breaking note. **Orden de
94
+ deploy:** los consumidores adoptan al hacer `bundle update bug_bunny` — no hay
95
+ deploy coordinado (cada servicio elige cuándo).
88
96
 
89
97
  ### i. Contrato con la skill productora
90
98
 
@@ -23,11 +23,15 @@ module BugBunny
23
23
  # respuesta RPC (ej: {CommunicationError}, {ConfigurationError}).
24
24
  #
25
25
  # @note **No loguear ni enviar a sinks (Sentry/logs) sin sanitizar.** El
26
- # cuerpo crudo puede contener datos sensibles (p. ej. en `details`). Antes
27
- # de cualquier sink, filtrar las claves sensibles del fleet
28
- # (`password|pass|passwd|secret|token|api_key|auth`) `[FILTERED]`. La
29
- # gema entrega el cuerpo crudo a propósito; sanitizarlo es responsabilidad
30
- # del consumidor.
26
+ # cuerpo crudo puede contener datos sensibles (p. ej. en `details`). La
27
+ # lista canónica de claves sensibles es
28
+ # {BugBunny::Observability::SENSITIVE_KEYS} no la repliques acá ni en el
29
+ # consumidor: se desincroniza (ojo que `pass` bare NO está en la lista, a
30
+ # propósito, para no filtrar `passport_number`). Para sanear, reusá
31
+ # {BugBunny::Observability.sensitive_key?} (filtra por NOMBRE de clave) y
32
+ # {BugBunny::Observability.redact_structure} (recorre la estructura y
33
+ # además redacta credenciales embebidas en el VALOR). La gema entrega el
34
+ # cuerpo crudo a propósito; sanitizarlo es responsabilidad del consumidor.
31
35
  attr_accessor :raw_response
32
36
 
33
37
  # @return [Integer, nil] El código de estado de la respuesta que originó el
@@ -27,6 +27,81 @@ module BugBunny
27
27
  SENSITIVE_KEYS.any? { |sensitive| key_str.include?(sensitive) }
28
28
  end
29
29
 
30
+ # Alternación de keys sensibles ordenada de más larga a más corta: en un regex
31
+ # la alternación matchea leftmost-first, así que sin este orden `auth` ganaría
32
+ # sobre `authorization` y el patrón dejaría de matchear (`orization=x` no sigue
33
+ # con `[:=]`).
34
+ SENSITIVE_KEYS_ALTERNATION = SENSITIVE_KEYS.sort_by { |k| -k.length }.join('|').freeze
35
+
36
+ # Reglas de VALOR sensible, como pares `[regex, reemplazo]`.
37
+ #
38
+ # {.sensitive_key?} solo ve el NOMBRE de la clave; no puede ver una credencial
39
+ # embebida en TEXTO LIBRE. El caso canónico es el `message` de una excepción
40
+ # inesperada (llega como `reason=` o `error_message=`, nombres no sensibles):
41
+ # un `NoMethodError` sobre un objeto de respuesta HTTP puede arrastrar
42
+ # `Authorization: "Bearer eyJ..."` en su mensaje y el filtro por-clave lo deja
43
+ # pasar entero al log.
44
+ #
45
+ # El reemplazo conserva el nombre de la clave cuando viaja dentro del texto
46
+ # (`token=[FILTERED]`, no `[FILTERED]`): saber QUÉ credencial apareció es
47
+ # diagnóstico útil; su valor no.
48
+ SENSITIVE_VALUE_RULES = [
49
+ # Esquemas de autenticación HTTP: "Bearer <jwt>", "Basic <base64>".
50
+ [/\b(?:bearer|basic)\s+[A-Za-z0-9\-._~+\/]{8,}={0,2}/i, '[FILTERED]'],
51
+ # La key viaja DENTRO del texto: `token=abc`, `password: 'x'`, `"api_key" => "y"`.
52
+ #
53
+ # El prefijo `\w*` va en lugar de un `\b`: `_` es word-char, así que un borde de
54
+ # palabra NO existe dentro de `access_token` ni de `accessToken` y esas variantes
55
+ # se colarían en claro — justo las que {.sensitive_key?} cubre a propósito con
56
+ # substring matching. Se captura el prefijo para conservar el nombre COMPLETO de la
57
+ # key en el log (`access_token=[FILTERED]`): saber qué credencial apareció es
58
+ # diagnóstico útil. No reintroduce el falso positivo de `passport_number` porque
59
+ # ninguna key de SENSITIVE_KEYS es substring suyo (por eso `pass` bare está excluida).
60
+ [/(\w*(?:#{SENSITIVE_KEYS_ALTERNATION}))["']?\s*(?:=>|[:=])\s*["']?[^\s,;"'}\])]+/i,
61
+ '\1=[FILTERED]'],
62
+ # Credenciales en una URL: `amqp://user:pass@host` → conserva el esquema y el host.
63
+ [%r{(://)[^\s/:@]+:[^\s/@]+@}, '\1[FILTERED]@']
64
+ ].freeze
65
+
66
+ # Redacta credenciales embebidas en un valor de texto libre.
67
+ #
68
+ # Complementa a {.sensitive_key?}: esa filtra por NOMBRE de clave, esta por
69
+ # CONTENIDO. Se aplica a todo valor no numérico que {#safe_log} serializa.
70
+ #
71
+ # @param value [Object] El valor a redactar (se serializa con `to_s`).
72
+ # @return [String] El valor con las credenciales reemplazadas por `[FILTERED]`.
73
+ def self.redact_value(value)
74
+ SENSITIVE_VALUE_RULES.reduce(value.to_s) do |acc, (pattern, replacement)|
75
+ acc.gsub(pattern, replacement)
76
+ end
77
+ end
78
+
79
+ # Redacta una estructura ANTES de serializarla, recorriendo keys y valores.
80
+ #
81
+ # Se usa para los valores `Hash` de {#safe_log}. Redactar el JSON ya serializado con
82
+ # {.redact_value} no sirve: la regla de key-dentro-del-texto normaliza el separador a
83
+ # `=` y se come la comilla de cierre de la key, dejando un objeto donde el par
84
+ # `"token": "abc"` quedó colapsado en `"token=[FILTERED]"` — el secreto desaparece,
85
+ # pero el campo deja de ser JSON parseable y quien consume el log pierde el objeto
86
+ # entero, no solo el valor redactado.
87
+ #
88
+ # Recorriendo la estructura, además, las keys internas SÍ pasan por {.sensitive_key?}
89
+ # (que solo veía las keys de primer nivel del metadata).
90
+ #
91
+ # @param obj [Object] Estructura a redactar (Hash/Array anidados incluidos).
92
+ # @return [Object] La misma forma, con los valores sensibles reemplazados.
93
+ def self.redact_structure(obj)
94
+ case obj
95
+ when Hash
96
+ obj.each_with_object({}) do |(k, v), acc|
97
+ acc[k] = sensitive_key?(k) ? '[FILTERED]' : redact_structure(v)
98
+ end
99
+ when Array then obj.map { |element| redact_structure(element) }
100
+ when Numeric, TrueClass, FalseClass, NilClass then obj
101
+ else redact_value(obj)
102
+ end
103
+ end
104
+
30
105
  private
31
106
 
32
107
  # Registra un evento estructurado. Nunca eleva excepciones.
@@ -43,12 +118,17 @@ module BugBunny
43
118
  val = BugBunny::Observability.sensitive_key?(k) ? '[FILTERED]' : v
44
119
  next if val.nil?
45
120
 
121
+ # La redacción por CONTENIDO se aplica a todo valor no numérico: el filtro
122
+ # por-clave de arriba no ve una credencial embebida en texto libre.
46
123
  formatted = case val
47
124
  when Numeric then val
48
125
  when Hash
49
- val.to_json
50
- when String then val.include?(' ') ? val.inspect : val
51
- else val.to_s.include?(' ') ? val.to_s.inspect : val
126
+ # Se redacta la estructura y DESPUÉS se serializa: al revés el campo
127
+ # queda con el secreto tapado pero el JSON roto (ver .redact_structure).
128
+ BugBunny::Observability.redact_structure(val).to_json
129
+ else
130
+ redacted = BugBunny::Observability.redact_value(val)
131
+ redacted.include?(' ') ? redacted.inspect : redacted
52
132
  end
53
133
  "#{k}=#{formatted}"
54
134
  end.compact.join(' ')
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = '5.1.0'
4
+ VERSION = '5.1.1'
5
5
  end
data/skill/SKILL.md CHANGED
@@ -19,7 +19,9 @@ Gema Ruby: capa de routing RESTful sobre AMQP/RabbitMQ. Microservicios se comuni
19
19
 
20
20
  ## Contrato resumido (piso mínimo)
21
21
 
22
- > Resume el contrato de **`bug_bunny` 4.18.0**. Suficiente para el uso típico sin abrir el detalle; el detalle version-locked está en [`../docs/behavior/behavior.md`](../docs/behavior/behavior.md) (6 flujos) y [`../docs/glossary/glossary.md`](../docs/glossary/glossary.md) (símbolos→significado). Antipatrones/API completa: más abajo (embebido interim, ver Cobertura y fronteras).
22
+ > Resume el contrato de **`bug_bunny` 5.1.1** (anclado a `v5.1.1`). Suficiente para el uso típico sin abrir el detalle; el detalle version-locked está en el **Índice de artefactos** de abajo. Antipatrones/API completa: más abajo (embebido interim, ver Cobertura y fronteras).
23
+ >
24
+ > **Si venís de 4.x, dos breaking a mirar antes de subir:** `5.0.0` eliminó la constante pública `BugBunny::SecurityError` (un `rescue BugBunny::SecurityError` que sobreviva revienta con `NameError` y **enmascara la excepción original**) y `4.18.0` cambió el wrapping `Bunny::Exception` → `CommunicationError`. Detalle en `CHANGELOG.md`.
23
25
 
24
26
  **Símbolos públicos clave**
25
27
 
@@ -58,14 +60,21 @@ client.publish('events', body: { type: 'x' }) # => { 'status' => 202 }
58
60
 
59
61
  ## Índice de artefactos (fuente de verdad)
60
62
 
61
- El detalle vive en `docs/<capa>/` (modelo `dev-*`); esta skill **indexa y resume**, no duplica. Links relativos = version-locked (mismo tag del release; `gemspec.files` incluye `docs/**`).
63
+ El detalle vive en `docs/<capa>/` (modelo `dev-*`); esta skill **indexa y resume**, no duplica. Links relativos = version-locked (mismo tag del release, `v5.1.1`; `gemspec.files` incluye `docs/**`, así que estos archivos viajan dentro del `.gem` que ya tenés instalado).
62
64
 
63
65
  | Capa | Artefacto | Estado |
64
66
  |---|---|---|
65
67
  | Glosario de dominio | [docs/glossary/glossary.md](../docs/glossary/glossary.md) | parcial, acreta por PR |
66
68
  | Comportamiento (flujos) | [docs/behavior/behavior.md](../docs/behavior/behavior.md) | completa — 6 flujos |
69
+ | Configuración | [docs/config/configuracion.md](../docs/config/configuracion.md) | §a-§e/§i estructura + §f/§g/§h enrich — completa |
70
+ | Dependencias consumidas | [docs/consumed/rabbitmq.md](../docs/consumed/rabbitmq.md) | §a/§b/§d estructura + §c/§e enrich |
71
+ | Errores | [docs/errors/errors.md](../docs/errors/errors.md) | §a/§b/§d completas; §c política inferida (verificación humana pendiente) |
72
+ | Test | [docs/test/testing.md](../docs/test/testing.md) | §a-§h — 16 unit / 7 integration |
73
+ | Release | [docs/release/release.md](../docs/release/release.md) | completa (régimen gema: build→publish; §e/f/g n/a) |
67
74
  | Datos | — | n/a — gema sin DB |
68
- | Operaciones / Interfaz / Topología | | F2 no implementado ver Cobertura y fronteras |
75
+ | Eventos | | n/a la gema **es** el transporte; no declara catálogo de eventos de dominio propio |
76
+ | Seguridad | — | n/a — sin authn/authz propias; el guard anti-RCE (403) está en `docs/errors/` |
77
+ | Operaciones / Interfaz / Topología | — | pendiente — ver Cobertura y fronteras |
69
78
 
70
79
  > **Glosario:** migrado a [docs/glossary/glossary.md](../docs/glossary/glossary.md)
71
80
  > (RFC-008 §2 — el compuesto referencia, no copia). Términos AMQP base
@@ -74,13 +83,15 @@ El detalle vive en `docs/<capa>/` (modelo `dev-*`); esta skill **indexa y resume
74
83
 
75
84
  ## Cobertura y fronteras
76
85
 
77
- **Coexistencia transitoria con destino pendiente (RFC-008 §2 — interim de migración):** mientras la capa de detalle destino (operaciones/interfaz/topología) esté declarada pero **no implementada** (dev-structure F1, F2 del plan), permanecen embebidos bajo el interim normado:
86
+ **Coexistencia transitoria con destino pendiente (RFC-008 §2 — interim de migración):** mientras las capas `docs/api/` (operaciones), `docs/interface/` y `docs/topology/` no estén generadas para este repo, el contrato que les correspondería permanece embebido bajo el interim normado:
78
87
 
79
88
  - **En esta skill (abajo):** el contrato detallado (jerarquía de excepciones, API de config, modos de entrega) **y** el diagrama de arquitectura (flujo RPC). El *Contrato resumido* de arriba es el piso mínimo (RFC-008 §2); lo de abajo es el detalle interim hasta que exista `docs/api|interface|topology`.
80
89
  - **En `README.md`:** el contrato (sin el diagrama de arquitectura).
81
90
  - **Guías how-to** (`references/*.md`, pre-estándar): destino futuro `docs/howto/`.
82
91
 
83
- Por RFC-008 §2: no se fabrica la capa, no se borra contrato sin destino, no se duplica; migra cuando F2 entregue, mismo PR. Estado transitorio declarado, no excepción permanente. Origen del gap (resuelto, normado): [sequre/ai_knowledge#95](https://github.com/sequre/ai_knowledge/issues/95).
92
+ Por RFC-008 §2: no se fabrica la capa, no se borra contrato sin destino, no se duplica; migra cuando se generen, mismo PR. Estado transitorio declarado, no excepción permanente. Origen del gap (resuelto, normado): [sequre/ai_knowledge#95](https://github.com/sequre/ai_knowledge/issues/95).
93
+
94
+ > **Nota de alcance (2026-07-31):** la justificación original de este interim era que el generador no implementaba esas capas. Ya las implementa, así que el pendiente es de **este repo**, no de tooling — en particular `docs/interface/` (RFC-004), donde correspondería registrar la API pública (incluidos `Observability.redact_value` y `.redact_structure`, agregados en `5.1.1`). Queda como trabajo propio, fuera del alcance de este release.
84
95
 
85
96
  ---
86
97
 
@@ -129,7 +140,7 @@ Por RFC-008 §2: no se fabrica la capa, no se borra contrato sin destino, no se
129
140
  | `BugBunny::Controller` | Base class tipo Rails. `before_action`, `around_action`, `after_action`, `rescue_from`, `render`. |
130
141
  | `BugBunny::Resource` | ORM sobre AMQP. `find`, `where`, `create`, `save`, `destroy`. ActiveModel validations y callbacks. |
131
142
  | `BugBunny::Routing::RouteSet` | DSL de rutas: `resources`, `namespace`, `member`, `collection`. |
132
- | `BugBunny::Observability` | Mixin de logging estructurado. `safe_log` nunca lanza excepciones. Filtra keys sensibles. |
143
+ | `BugBunny::Observability` | Mixin de logging estructurado. `safe_log` nunca lanza excepciones. Redacta credenciales en dos capas: por nombre de clave (`sensitive_key?`) y por contenido del valor (`redact_value` / `redact_structure`, para la credencial embebida en texto libre). |
133
144
  | `BugBunny::Middleware::Stack` | Builder de middlewares client-side (onion architecture tipo Faraday). |
134
145
  | BugBunny::Request | Value object del mensaje saliente con metadata AMQP completa. |
135
146
  | BugBunny::OTel | Helpers para emitir campos siguiendo las OTel semantic conventions for messaging. |
@@ -37,9 +37,13 @@ rescue BugBunny::Error => e
37
37
  end
38
38
  ```
39
39
 
40
- > ⚠️ **No loguear `raw_response` crudo.** Puede contener datos sensibles. Filtrar
41
- > claves (`password|pass|passwd|secret|token|api_key|auth`) `[FILTERED]` antes
42
- > de Sentry/logs. La gema lo entrega a propósito; sanitizar es del consumidor.
40
+ > ⚠️ **No loguear `raw_response` crudo.** Puede contener datos sensibles. Sanear
41
+ > antes de Sentry/logs con `Observability.redact_structure` (recorre la
42
+ > estructura: filtra por nombre de clave y también redacta la credencial
43
+ > embebida en el valor). **No replicar la lista de claves** — la canónica es
44
+ > `Observability::SENSITIVE_KEYS`; replicarla la desincroniza (y `pass` bare NO
45
+ > está en ella, a propósito, para no filtrar `passport_number`). La gema entrega
46
+ > el cuerpo crudo a propósito; sanitizar es del consumidor.
43
47
 
44
48
  Para consumir un envelope estructurado de dominio (ej. `{ error: { code,
45
49
  message, details } }`), parsealo en el boundary del servicio desde
@@ -29,6 +29,164 @@ RSpec.describe BugBunny::Observability do
29
29
  log_output.string.split("\n").last.to_s.sub(/\A.*?:\s*/, '')
30
30
  end
31
31
 
32
+ describe '.redact_value (contenido sensible en texto libre)' do
33
+ # Complementa a .sensitive_key?: esa mira el NOMBRE de la clave, esta el
34
+ # CONTENIDO. El caso que motiva la feature es el `message` de una excepción.
35
+ it 'redacta un Bearer token' do
36
+ redacted = BugBunny::Observability.redact_value(
37
+ 'undefined method for #<Faraday::Response headers={"Authorization"=>"Bearer eyJhbGciOiJIUzI1NiJ9.abc"}>'
38
+ )
39
+
40
+ expect(redacted).not_to include('eyJhbGciOiJIUzI1NiJ9.abc')
41
+ expect(redacted).to include('[FILTERED]')
42
+ end
43
+
44
+ it 'redacta un esquema Basic' do
45
+ redacted = BugBunny::Observability.redact_value('Authorization: Basic dXNlcjpwYXNzd29yZA==')
46
+
47
+ expect(redacted).not_to include('dXNlcjpwYXNzd29yZA')
48
+ expect(redacted).to include('[FILTERED]')
49
+ end
50
+
51
+ it 'redacta el valor cuando la key viaja DENTRO del texto, conservando el nombre' do
52
+ redacted = BugBunny::Observability.redact_value('connect failed (token=abc123, host=rabbit)')
53
+
54
+ expect(redacted).not_to include('abc123')
55
+ expect(redacted).to include('token=[FILTERED]')
56
+ # El resto del mensaje sobrevive: la redacción es quirúrgica, no destructiva.
57
+ expect(redacted).to include('host=rabbit')
58
+ end
59
+
60
+ it 'matchea la key más larga primero (authorization no se parte en auth)' do
61
+ redacted = BugBunny::Observability.redact_value('authorization: "Bearer-less-secret-value"')
62
+
63
+ expect(redacted).not_to include('Bearer-less-secret-value')
64
+ expect(redacted).to include('authorization=[FILTERED]')
65
+ end
66
+
67
+ it 'redacta credenciales de una URL conservando esquema y host' do
68
+ redacted = BugBunny::Observability.redact_value('amqp://guest:s3cr3t@rabbit:5672/vhost')
69
+
70
+ expect(redacted).not_to include('s3cr3t')
71
+ expect(redacted).to include('amqp://[FILTERED]@rabbit:5672/vhost')
72
+ end
73
+
74
+ it 'deja intacto un texto sin credenciales' do
75
+ expect(BugBunny::Observability.redact_value('timeout after 30s on queue acs.rpc'))
76
+ .to eq('timeout after 30s on queue acs.rpc')
77
+ end
78
+
79
+ it 'no confunde una key no sensible que contiene un substring parecido' do
80
+ expect(BugBunny::Observability.redact_value('passport_number=AB123'))
81
+ .to include('AB123')
82
+ end
83
+
84
+ # `_` es word-char: un `\b` antes de la key NO encuentra borde dentro de
85
+ # `access_token` ni de `accessToken`, y esas variantes se colaban en claro.
86
+ # Son exactamente las que sensitive_key? cubre a propósito con substring matching.
87
+ it 'redacta la variante con separador (access_token) conservando el nombre completo' do
88
+ redacted = BugBunny::Observability.redact_value('request failed access_token=eyJsecret.jwt')
89
+
90
+ expect(redacted).not_to include('eyJsecret.jwt')
91
+ expect(redacted).to include('access_token=[FILTERED]')
92
+ end
93
+
94
+ it 'redacta la variante con prefijo (user_password)' do
95
+ redacted = BugBunny::Observability.redact_value('invalid params user_password=hunter2')
96
+
97
+ expect(redacted).not_to include('hunter2')
98
+ expect(redacted).to include('user_password=[FILTERED]')
99
+ end
100
+
101
+ it 'redacta la variante camelCase (accessToken)' do
102
+ redacted = BugBunny::Observability.redact_value('boom accessToken=eyJsecret.jwt')
103
+
104
+ expect(redacted).not_to include('eyJsecret.jwt')
105
+ expect(redacted).to include('accessToken=[FILTERED]')
106
+ end
107
+
108
+ it 'no redacta una key no sensible con sufijo numérico (processing_session_count)' do
109
+ expect(BugBunny::Observability.redact_value('processing_session_count=5'))
110
+ .to eq('processing_session_count=5')
111
+ end
112
+ end
113
+
114
+ describe '.redact_structure (estructura antes de serializar)' do
115
+ it 'redacta por key interna y deja la forma intacta' do
116
+ redacted = BugBunny::Observability.redact_structure(
117
+ 'token' => 'abc123', 'host' => 'rabbit'
118
+ )
119
+
120
+ expect(redacted).to eq('token' => '[FILTERED]', 'host' => 'rabbit')
121
+ end
122
+
123
+ it 'recorre Hash anidado y Array' do
124
+ redacted = BugBunny::Observability.redact_structure(
125
+ 'nested' => { 'api_key' => 'xyz', 'n' => 1 },
126
+ 'list' => ['token=abc123', 'clean']
127
+ )
128
+
129
+ expect(redacted).to eq(
130
+ 'nested' => { 'api_key' => '[FILTERED]', 'n' => 1 },
131
+ 'list' => ['token=[FILTERED]', 'clean']
132
+ )
133
+ end
134
+
135
+ it 'no altera numéricos ni booleanos ni nil' do
136
+ expect(BugBunny::Observability.redact_structure('n' => 1, 'ok' => true, 'x' => nil))
137
+ .to eq('n' => 1, 'ok' => true, 'x' => nil)
138
+ end
139
+ end
140
+
141
+ describe '#safe_log — redacción por contenido' do
142
+ # El caso real: el nombre de la clave NO es sensible (`reason`), así que el
143
+ # filtro por-clave lo deja pasar; la credencial va en el valor.
144
+ it 'filtra una credencial embebida en el valor de una key NO sensible' do
145
+ host.safe_log(:error, 'unhandled_exception',
146
+ reason: 'NoMethodError on headers {"Authorization"=>"Bearer eyJsupersecret.jwt"}')
147
+
148
+ expect(last_log_line).not_to include('eyJsupersecret.jwt')
149
+ expect(last_log_line).to include('[FILTERED]')
150
+ end
151
+
152
+ it 'filtra dentro de un Hash serializado (las keys internas no pasan por sensitive_key?)' do
153
+ host.safe_log(:error, 'unhandled_exception', details: { 'token' => 'abc123xyz' })
154
+
155
+ expect(last_log_line).not_to include('abc123xyz')
156
+ expect(last_log_line).to include('[FILTERED]')
157
+ end
158
+
159
+ # La redacción no puede costar la estructura: quien consume el log parsea este campo
160
+ # como JSON, y un objeto roto le hace perder TODOS los pares, no solo el redactado.
161
+ it 'mantiene el campo Hash como JSON parseable después de redactar' do
162
+ host.safe_log(:error, 'unhandled_exception',
163
+ details: { 'token' => 'abc123xyz', 'host' => 'rabbit',
164
+ 'nested' => { 'api_key' => 'xyz789', 'n' => 1 } })
165
+
166
+ field = last_log_line[/details=(\S+)/, 1]
167
+
168
+ expect { JSON.parse(field) }.not_to raise_error
169
+ expect(JSON.parse(field)).to eq(
170
+ 'token' => '[FILTERED]', 'host' => 'rabbit',
171
+ 'nested' => { 'api_key' => '[FILTERED]', 'n' => 1 }
172
+ )
173
+ end
174
+
175
+ it 'no altera un valor numérico' do
176
+ host.safe_log(:info, 'done', duration_s: 1.5, status: 200)
177
+
178
+ expect(last_log_line).to include('duration_s=1.5', 'status=200')
179
+ end
180
+
181
+ it 'preserva el resto de la línea (component, event y campos limpios)' do
182
+ host.safe_log(:error, 'request_error', kind: 'unavailable', reason: 'ACS down')
183
+
184
+ line = last_log_line
185
+ expect(line).to include('event=request_error', 'kind=unavailable')
186
+ expect(line).to include('reason="ACS down"')
187
+ end
188
+ end
189
+
32
190
  describe '.sensitive_key? (módulo público)' do
33
191
  subject(:sensitive?) { BugBunny::Observability.method(:sensitive_key?) }
34
192
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bug_bunny
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.0
4
+ version: 5.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - gabix
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-22 00:00:00.000000000 Z
11
+ date: 2026-07-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny
@@ -314,7 +314,7 @@ metadata:
314
314
  homepage_uri: https://github.com/gedera/bug_bunny
315
315
  source_code_uri: https://github.com/gedera/bug_bunny
316
316
  changelog_uri: https://github.com/gedera/bug_bunny/blob/main/CHANGELOG.md
317
- documentation_uri: https://github.com/gedera/bug_bunny/blob/v5.1.0/skill
317
+ documentation_uri: https://github.com/gedera/bug_bunny/blob/v5.1.1/skill
318
318
  post_install_message:
319
319
  rdoc_options: []
320
320
  require_paths: