basecradle 0.10.2 → 0.10.3

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: 4e662740bb7ecbde3769c3430a269cbe32a0c507a4244626688af1ca13ce3ce4
4
- data.tar.gz: 64820f7409159b2cfd1b063a53c96858302bfc939e4ff2df17b6ffcaf3b494e5
3
+ metadata.gz: b1fc584abdde2edfd9233ea937bf054ba6fb672708b6c815014d46a1dc4d59c2
4
+ data.tar.gz: bc53a3e49ad9eb40fd21e5fb2fe424d054738359a30eb2c137a26c8a5f634d27
5
5
  SHA512:
6
- metadata.gz: 6c7a007a0c7eef4fe1bb8d4d19fc108c361fc5e5d4a618d5d61600a0cdd55fbb6b97ecc5ea5c07ed8ac6488840b7cc33133c59a6160feab2a04222da73683eeb
7
- data.tar.gz: 7059369ae648787b45a97afe3b12ba721474629078031ac036461732cc7dfc9d13e764b916f48a7e72fa96b248bac4c53c1ee3ed686f2abc98d13ed1c54a6c28
6
+ metadata.gz: c603f01c320e465db82dc376ad78cfca4d52c63db107580be7366d2a2c3b79f99e1d5b3f356917bd1526b9f63bb294747cc218a1e7cf71e8fc22caa41ccc4b34
7
+ data.tar.gz: 401d5b9b58f5dc726fb3bc457180954b582e077b8b0aa224c0f92513a39c4122bac16ea56dfff4746e44e2b252cc88ab8a6cf12b178d989ee1eed5b2caf1d02a
data/CHANGELOG.md CHANGED
@@ -9,6 +9,87 @@ section**. The newest heading is always the version `lib/basecradle/version.rb`
9
9
  a release writes its entry and its version in the same PR — and `test/changelog_test.rb`
10
10
  fails CI if the two ever disagree.
11
11
 
12
+ ## [0.10.3] - 2026-09-30
13
+
14
+ ### Security
15
+
16
+ - **`Marshal.dump` and `to_yaml` no longer emit a `Client`'s token**
17
+ ([#205](https://github.com/basecradle/basecradle-ruby/issues/205)). 0.10.1 shut the
18
+ JSON door on a client; `Marshal` and Psych walk instance variables directly, consult no
19
+ `as_json`, and need no ActiveSupport to do it — so both still wrote the raw `bc_uat_`
20
+ credential into their output. They are the worse pair, because they are what puts a
21
+ token *at rest*: ActiveSupport's cache stores marshal what you write, so
22
+ `Rails.cache.write("bc", bc)` landed it in Redis, memcached or a file store; a
23
+ Marshal-backed session landed it in the session; Delayed::Job YAMLs its handler into
24
+ the database. (Queue backends that serialize arguments as **JSON** — ActiveJob and
25
+ Sidekiq among them — go through `to_json`, and so were already closed in 0.10.1.)
26
+ `Marshal.dump(bc)`, `bc.to_yaml`,
27
+ `YAML.dump(conn: bc)` and the `Marshal.load(Marshal.dump(bc))` deep-copy idiom now all
28
+ raise `BaseCradle::NotSerializableError`, the same typed error with the same message
29
+ the JSON door already raised. Long-standing and latent; no token is known to have been
30
+ emitted.
31
+ - **The collection door was the wider one, not the narrower one.** Through JSON, a
32
+ collection never reached the token — ActiveSupport's `Enumerable#as_json` shadows
33
+ `Object#as_json`, so `bc.messages.to_json` serialized fetched records, never the client
34
+ held in an ivar. `Marshal` and Psych have no such shadow, so
35
+ **`Marshal.dump(bc.messages)` reached the credential where `bc.messages.to_json` never
36
+ did** — and a query is the likelier of the two to be handed to a cache. Every lazy
37
+ collection (`bc.timelines`, `bc.messages`, `timeline.tasks`, any `.filter(...)`, the
38
+ `Paginator` behind them) now refuses through all four doors; the refusals are on the
39
+ shared `NotSerializable` mixin, so a resource added later is covered by construction.
40
+ **`.to_a` is only half the remedy for a dumper** — the array it hands back is full of
41
+ models that each hold the client, so `Marshal.dump(bc.messages.to_a)` lands back on the
42
+ same error. Dump `bc.messages.to_a.map(&:to_h)`, which the refusal now says.
43
+ - **Anything holding a client refuses too — models included, and that is the fix.** Both
44
+ walkers recurse, so `Marshal.dump(message)` and `message.to_yaml` reached the client's
45
+ token where `message.to_json` (which serializes the wire record) never did. They now
46
+ raise, naming `BaseCradle::Client` as what was reached. **If you cache or enqueue
47
+ models, cache the record instead** — `message.to_h` is the wire `Hash`, holds no
48
+ client, and marshals exactly as it always did; rebuild with
49
+ `BaseCradle::Message.new(record, client: bc)` when you need the verbs back. Nothing
50
+ else changed: `to_json`, `as_json`, `to_h`, `inspect`, every field reader and
51
+ `dup`/`clone` (which do not go through `Marshal`) are untouched. This matches the
52
+ Python SDK, whose `__reduce__` refusal says the same thing — *“Nothing holding a client
53
+ can be serialized either … copy the record's data instead.”*
54
+
55
+ ### Known limitation
56
+
57
+ - **YAML reaches the `Enumerator` escape that 0.10.1 documented for JSON, on plain
58
+ Ruby.** Every refusal here is on this SDK's own objects; an `Enumerator` you build from
59
+ a collection (`bc.messages.each`, `bc.messages.lazy`) is a plain Ruby object this SDK
60
+ does not own and may not patch. Psych iterates one to dump it — with **no
61
+ ActiveSupport required**, where the `to_json` version is inert without it — so
62
+ `bc.messages.each.to_yaml` fires the page-by-page GET loop and *then* raises
63
+ `NotSerializableError` on the first record, which holds the client: the requests are
64
+ spent and no document comes out. `Marshal` is the exception — Ruby itself refuses to
65
+ dump an `Enumerator` at all. No token is exposed through any of them. Call `.first(n)`
66
+ or `.to_a` before a renderer, and `.map(&:to_h)` too before a dumper.
67
+ - **Four doors is every hook Ruby gives a library**, not every way bytes can be made. A
68
+ serializer that reads instance variables directly rather than through `as_json`,
69
+ `to_json`, `marshal_dump` or `encode_with` is outside what this SDK can intercept.
70
+ Keep a client out of one.
71
+
72
+ ### Migrating
73
+
74
+ Nothing to change unless you `Marshal.dump` or `to_yaml` something that holds a client.
75
+ If you do:
76
+
77
+ - **Caching or enqueuing a model** — `Rails.cache.write(key, message)`,
78
+ `Rails.cache.fetch(key) { bc.messages.get(id) }` — now raises
79
+ `BaseCradle::NotSerializableError` where it used to write your token to the store.
80
+ Cache `message.to_h`, the wire `Hash`, and rebuild with
81
+ `BaseCradle::Message.new(record, client: bc)` if you need the verbs back.
82
+ - **Caching or enqueuing a client or a collection** — serialize nothing; build a client
83
+ where you need one (`BaseCradle::Client.new(token)`), moving the token only through
84
+ whatever you already trust with secrets. For a collection, `.to_a` / `.first(n)` is
85
+ enough for JSON but **not** for a dumper — use `bc.messages.to_a.map(&:to_h)`, since
86
+ the models in that array each hold the client.
87
+ - **Deep-copying with `Marshal.load(Marshal.dump(x))`** — refuses at the dump for the
88
+ same objects. `dup` and `clone` do not go through `Marshal` and are unchanged.
89
+
90
+ If any of these ran in production against a real store, treat the token as disclosed and
91
+ rotate it: `bc.sessions` lists your credentials and `session.revoke` retires one.
92
+
12
93
  ## [0.10.2] - 2026-09-30
13
94
 
14
95
  ### Security
@@ -484,6 +565,7 @@ the Python SDK's behavior in idiomatic Ruby. Zero runtime dependencies.
484
565
  - **Quality bars** — a README-as-tested-doc harness (every example runs against a mocked
485
566
  API) and a spec drift-guard (CI fails if the live API grows beyond the SDK).
486
567
 
568
+ [0.10.3]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.3
487
569
  [0.10.2]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.2
488
570
  [0.10.1]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.1
489
571
  [0.10.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.0
data/README.md CHANGED
@@ -375,10 +375,15 @@ something Rails renders comes out right too. Three caveats worth knowing:
375
375
  ## What does not serialize: clients and collections
376
376
 
377
377
  Two things in this SDK are *not* records, and serializing either raises
378
- `BaseCradle::NotSerializableError` rather than emitting something:
378
+ `BaseCradle::NotSerializableError` rather than emitting something — and under `Marshal`
379
+ and YAML, so does anything *holding* one, models included (see below). Four doors refuse:
380
+ `to_json`, `as_json`, `Marshal.dump` and `to_yaml` — every hook Ruby gives a library to
381
+ intercept. A serializer that reads instance variables directly instead of through those
382
+ hooks is outside what any library can stop, so keep a client out of one.
379
383
 
380
384
  ```ruby
381
385
  require "basecradle"
386
+ require "yaml"
382
387
 
383
388
  bc = BaseCradle::Client.new
384
389
 
@@ -394,6 +399,18 @@ rescue BaseCradle::NotSerializableError => e
394
399
  puts e.message.include?(".to_a") # => true
395
400
  end
396
401
 
402
+ begin
403
+ Marshal.dump(bc) # and Rails.cache.write("bc", bc), a job argument
404
+ rescue BaseCradle::NotSerializableError => e
405
+ puts e.message.include?("bc_uat_") # => true
406
+ end
407
+
408
+ begin
409
+ bc.messages.to_yaml # YAML walks the ivars, and reaches the client
410
+ rescue BaseCradle::NotSerializableError => e
411
+ puts e.message.include?(".to_a") # => true
412
+ end
413
+
397
414
  puts bc.messages.first(20).to_json # serialize the page you actually asked for
398
415
  ```
399
416
 
@@ -410,10 +427,50 @@ walk — but a client's instance variables are eight collections, so serializing
410
427
  ran those loops too, on its way to the credential.) Call `.to_a` or `.first(n)` yourself
411
428
  and serialize that, so how much you fetch is a visible act in your code.
412
429
 
413
- Both refusals happen before any HTTP — but they are on the SDK's own objects. An
430
+ **`Marshal` and YAML are the same refusal for a different reason.** Both walk instance
431
+ variables directly, so neither needed ActiveSupport to reach the token — and neither is
432
+ shadowed the way `Enumerable#as_json` shadowed the ivar walk, so `Marshal.dump(bc.messages)`
433
+ reached the credential where `bc.messages.to_json` never did. They are also where a leaked
434
+ token stops being a log line and becomes a token *at rest*: ActiveSupport's cache stores
435
+ marshal what you write, so `Rails.cache.write("bc", bc)` landed it in Redis, memcached or
436
+ a file; a Marshal-backed session landed it in the session; Delayed::Job YAMLs its handler
437
+ into the database. `Marshal.load(Marshal.dump(x))`, the deep-copy idiom, refuses at the
438
+ dump. (Queue backends that serialize arguments as **JSON** — ActiveJob and Sidekiq among
439
+ them — go through `to_json`, and so were already closed in 0.10.1.)
440
+
441
+ **Anything *holding* a client refuses too, models included** — both walkers recurse, so
442
+ `Marshal.dump(message)` reaches the client and raises naming `BaseCradle::Client`. That is
443
+ the fix, not a limitation: before, it emitted the token. It is also why **`.to_a` is only
444
+ half the remedy for a dumper**: the array it hands back is full of models that each hold
445
+ the client, so `Marshal.dump(bc.messages.to_a)` lands straight back on the same error.
446
+ Dump the wire records — which is what you wanted in a cache anyway:
447
+
448
+ ```ruby
449
+ require "basecradle"
450
+
451
+ bc = BaseCradle::Client.new
452
+ message = bc.messages.first
453
+
454
+ record = message.to_h # the wire Hash, no client in it
455
+ puts Marshal.load(Marshal.dump(record)) == record # => true
456
+
457
+ page = bc.messages.first(20).map(&:to_h) # a whole page, no client in it
458
+ puts Marshal.load(Marshal.dump(page)) == page # => true
459
+ ```
460
+
461
+ Rebuild a model from a cached record with `BaseCradle::Message.new(record, client: bc)`
462
+ when you need its verbs back.
463
+
464
+ Every refusal happens before any HTTP — but they are on the SDK's own objects. An
414
465
  `Enumerator` you built from one is a plain Ruby object this SDK does not own, so
415
466
  `render json: { recent: bc.messages.lazy }` and `bc.messages.each.to_json` still page the
416
- whole resource. Call `.first(n)` or `.to_a` before handing a query to a renderer.
467
+ whole resource. **YAML reaches that escape on plain Ruby**, where the `to_json` version
468
+ needs ActiveSupport: Psych iterates an `Enumerator` to dump it, so
469
+ `bc.messages.each.to_yaml` fires the page-by-page GET loop and *then* raises
470
+ `NotSerializableError` on the first record (which holds the client) — the requests are
471
+ spent, and no document comes out. (`Marshal` is the exception: Ruby itself refuses to
472
+ dump an `Enumerator` at all.) No token is exposed through any of them. Call `.first(n)`
473
+ or `.to_a` before handing a query to a renderer, and `.map(&:to_h)` too before a dumper.
417
474
 
418
475
  `bc.inspect` and `"#{bc}"` are redacted for the same reason — Ruby's default `inspect`
419
476
  dumps every instance variable, which would print the token into every exception message
@@ -243,14 +243,28 @@ module BaseCradle
243
243
 
244
244
  private
245
245
 
246
- # Why +to_json+ / +as_json+ refuse (see BaseCradle::NotSerializable). A client is not
247
- # a record, and the thing it would emit is a live credential.
246
+ # Why every serializer refuses — +to_json+ / +as_json+, +Marshal.dump+, +to_yaml+
247
+ # (see BaseCradle::NotSerializable). A client is not a record, and the thing it would
248
+ # emit is a live credential.
249
+ #
250
+ # The last sentence is for the caller who never named a client: Marshal and Psych
251
+ # recurse, so dumping a model or a resource reaches the client it holds and lands
252
+ # here. Telling that caller only about clients would be telling them about an object
253
+ # they did not pass — the Python SDK's +__reduce__+ is worded for the same reason
254
+ # ("copying a resource or a record reaches this too — copy the record's data").
255
+ #
256
+ # And it has to name +to_h+, because the first remedy does not hold through those two
257
+ # doors: a model serializes as its record through JSON, but <tt>Marshal.dump(bc.me)</tt>
258
+ # walks to the client again. Advice that fails through the door the reader just used
259
+ # is worse than none.
248
260
  def serialization_refusal
249
261
  "#{self.class} is a connection holding your bc_uat_ token, not a record. " \
250
- "Serializing it writes the raw credential into whatever you were rendering or " \
251
- "logging, and a token in a log is a token to rotate. Serialize the record you " \
252
- "meant instead (bc.me, a timeline, a message), or bc.base_url to name the " \
253
- "connection itself."
262
+ "Serializing it writes the raw credential into whatever you were rendering, " \
263
+ "logging or storing, and a token in a log or a cache is a token to rotate. " \
264
+ "Serialize the record you meant instead (bc.me, a timeline, a message), or " \
265
+ "bc.base_url to name the connection itself. Marshal and YAML reach a client " \
266
+ "through anything holding one, models included, so dump the wire record rather " \
267
+ "than the object: model.to_h holds no client."
254
268
  end
255
269
 
256
270
  # Record what the mint response said about the credential just issued. Private: only
@@ -30,10 +30,15 @@ module BaseCradle
30
30
  # this response form). The SDK raises rather than return an ambiguous nil.
31
31
  class MissingFieldError < Error; end
32
32
 
33
- # A Client or a collection resource was serialized (+to_json+ / +as_json+). Neither is
34
- # a record: a Client holds your bearer token, and a collection is a lazy query whose
35
- # serialization would page the whole API from inside your renderer. The SDK refuses
36
- # loudly rather than emit a credential or a heap address — see BaseCradle::NotSerializable.
33
+ # A Client or a collection resource was serialized — +to_json+ / +as_json+,
34
+ # +Marshal.dump+, or +to_yaml+ / +YAML.dump+. Neither is a record: a Client holds your
35
+ # bearer token, and a collection is a lazy query that holds the client and would page
36
+ # the whole API from inside your renderer. The SDK refuses loudly rather than emit a
37
+ # credential or a heap address — see BaseCradle::NotSerializable.
38
+ #
39
+ # It is also how a caller who serialized *neither* gets here: +Marshal+ and Psych
40
+ # recurse, so dumping a model reaches the client it holds and raises this, naming
41
+ # BaseCradle::Client. Dump the record's own data, +model.to_h+, which holds no client.
37
42
  class NotSerializableError < Error; end
38
43
 
39
44
  # The request never got an API response (DNS failure, refused connection, timeout).
@@ -18,6 +18,17 @@ module BaseCradle
18
18
  # Without ActiveSupport the same calls were merely useless: +"#<BaseCradle::Client:0x…>"+,
19
19
  # a heap address that differs every run.
20
20
  #
21
+ # +Marshal+ and Psych are that same act through two doors ActiveSupport never touched.
22
+ # Both walk instance variables directly and neither consults +as_json+, so closing the
23
+ # JSON door left them open, needing no ActiveSupport to be reachable. They are also the
24
+ # worse pair, because they are what puts a credential *at rest*: ActiveSupport's cache
25
+ # stores marshal what you write, so <tt>Rails.cache.write("bc", bc)</tt> landed the
26
+ # token in Redis, memcached or a file; a Marshal-backed session landed it in the
27
+ # session; Delayed::Job YAMLs its handler into the database. And nothing shadows the
28
+ # ivar walk the way +Enumerable#as_json+ shadows +Object#as_json+ — so
29
+ # <tt>Marshal.dump(bc.messages)</tt> reached the token where <tt>bc.messages.to_json</tt>
30
+ # never did. Both doors now raise, on the same mixin, so every includer is covered.
31
+ #
21
32
  # Includers supply +serialization_refusal+; the default below is the honest fallback
22
33
  # for one that does not, so the failure is still a rescuable BaseCradle::Error rather
23
34
  # than a NoMethodError out of a renderer.
@@ -34,6 +45,24 @@ module BaseCradle
34
45
  raise NotSerializableError, serialization_refusal
35
46
  end
36
47
 
48
+ # +Marshal.dump+, and so every store built on it: ActiveSupport's cache backends, a
49
+ # Marshal-backed session, and the <tt>Marshal.load(Marshal.dump(x))</tt> deep-copy
50
+ # idiom. Marshal reaches for this hook before it walks ivars, so defining it is what
51
+ # stops the walk.
52
+ #
53
+ # Deliberately unpaired with a +marshal_load+: the pair only matters to something
54
+ # being loaded, and nothing can be loaded because nothing is ever dumped.
55
+ def marshal_dump
56
+ raise NotSerializableError, serialization_refusal
57
+ end
58
+
59
+ # Psych's hook — +to_yaml+, +YAML.dump+, and anything either one nests. Psych prefers
60
+ # +encode_with+ over its own ivar walk for any object that defines it. The coder is
61
+ # unused: it is the thing we decline to fill.
62
+ def encode_with(_coder)
63
+ raise NotSerializableError, serialization_refusal
64
+ end
65
+
37
66
  private
38
67
 
39
68
  def serialization_refusal
@@ -59,16 +88,31 @@ module BaseCradle
59
88
 
60
89
  private
61
90
 
62
- # Deliberately does *not* claim a token leak. Measured on this tree: ActiveSupport's
91
+ # One message, four doors — and it names both harms because which one you get depends
92
+ # on the door, measured on this tree. Through JSON, ActiveSupport's
63
93
  # +Enumerable#as_json+ shadows +Object#as_json+, so a collection serialized as its
64
- # fetched records, never as its ivars — the client it holds was never walked. The
65
- # harm is the unbounded fetch and the records that came out with it.
94
+ # fetched records and the client in its ivars was never walked: the harm is the
95
+ # unbounded fetch. +Marshal+ and Psych have no such shadow and walked the ivars
96
+ # instead, reaching the token and writing it wherever the dump went.
97
+ #
98
+ # Naming only the fetch would have told a +Rails.cache.write+ caller their credential
99
+ # was slow to write rather than at rest in Redis — a wrong diagnosis being worse than
100
+ # a missing one. Naming both keeps one refusal per resource, which is what stops the
101
+ # doors drifting apart.
102
+ #
103
+ # The remedy is two-part for the same reason. <tt>.to_a</tt> alone is enough for JSON,
104
+ # where a model serializes as its record — but the array it hands back is full of
105
+ # models that each hold the client, so <tt>Marshal.dump(bc.messages.to_a)</tt> lands
106
+ # straight back here. Measured, not assumed.
66
107
  def serialization_refusal
67
- "#{self.class} is a lazy, auto-paginating query, not a record, so there is no one " \
68
- "record to serialize. Serializing it runs a page-by-page GET loop over the whole " \
69
- "resource from inside your renderer, and emits every record it fetched. Call " \
70
- ".to_a (or .first(n), or .filter(...).to_a) and serialize that — then how much you " \
71
- "fetch is a visible act in your own code."
108
+ "#{self.class} is a lazy, auto-paginating query holding your connection, not a " \
109
+ "record, so there is no one record to serialize. Serializing it either runs a " \
110
+ "page-by-page GET loop over the whole resource from inside your renderer and " \
111
+ "emits every record it fetched, or writes out the connection it holds — your " \
112
+ "bc_uat_ token with it. Call .to_a (or .first(n), or .filter(...).to_a) and " \
113
+ "serialize that — then how much you fetch is a visible act in your own code. " \
114
+ "Marshal and YAML reach the client through the records too, so dump the wire " \
115
+ "records rather than the models: .to_a.map(&:to_h)."
72
116
  end
73
117
  end
74
118
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BaseCradle
4
- VERSION = "0.10.2"
4
+ VERSION = "0.10.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: basecradle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.2
4
+ version: 0.10.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Drawk Kwast