2020 of every string literal on the line, keeping the assignment target and the
2121 surrounding syntax so the finding is still readable.
2222
23- ``redact_snippet`` composes them, and ``is_credential_finding`` decides which
24- rules get the second pass.
23+ ``redact_snippet`` composes them, ``redact_message`` removes values interpolated
24+ into rule messages, and ``is_credential_finding`` decides which rules get the
25+ second pass.
2526"""
2627
2728import re
3233 "scrub_tokens" ,
3334 "redact_literals" ,
3435 "redact_snippet" ,
36+ "redact_message" ,
3537 "redact_dataflow_trace" ,
3638 "is_credential_finding" ,
3739]
@@ -132,7 +134,11 @@ def _mask_match(match: 're.Match[str]') -> str:
132134 secret = match .group (1 )
133135 if not secret :
134136 return whole
135- return whole .replace (secret , mask_value (secret ), 1 )
137+ secret_start , secret_end = match .span (1 )
138+ match_start = match .start (0 )
139+ relative_start = secret_start - match_start
140+ relative_end = secret_end - match_start
141+ return f"{ whole [:relative_start ]} { mask_value (secret )} { whole [relative_end :]} "
136142
137143
138144def scrub_tokens (text : Any ) -> str :
@@ -158,35 +164,33 @@ def redact_literals(text: Any) -> str:
158164 if not isinstance (text , str ) or not text :
159165 return text if isinstance (text , str ) else ''
160166
161- found_literal = False
162-
163167 def _mask_literal (match : 're.Match[str]' ) -> str :
164- # A quoted empty string is recorded as a literal even though there is
165- # nothing to mask, so an already-quoted line does not fall through to
166- # the unquoted branch and get its quotes masked instead.
167- nonlocal found_literal
168- found_literal = True
169168 body = match .group ('body' )
170169 if not body :
171170 return match .group (0 )
172171 quote = match .group ('quote' )
173172 return f'{ quote } { mask_value (body )} { quote } '
174173
175- redacted = _STRING_LITERAL .sub (_mask_literal , text )
176- if found_literal :
177- return redacted
178-
179- # No literal on the line, so fall back to masking each assignment's value.
174+ # Mask unquoted assignments line by line before processing literals. A
175+ # quoted value is left for the literal pass, while an unquoted value is
176+ # masked even if another line or a trailing comment contains quoted text.
180177 masked_lines = []
181178 for line in text .split ('\n ' ):
182179 match = _UNQUOTED_ASSIGNMENT .match (line )
183- if match :
180+ body = match .group ('body' ) if match else ''
181+ if match and not body .lstrip ().startswith (('"' , "'" , '`' )):
182+ # If quoted text appears later in an unquoted value, mask the whole
183+ # body. Measuring that combined text could otherwise make a short
184+ # credential eligible for a partial reveal.
185+ masked_body = (
186+ '*' * len (body ) if _STRING_LITERAL .search (body ) else mask_value (body )
187+ )
184188 masked_lines .append (
185- f"{ match .group ('head' )} { mask_value ( match . group ( 'body' )) } { match .group ('tail' )} "
189+ f"{ match .group ('head' )} { masked_body } { match .group ('tail' )} "
186190 )
187191 else :
188192 masked_lines .append (line )
189- return '\n ' .join (masked_lines )
193+ return _STRING_LITERAL . sub ( _mask_literal , '\n ' .join (masked_lines ) )
190194
191195
192196def is_credential_finding (rule_id : Any , metadata : Mapping [str , Any ] | None = None ) -> bool :
@@ -217,6 +221,32 @@ def redact_snippet(text: Any, credential_finding: bool = False) -> str:
217221 return scrubbed
218222
219223
224+ def redact_message (text : Any , metavars : Any = None ,
225+ credential_finding : bool = False ) -> str :
226+ """Mask credentials interpolated into a scanner rule's message.
227+
228+ OpenGrep expands metavariables before returning a result. For credential
229+ findings, mask every expanded metavariable that appears in the message;
230+ the connector cannot reliably identify which metavariable held the secret.
231+ Well-known token formats are scrubbed from every message independently.
232+ """
233+ redacted = scrub_tokens (text )
234+ if not credential_finding or not isinstance (metavars , Mapping ):
235+ return redacted
236+
237+ values = set ()
238+ for details in metavars .values ():
239+ if not isinstance (details , Mapping ):
240+ continue
241+ value = details .get ('abstract_content' )
242+ if isinstance (value , str ) and value :
243+ values .add (value )
244+
245+ for value in sorted (values , key = len , reverse = True ):
246+ redacted = redacted .replace (value , mask_value (value ))
247+ return redacted
248+
249+
220250def redact_dataflow_trace (trace : Any ) -> Any :
221251 """Scrub the code fragments carried by a taint-mode dataflow trace."""
222252 if not isinstance (trace , dict ):
0 commit comments