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 valuefor 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 soAuthorization: Bearer XYZmasks 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 barehttps://host:8080/p(no@), thehost:portthat follows the@, and an IPv6 host@[::1]:5432are all left untouched — only a realuser: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-separateduser: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 8080is never masked, and the spacedmysql -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
-
.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>. -
.mask_inline(text) ⇒ Object
Mask inline patterns like
Authorization: Bearer XYZin any string, whether or not the caller knows the surrounding context. -
.mask_value(value, key: nil) ⇒ Object
Mask a single value, given the key it belongs to.
-
.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).
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_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).
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 |