Module: Rubino::Util::SecretsMask

Defined in:
lib/rubino/util/secrets_mask.rb

Overview

Heuristic masking for credentials in tool arguments. The model often passes secrets through cleanly (env vars, config files), but a stray ‘command: “curl -H ’Authorization: Bearer sk_live_…‘”` showing up in an approval prompt — or, worse, in the persistent scrollback — is a leak waiting to happen. Mask aggressively on display; the underlying tool still receives the real value.

Constant Summary collapse

SECRET_KEY_TOKENS =
%w[
  password passwd
  secret
  token bearer
  api_key apikey api-key
  access_key accesskey access-key
  private_key privatekey private-key
  auth authorization
].freeze
INLINE_RE =

Pattern that matches ‘key=value`, `key: value`, `key value` for the secret-named keys, inside a free-text string (shell command, URL query). The trailing value is grabbed up to whitespace or a known delimiter; quoted values are grabbed whole. `Bearer <token>` is treated as a single value so `Authorization: Bearer XYZ` masks the whole token instead of leaving XYZ exposed.

/
  (?<key>password|passwd|secret|token|
        api[_-]?key|access[_-]?key|private[_-]?key|
        authorization|auth|bearer)
  (?<sep>\s*[:=]\s*|\s+)
  (?<val>"[^"]+"|'[^']+'|(?:Bearer\s+)?[^"'\s]+)
/xi
URL_USERINFO_RE =

URL userinfo credentials: ‘scheme://user:PASSWORD@host`. Masks ONLY the password, keeping scheme/user/host so the trace still says which service/account was touched (`postgresql://app:***@db`). The userinfo username is `[^:@/s]+` and the password `[^@/s]+`, both terminating at the `@`, so a bare `host:8080/p` (no `@`), the `host:port` that follows the `@`, and an IPv6 host `@[::1]:5432` are all left untouched —only a real `user:pass@` triggers. The unambiguous, industry-standard form (git/pip redact credentials in URLs exactly this way; RFC 3986 deprecates them outright).

%r{
  (?<scheme>[a-z][a-z0-9+.-]*://)
  (?<user>[^:@/\s]+)
  (?<sep>:)
  (?<pass>[^@/\s]+)
  (?<at>@)
}xi
U_FLAG_CRED_RE =

Basic-auth credential pair ‘-u user:pass` (curl/wget). Unambiguous: the value carries a colon-separated `user:pass`, so we mask the password half and keep the username (`-u admin:***`). Both glued (`-uadmin:pw`) and spaced (`-u admin:pw`) forms match; a bare username with no colon is left alone (no secret on the line to mask).

/
  (?<flag>(?<![\w-])-u)
  (?<sp>\s*)
  (?<user>[^\s:'"]+)
  (?<sep>:)
  (?<pass>[^\s'"]+)
/x
MYSQL_PFLAG_RE =

Glued DB-client password flag ‘-p<password>`, scoped to mysql/mariadb clients ONLY. `-p<val>` is a password there but a PORT/PATH/anything for most other tools (`ssh -p 22`, `kubectl -p`), so we require BOTH the value to be GLUED to the flag (`-pSECRET`, no space — mysql’s own convention) AND a mysql-family client word on the same command. A generic ‘-p 8080` is never masked, and the spaced `mysql -p` (interactive prompt) carries no secret on the line so there is nothing to mask.

/
  (?<client>\b(?:mysql|mysqldump|mariadb|mariadb-dump)\b[^\n|;&]*?\s)
  (?<flag>-p)
  (?<pass>[^\s'"]+)
/xi
MASK =
"***"

Class Method Summary collapse

Class Method Details

.mask_glued_credentials(text) ⇒ Object

The glued/URL credential forms the keyed INLINE_RE can’t see: URL userinfo passwords, ‘-u user:pass`, and mysql/mariadb `-p<password>`. Each keeps the surrounding, non-secret context (scheme/user/host, the flag, the username) so the trace stays useful while the secret is gone.



121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/rubino/util/secrets_mask.rb', line 121

def self.mask_glued_credentials(text)
  out = text.gsub(URL_USERINFO_RE) do
    m = Regexp.last_match
    "#{m[:scheme]}#{m[:user]}:#{MASK}@"
  end
  out = out.gsub(U_FLAG_CRED_RE) do
    m = Regexp.last_match
    "#{m[:flag]}#{m[:sp]}#{m[:user]}:#{MASK}"
  end
  out.gsub(MYSQL_PFLAG_RE) do
    m = Regexp.last_match
    "#{m[:client]}#{m[:flag]}#{MASK}"
  end
end

.mask_hash(hash) ⇒ Object

Convenience for Hash arguments: returns a new Hash with sensitive values masked, leaving the original untouched (the real value still has to reach the tool).



139
140
141
142
143
# File 'lib/rubino/util/secrets_mask.rb', line 139

def self.mask_hash(hash)
  return hash unless hash.is_a?(Hash)

  hash.each_with_object({}) { |(k, v), out| out[k] = mask_value(v, key: k) }
end

.mask_inline(text) ⇒ Object

Mask inline patterns like ‘Authorization: Bearer XYZ` in any string, whether or not the caller knows the surrounding context. Quoted values keep their quotes around the mask so the surrounding structure (`-H “Authorization: ***”`) stays balanced — otherwise the mask would eat a quote and the rest of the string would look like one long open string.



103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/rubino/util/secrets_mask.rb', line 103

def self.mask_inline(text)
  masked = text.to_s.gsub(INLINE_RE) do
    m   = Regexp.last_match
    val = m[:val]
    inner = case val[0]
            when '"' then %("#{MASK}")
            when "'" then "'#{MASK}'"
            else MASK
            end
    "#{m[:key]}#{m[:sep]}#{inner}"
  end
  mask_glued_credentials(masked)
end

.mask_value(value, key: nil) ⇒ Object

Mask a single value, given the key it belongs to. Returns MASK if the key is sensitive; otherwise scans the value for inline secrets.



90
91
92
93
94
95
# File 'lib/rubino/util/secrets_mask.rb', line 90

def self.mask_value(value, key: nil)
  return value if value.nil?
  return MASK if key && sensitive_key?(key)

  mask_inline(value.to_s)
end

.sensitive_key?(key) ⇒ Boolean

True if the given key looks sensitive on its own (used when the caller already has key/value pairs, e.g. a Hash of arguments).

Returns:

  • (Boolean)


83
84
85
86
# File 'lib/rubino/util/secrets_mask.rb', line 83

def self.sensitive_key?(key)
  k = key.to_s.downcase.tr("-", "_")
  SECRET_KEY_TOKENS.any? { |t| k == t.tr("-", "_") || k.include?(t.tr("-", "_")) }
end