bug_bunny 5.0.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: e1076566b6a3b8c25f6f00fc624c92681e8db881ffc3b8d6640da3bb7158333b
4
- data.tar.gz: 2510fe871ec6f32804dba3faa7f1110408656e8cff30d2c0d537c7d0b30e1686
3
+ metadata.gz: dd173a6f940a79457235d63629891426cba1f94a8381c19a219e38f275fe96dd
4
+ data.tar.gz: 78f927d9ed2ef4d04bf5b3059a3b3d54b93974ee11a8d7235ade9ae82095a51b
5
5
  SHA512:
6
- metadata.gz: 13525491f89b99f1e1be12663f64990109e17b9da22e034ecea463a3fda5ff68abfb3ab10fdf274d43df5460b3c74eabd14e1699e2af7f8c38e608a78e22cb69
7
- data.tar.gz: 9f31c6ed9fbfff650e2770f970ab94e49979f8eec8b254c212817ca410106b4f4293cc9b6cde87f382145a2483f639aabe0998134c5656b7ec6b053aebcb7c73
6
+ metadata.gz: 188ad45954c08cf19dda7625236fdc8da934676de77a6af7136547893f2fc8ae0ad782aa890782dbd59e16b39b90d8fa8dec3f9b1c9a5c3b4b4fd22db5c77f8f
7
+ data.tar.gz: 87bf5385a97fea06f86fccb396ade8ce2602ec831181e4a2ba7893711a784eccb4d922ca4303137f0e25f7a625d88d015e61858f4f48209fa89624ae02a92cea
data/AGENTS.md CHANGED
@@ -17,7 +17,7 @@ proyecto y convenciones de equipo, ver `CLAUDE.md`. Para la entrada humana, ver
17
17
  | Errores | `docs/errors/errors.md` | completo (§a/b/d estructura + §c política inferida) | jerarquía de excepciones públicas, mapeo `status→excepción`, shape del payload, política retry |
18
18
  | Configuración | `docs/config/configuracion.md` | completo (estructura + enrich §f/g/h) | opciones de `Configuration`, defaults, failure-mode/threading, inyecciones del `Railtie`, ENV sugeridas |
19
19
  | Dependencias consumidas | `docs/consumed/rabbitmq.md` | completo (estructura + enrich §c/e) | qué consume del broker RabbitMQ vía `bunny`, mapeo error-Bunny→excepción, retry/degradación |
20
- | Test | `docs/test/test.md` | completo (estructura + enrich §e-h) | suites RSpec/Minitest, CI, contract-assessment, link a incidentes (#49/#52) |
20
+ | Test | `docs/test/testing.md` | completo (estructura + enrich §e-h) | suites RSpec/Minitest, CI, contract-assessment, link a incidentes (#49/#52) |
21
21
  | Release | `docs/release/release.md` | completo | patrón gema-tag, versionado, publish a RubyGems |
22
22
  | Datos | — | n/a | gema sin DB |
23
23
  | Operaciones / Interfaz / Topología | — | dev-structure F2 no implementado | contrato embebido en `README.md`/`skill/SKILL.md` (interim RFC-008 §2) |
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
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
+
12
+ ## [5.1.0] - 2026-07-22
13
+
14
+ ### Nuevas funcionalidades
15
+ - **Hook `extra_top_level_params` en `Resource#save`:** una subclase puede sobrescribirlo (default `{}`) para mergear datos TOP-LEVEL hermanos del recurso en el body del `save` —fuera del wrapper `param_key`— sin que sean atributos del recurso ni se persistan en el modelo. Caso de uso: un dato de transporte (p. ej. una credencial) que el servidor lee como `params[:x]`. No-breaking (el default `{}` deja el body idéntico para los recursos que no lo sobrescriben). — @Pslp
16
+
17
+ ### Documentación
18
+ - `docs/test/` incrementado (spec `extra_top_level_params`, conteos 16 unit / 23 total) y renombrado a `docs/test/testing.md` (filename canónico RFC-013). Hook documentado en el contrato interino `skill/references/resource.md`. — @Pslp
19
+
3
20
  ## [5.0.0] - 2026-07-01
4
21
 
5
22
  > **BREAKING.** Se elimina la constante pública `BugBunny::SecurityError`. Aunque la excepción nunca se *levantaba*, su **ausencia rompe en evaluación**: un `rescue BugBunny::SecurityError` en un consumidor resuelve la constante cuando *cualquier* excepción entra a ese bloque → `NameError` que enmascara la excepción original. Por eso es breaking real, no inerte.
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
 
@@ -1,14 +1,14 @@
1
1
  # Test — bug_bunny
2
2
 
3
3
  > meta: artefacto test · RFC-013 · generado `arch-structure` (§a-§d) +
4
- > `arch-enrich` (§e-§h) · anclado a `24ea397`, `Rakefile`, `bug_bunny.gemspec`,
4
+ > `arch-enrich` (§e-§h) · anclado a `7bf1da7`, `Rakefile`, `bug_bunny.gemspec`,
5
5
  > `spec/spec_helper.rb`, `spec/support/integration_helper.rb`,
6
- > `.github/workflows/main.yml`, `CHANGELOG.md` · fecha 2026-06-30 · cobertura:
6
+ > `.github/workflows/main.yml`, `CHANGELOG.md` · fecha 2026-07-22 · cobertura:
7
7
  > §a-§d (estructura) + §e-§h (enrich, anclado a specs/CHANGELOG) completas.
8
8
 
9
9
  ## 1. Resumen
10
10
 
11
- Suite principal **RSpec** (`spec/`, 22 specs: 15 unit + 7 integration). Tarea
11
+ Suite principal **RSpec** (`spec/`, 23 specs: 16 unit + 7 integration). Tarea
12
12
  `:test` legacy de **Minitest** (`test/`, 2 archivos) fuera del default y del CI.
13
13
  CI corre `bundle exec rake` (= `:spec`) en Ruby 3.4.4. Sin coverage tool
14
14
  configurado.
@@ -19,7 +19,7 @@ configurado.
19
19
 
20
20
  | framework | dir | nivel | nº | propósito |
21
21
  |---|---|---|---|---|
22
- | **RSpec** `~> 3.0` | `spec/unit/` | unit | 15 | client/session pool, configuration, consumer, producer, controller, raise_error, remote_error, request, route, observability, otel, resource, middleware |
22
+ | **RSpec** `~> 3.0` | `spec/unit/` | unit | 16 | client/session pool, configuration, consumer, producer, controller, raise_error, remote_error, request, route, observability, otel, resource, middleware, `extra_top_level_params` (hook de params hermanos en `Resource#save`) |
23
23
  | **RSpec** | `spec/integration/` | integration | 7 | client, consumer_middleware, controller, error_handling, infrastructure, publisher_confirms, resource — **requieren RabbitMQ real** (usan `BugBunny.create_connection` + pool) |
24
24
  | **Minitest** `~> 5.0` (+ `mocha`, `minitest-reporters`) | `test/integration/` | integration (legacy) | 2 | `manual_client_test.rb`, `infrastructure_test.rb` — tarea `:test`, **no** en default ni CI |
25
25
 
@@ -60,7 +60,7 @@ umbral de coverage declarado.
60
60
  - **Integration specs no corren en CI:** `main.yml` no declara servicio RabbitMQ;
61
61
  las 7 integration specs **se skipean** vía `rabbitmq_available?`
62
62
  (`spec/support/integration_helper.rb:14`, ver `publisher_confirms_spec.rb:10`).
63
- En CI solo se ejercitan las **15 unit specs** → el contrato AMQP real (publish/
63
+ En CI solo se ejercitan las **16 unit specs** → el contrato AMQP real (publish/
64
64
  consume/confirms contra broker) **no se valida en pipeline**, solo localmente
65
65
  con broker. Gap relevante.
66
66
  - **Sin medición de cobertura:** no hay SimpleCov ni umbral → la cobertura no está
@@ -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(' ')
@@ -440,7 +440,7 @@ module BugBunny
440
440
  rk = calculate_routing_key(id)
441
441
  flat_payload = changes_to_send
442
442
  key = self.class.param_key
443
- wrapped_payload = { key => flat_payload }
443
+ wrapped_payload = { key => flat_payload }.merge(extra_top_level_params)
444
444
 
445
445
  path = is_new ? self.class.resource_name : "#{self.class.resource_name}/#{id}"
446
446
  method = is_new ? :post : :put
@@ -467,6 +467,20 @@ module BugBunny
467
467
  false
468
468
  end
469
469
 
470
+ # Params top-level extra a incluir en el body del `save`, JUNTO al recurso
471
+ # envuelto en `param_key` (no dentro de él). Default: ninguno.
472
+ #
473
+ # Las subclases lo sobrescriben para enviar datos HERMANOS del recurso que no
474
+ # forman parte de sus atributos ni deben persistir en el modelo — p. ej. una
475
+ # credencial de transporte que el servidor lee como `params[:x]` y que no se
476
+ # serializa dentro del recurso. Se mergea sobre `{ param_key => attrs }`, así
477
+ # que no debe usar `param_key` como clave (colisionaría con el recurso).
478
+ #
479
+ # @return [Hash]
480
+ def extra_top_level_params
481
+ {}
482
+ end
483
+
470
484
  # Elimina el recurso del servidor remoto (DELETE).
471
485
  #
472
486
  # @return [Boolean]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = '5.0.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
@@ -65,6 +65,24 @@ order.errors # ActiveModel::Errors
65
65
  - **Existente** (`persisted? == true`): Envía PUT solo con atributos cambiados (`changes_to_send`).
66
66
  - Captura `BugBunny::UnprocessableEntity` (422) y carga `resource.errors`. Retorna `false`.
67
67
 
68
+ ### Params top-level hermanos: `extra_top_level_params`
69
+
70
+ Por default `save` envía el body `{ param_key => attrs }`. Una subclase puede sobrescribir `extra_top_level_params` (default `{}`) para mergear datos **top-level hermanos** del recurso —fuera del wrapper `param_key`— sin que sean atributos del recurso ni se persistan en el modelo. Caso de uso: un dato de transporte (p. ej. una credencial) que el servidor lee como `params[:x]`.
71
+
72
+ ```ruby
73
+ class Service < BugBunny::Resource
74
+ self.param_key = 'service'
75
+ attr_accessor :registry_auth # dato transiente, NO atributo del recurso
76
+
77
+ def extra_top_level_params
78
+ registry_auth ? { registry_auth: registry_auth } : {}
79
+ end
80
+ end
81
+ # save envía: { 'service' => { ...attrs }, registry_auth: '...' }
82
+ ```
83
+
84
+ No debe usar `param_key` como clave (colisionaría con el wrapper del recurso).
85
+
68
86
  ## Contexto Dinámico (.with)
69
87
 
70
88
  ### Forma de bloque (recomendada)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Hook `extra_top_level_params`: permite a una subclase mandar params HERMANOS del
6
+ # recurso en el body del `save` (top-level, junto al `param_key`), sin que formen
7
+ # parte de los atributos del recurso ni se persistan en el modelo. Caso de uso:
8
+ # una credencial de transporte que el servidor lee como `params[:x]`.
9
+ module ExtraTopLevelParamsSpec
10
+ # Recurso sin override: usa el hook por default (no manda extras).
11
+ class Plain < BugBunny::Resource
12
+ self.resource_name = 'widget'
13
+ self.param_key = 'widget'
14
+ self.exchange = 'etlp_ex'
15
+ self.exchange_type = 'direct'
16
+ attribute :name, :string
17
+ end
18
+
19
+ # Recurso que sobrescribe el hook para mandar un sibling transiente (no atributo).
20
+ class WithSibling < BugBunny::Resource
21
+ self.resource_name = 'service'
22
+ self.param_key = 'service'
23
+ self.exchange = 'etlp_ex'
24
+ self.exchange_type = 'direct'
25
+ attribute :name, :string
26
+
27
+ attr_accessor :registry_auth
28
+
29
+ def extra_top_level_params
30
+ registry_auth ? { registry_auth: registry_auth } : {}
31
+ end
32
+ end
33
+ end
34
+
35
+ RSpec.describe 'BugBunny::Resource#extra_top_level_params' do
36
+ let(:client) { instance_double(BugBunny::Client) }
37
+
38
+ before do
39
+ allow(client).to receive(:request) do |_path, **args|
40
+ @sent_body = args[:body]
41
+ { 'body' => {} }
42
+ end
43
+ end
44
+
45
+ def save_with_stub(resource)
46
+ allow(resource).to receive(:bug_bunny_client).and_return(client)
47
+ resource.save
48
+ end
49
+
50
+ it 'default: el body solo envuelve el recurso en param_key (sin extras)' do
51
+ save_with_stub(ExtraTopLevelParamsSpec::Plain.new(name: 'w'))
52
+
53
+ expect(@sent_body.keys).to eq(['widget'])
54
+ end
55
+
56
+ it 'override: agrega el sibling top-level JUNTO al recurso envuelto, no adentro' do
57
+ resource = ExtraTopLevelParamsSpec::WithSibling.new(name: 's').tap { |r| r.registry_auth = 'b64cred' }
58
+
59
+ save_with_stub(resource)
60
+
61
+ expect(@sent_body[:registry_auth]).to eq('b64cred')
62
+ expect(@sent_body['service']).not_to have_key('registry_auth')
63
+ end
64
+
65
+ it 'override sin valor: no agrega el sibling (hook devuelve {})' do
66
+ save_with_stub(ExtraTopLevelParamsSpec::WithSibling.new(name: 's'))
67
+
68
+ expect(@sent_body.keys).to eq(['service'])
69
+ end
70
+ end
@@ -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.0.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-01 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
@@ -240,7 +240,7 @@ files:
240
240
  - docs/errors/errors.md
241
241
  - docs/glossary/glossary.md
242
242
  - docs/release/release.md
243
- - docs/test/test.md
243
+ - docs/test/testing.md
244
244
  - initializer_example.rb
245
245
  - lib/bug_bunny.rb
246
246
  - lib/bug_bunny/client.rb
@@ -294,6 +294,7 @@ files:
294
294
  - spec/unit/consumer_middleware_spec.rb
295
295
  - spec/unit/consumer_spec.rb
296
296
  - spec/unit/controller_after_action_spec.rb
297
+ - spec/unit/extra_top_level_params_spec.rb
297
298
  - spec/unit/observability_spec.rb
298
299
  - spec/unit/otel_spec.rb
299
300
  - spec/unit/producer_spec.rb
@@ -313,7 +314,7 @@ metadata:
313
314
  homepage_uri: https://github.com/gedera/bug_bunny
314
315
  source_code_uri: https://github.com/gedera/bug_bunny
315
316
  changelog_uri: https://github.com/gedera/bug_bunny/blob/main/CHANGELOG.md
316
- documentation_uri: https://github.com/gedera/bug_bunny/blob/v5.0.0/skill
317
+ documentation_uri: https://github.com/gedera/bug_bunny/blob/v5.1.1/skill
317
318
  post_install_message:
318
319
  rdoc_options: []
319
320
  require_paths: