Class: Woods::Console::CredentialIndex
- Inherits:
-
Object
- Object
- Woods::Console::CredentialIndex
- Defined in:
- lib/woods/console/credential_index.rb
Overview
Boot-time index of every string leaf in ‘Rails.application.credentials`.
The pattern-based CredentialScanner catches *known credential shapes* (Stripe ‘sk_live_…`, AWS `AKIA…`, etc.). It cannot catch a value whose shape is unknown — a hand-rolled HMAC secret, a Twilio auth token, a third-party webhook signing key. This index closes that gap by remembering the host app’s actual credential values and substring- matching them in every Console MCP response, so a row whose value exactly matches a stored credential is redacted regardless of column name or value shape.
The index is built once at server boot — walking encrypted credentials on every request would be both expensive and unsafe (it requires the master key). Each string leaf with length ≥ MIN_LENGTH is added to a frozen Set, and a pre-compiled ‘Regexp.union` is held for one-pass `gsub` substitution.
### Multi-DB / sharded caveat The index reflects credentials available to the *Rails process* that boots the Console MCP server. A separate database that holds its own secrets (e.g., a vendored CMS app sharing the same Rails host) is not in scope. Use Layer 3 (‘console_redacted_columns` / `console_redacted_key_values`) for those.
### Missing master key In environments without ‘config/master.key` (CI, fresh checkouts) the `Rails.application.credentials.config` call raises `ActiveSupport::EncryptedConfiguration::MissingKeyError` or `ActiveSupport::MessageEncryptor::InvalidMessage`. CredentialIndex.build catches both *by name* (no constant reference, so the class load order is irrelevant) and returns an empty index — the server still boots, the other defense layers still apply.
Constant Summary collapse
- PROCESS_START =
Captured at require time so the mtime-check warning has a stable reference point even if the clock skews later. Frozen immediately to prevent accidental mutation.
Time.now.freeze
- MIN_LENGTH =
Substrings shorter than this are not added to the index. Below ~12 chars the false-positive rate climbs sharply (env names like ‘production`, hostnames, version strings, etc.).
12- REDACTED =
Rendered marker for substring hits — distinct from the pattern scanner’s ‘[REDACTED]` so operators reading audit output can see which layer caught the leak.
'[REDACTED:credential]'- MISSING_KEY_ERRORS =
Encryption-related exception class names caught by name. Rails moves these around between versions; matching by ‘Class#name` keeps us from coupling to a specific constant path.
%w[ ActiveSupport::EncryptedConfiguration::MissingKeyError ActiveSupport::EncryptedFile::MissingKeyError ActiveSupport::MessageEncryptor::InvalidMessage ].freeze
Instance Attribute Summary collapse
-
#pattern ⇒ Regexp?
readonly
Precompiled ‘Regexp.union` of every secret, or nil when the index is empty (no allocation, no per-string overhead).
-
#secrets ⇒ Set<String>
readonly
Frozen set of secret strings (length ≥ MIN_LENGTH).
Class Method Summary collapse
-
.build(rails_app:) ⇒ CredentialIndex
Build an index from a Rails application’s encrypted credentials.
-
.warn_if_credentials_rotated(credentials_files:, process_start:, logger:) ⇒ void
Emit a structured log warning when any of the given credentials files has a modification time newer than ‘process_start`.
Instance Method Summary collapse
-
#empty? ⇒ Boolean
True when no secrets were collected (missing key, empty credentials file, or every leaf below MIN_LENGTH).
-
#initialize(secrets:) ⇒ CredentialIndex
constructor
A new instance of CredentialIndex.
-
#match?(str) ⇒ Boolean
True when any indexed secret appears as a substring.
-
#redact(str) ⇒ String
Replace every indexed-secret substring in ‘str` with REDACTED.
Constructor Details
#initialize(secrets:) ⇒ CredentialIndex
Returns a new instance of CredentialIndex.
169 170 171 172 173 |
# File 'lib/woods/console/credential_index.rb', line 169 def initialize(secrets:) filtered = Array(secrets).select { |s| s.is_a?(String) && s.length >= MIN_LENGTH } @secrets = filtered.to_set.freeze @pattern = @secrets.empty? ? nil : Regexp.union(@secrets.to_a) end |
Instance Attribute Details
#pattern ⇒ Regexp? (readonly)
Returns precompiled ‘Regexp.union` of every secret, or nil when the index is empty (no allocation, no per-string overhead).
164 165 166 |
# File 'lib/woods/console/credential_index.rb', line 164 def pattern @pattern end |
#secrets ⇒ Set<String> (readonly)
Returns frozen set of secret strings (length ≥ MIN_LENGTH).
160 161 162 |
# File 'lib/woods/console/credential_index.rb', line 160 def secrets @secrets end |
Class Method Details
.build(rails_app:) ⇒ CredentialIndex
Build an index from a Rails application’s encrypted credentials.
**Restart required after rotation.** The index is built once at server boot and held in memory for the life of the MCP process. When a host app rotates Rails credentials, the MCP process keeps the pre-rotation secrets in its frozen Set until the process restarts. New secrets added during rotation are NOT in the index — only Layer 2 shape patterns can catch them until restart.
To trigger a rebuild without restarting, call ‘Woods::Console::Server.rebuild_credential_index` from a rotation job or initializer hook. See docs/CONSOLE_MCP_SETUP.md for the full restart guidance.
92 93 94 95 96 97 98 |
# File 'lib/woods/console/credential_index.rb', line 92 def build(rails_app:) new(secrets: collect_secrets(rails_app)) rescue StandardError => e raise unless missing_key_error?(e) new(secrets: []) end |
.warn_if_credentials_rotated(credentials_files:, process_start:, logger:) ⇒ void
This method returns an undefined value.
Emit a structured log warning when any of the given credentials files has a modification time newer than ‘process_start`. This catches the common “rotated credentials but forgot to restart” situation at boot time.
Only files that actually exist on disk are checked; missing paths are silently skipped so CI and fresh-checkout environments (which have no credentials file) produce no noise.
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 |
# File 'lib/woods/console/credential_index.rb', line 116 def warn_if_credentials_rotated(credentials_files:, process_start:, logger:) credentials_files.each do |path| next unless File.exist?(path) mtime = File.mtime(path) next unless mtime > process_start logger.warn( 'console.credential_index.stale', credentials_file: path, file_mtime: mtime.iso8601, process_start: process_start.iso8601, hint: 'Credentials file was modified after process start — ' \ 'restart the MCP process or call ' \ 'Woods::Console::Server.rebuild_credential_index to ' \ 'pick up rotated secrets.' ) end end |
Instance Method Details
#empty? ⇒ Boolean
Returns true when no secrets were collected (missing key, empty credentials file, or every leaf below MIN_LENGTH).
177 178 179 |
# File 'lib/woods/console/credential_index.rb', line 177 def empty? @secrets.empty? end |
#match?(str) ⇒ Boolean
Returns true when any indexed secret appears as a substring.
183 184 185 186 187 |
# File 'lib/woods/console/credential_index.rb', line 183 def match?(str) return false if empty? || !str.is_a?(String) @pattern.match?(str) end |