skip to content

In Ruby 4.0, how do you keep a secret instance variable such as @password out of an object's inspect output, and where can it still leak?

level: seniorimportance: should knowfreq 28%

answer

  1. Kernel#inspect lists every ivar
  2. instance_variables_to_inspect, new in 4.0
  3. Array or nil, else TypeError
  4. pp checks respond_to?, not private
  5. wrap secrets in a redacting object

basics

~20 s

Ruby 4.0's Kernel#inspect shows only the instance variables returned by an instance_variables_to_inspect method; before 4.0 you override inspect. Secrets still leak through a hand-written to_s, direct ivar access and pp when the hook is private.

solid answer

~40 s

The default `Kernel#inspect` prints every instance variable, so `p config`, an error report or a console session can expose `@password`. Ruby 4.0 lets a class define `instance_variables_to_inspect`, usually privately, returning the ivars to show, e.g. `[:@host, :@user]`; returning `nil` means all, and anything other than an Array or `nil` raises `TypeError`. On older Rubies you override `inspect` itself. Filtering `inspect` is not a vault: a custom `to_s` or log line can still interpolate the secret, `instance_variable_get` and debuggers read it directly, and the pp library shipped with 4.0.7 checks `respond_to?(:instance_variables_to_inspect)`, which is false for a private method, so `pp` prints every ivar unless the hook is public or `inspect` is overridden. The sturdier design wraps the secret in a small object whose `to_s` and `inspect` both return a redacted marker.

code

ruby · 14 lines
ruby
class DbConfig
  def initialize(host, user, password)
    @host, @user, @password = host, user, password
  end

  private def instance_variables_to_inspect = [:@host, :@user]
end

conf = DbConfig.new("db.local", "timetable", "hunter2")
p conf
# #<DbConfig:0x... @host="db.local", @user="timetable">

conf.inspect.include?("hunter2")  # => false
pp conf   # pp 0.6.3: respond_to? skips the private hook, so @password shows

go deeper

for a junior

Know that p shows every instance variable by default, so p on an object holding a password prints the password.

for a middle

Explain the Ruby 4.0 hook: an Array of names filters inspect, nil shows everything, other values raise TypeError, and older Rubies need an inspect override.

for a senior

Name the remaining leak paths - pp with a private hook, custom to_s, direct ivar access, copied strings - and prefer a redacting wrapper with a spec asserting inspect omits the secret.

for a principal

Set a codebase rule that credentials travel only inside redacting value objects, so safety does not depend on every class author remembering an inspect filter.

Any object that holds a credential - a database password, an API token, a signing key - is one `p` call, console session or error report away from printing it. This question is about Ruby's tools for preventing that, and their limits. ## Why the default leaks `Kernel#inspect`, the default behind `p` and every collection's `inspect`, shows the class name, the object's address and **every instance variable** with its value: ```ruby conf = DbConfig.new("db.local", "timetable", "hunter2") p conf # #<DbConfig:0x... @host="db.local", @user="timetable", @password="hunter2"> ``` Because arrays and hashes render their elements with `inspect`, the secret also appears when the object sits inside a collection that is logged, and `pp` builds its own listing of the same instance variables. ## Ruby 4.0: instance_variables_to_inspect Ruby 4.0 added a hook. When `Kernel#inspect` runs, it calls the object's `instance_variables_to_inspect` method (a private default on `Kernel` returns `nil`) and interprets the result: | Return value | Effect on `inspect` | |---|---| | `nil` | every instance variable is shown (the old behaviour) | | an Array of names, e.g. `[:@host, :@user]` | only those instance variables are shown | | an empty Array | no instance variables; the output is just `#<DbConfig:0x...>` | | anything else | `TypeError`: "Expected #instance_variables_to_inspect to return an Array or nil" | The documented style is a private one-liner using an endless method: ```ruby private def instance_variables_to_inspect = [:@host, :@user] ``` Listing what to **show** rather than what to hide is the safer default: a new `@api_token` added later stays hidden until someone deliberately lists it. ## Before 4.0: override inspect On 3.x the only option is to define `inspect` yourself, for example returning `"#<DbConfig host=#{@host.inspect} user=#{@user.inspect}>"`. This still works in 4.0 and gives full control of the format, at the cost of keeping it in sync with the class by hand. ## Where the secret can still leak Filtering `inspect` closes one path, not all of them: 1. **`pp` with a private hook.** The pp library shipped with Ruby 4.0.7 (pp 0.6.3) builds its list of instance variables with `respond_to?(:instance_variables_to_inspect) ? instance_variables_to_inspect : instance_variables`. `respond_to?` ignores private methods by default, so with the private style above, `pp conf` prints **all** instance variables, `@password` included. Making the hook public, or overriding `inspect` (pp uses a class's own `inspect` when one is defined), closes it. 2. **A hand-written `to_s` or log message** that interpolates `@password` - `inspect` filtering does not touch `to_s`. 3. **Direct access**: `instance_variables`, `instance_variable_get`, serialisation with `Marshal`, and interactive debuggers read the variable regardless of `inspect`. 4. **Code that copies the value out**, such as building a connection URL string that then gets logged. One path got narrower on its own: since Ruby 3.3 a `NoMethodError` message says "an instance of DbConfig" instead of embedding the receiver's `inspect`, so a typo'd method call no longer dumps the object into an exception message. ## A sturdier design Hide the secret in the **value itself**, not in every class that holds it. A tiny wrapper whose `to_s` and `inspect` both return a marker such as `"[FILTERED]"`, and which exposes the raw string only through an explicit method like `reveal`, stays redacted in `puts`, `p`, `pp`, interpolation and collections, because every printing path calls one of those two methods. The raw value then appears only where code asks for it by name, which is easy to review. ## Proving it in a spec A filter that nobody tests drifts: someone renames `@user` to `@username`, the allow-list silently stops showing it, or someone adds a new secret and switches the list to a deny-list. A two-line test pins the behaviour down - build the object with a recognisable fake password and assert that neither `inspect` nor `to_s` contains it. If the team relies on `pp` in consoles, assert on `pretty_inspect` as well, since it takes a different route to the instance variables. These tests are cheap, and they turn an easily forgotten convention into a failing build. ## Checklist for a class holding credentials - Define `instance_variables_to_inspect` (Ruby 4.0) listing only safe ivars - or override `inspect` on older Rubies. - Make the hook public or also override `inspect` if the team uses `pp`. - Never interpolate the secret in `to_s` or log lines. - Prefer a redacting wrapper object for the secret itself. - Test it: assert that `conf.inspect` does not include the password.

  • In Ruby 4.0, what does Kernel#inspect do if instance_variables_to_inspect returns an empty array?
    It shows no instance variables: with zero names to render, `Kernel#inspect` falls back to the plain `#<DbConfig:0x...>` form with just the class name and address. Returning `nil` has the opposite effect and shows all of them.
  • Why is listing the variables to show safer than removing the secret one from instance_variables?
    An allow-list fails closed: a credential ivar added later, such as `@api_token`, stays hidden until someone lists it on purpose. A deny-list built as `instance_variables - [:@password]` fails open, exposing every new secret by default.

A redacting wrapper is a sealed envelope marked confidential: anyone photographing the desk sees the envelope, and only someone who deliberately opens it sees what is inside.

saying these in an interview costs you the question

  • Overriding to_s is enough to hide a password from p and the console.
  • instance_variables_to_inspect also removes the ivar from instance_variables.
  • Returning a string from instance_variables_to_inspect is simply ignored.
  • Once inspect is filtered, every printer including pp is safe.
  • The instance_variables_to_inspect hook works on Ruby 3.x as well.