Skip to content

Commit f9652d6

Browse files
committed
fix(redaction): close credential masking gaps
1 parent 20519de commit f9652d6

5 files changed

Lines changed: 114 additions & 24 deletions

File tree

‎socket_basics/core/connector/opengrep/__init__.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from ...utils.redaction import (
2727
is_credential_finding,
2828
redact_dataflow_trace,
29+
redact_message,
2930
redact_snippet,
3031
)
3132

@@ -412,6 +413,14 @@ def _convert_to_socket_facts(self, raw_results: Any) -> Dict[str, Any]:
412413
severity = ((r.get('extra') or {}).get('severity') or r.get('severity') or '')
413414
severity_norm = str(severity).lower() if severity is not None else ''
414415
message = (r.get('extra') or {}).get('message') or r.get('message') or ''
416+
credential_finding = is_credential_finding(
417+
check_id, (r.get('extra') or {}).get('metadata') or {}
418+
)
419+
message = redact_message(
420+
message,
421+
(r.get('extra') or {}).get('metavars') or {},
422+
credential_finding=credential_finding,
423+
)
415424
start = (r.get('start') or {}).get('line')
416425
end = (r.get('end') or {}).get('line')
417426

@@ -435,9 +444,7 @@ def _convert_to_socket_facts(self, raw_results: Any) -> Dict[str, Any]:
435444
code_snippet = (r.get('extra') or {}).get('lines') or (r.get('extra') or {}).get('snippet') or ''
436445
code_snippet = redact_snippet(
437446
code_snippet,
438-
credential_finding=is_credential_finding(
439-
check_id, (r.get('extra') or {}).get('metadata') or {}
440-
),
447+
credential_finding=credential_finding,
441448
)
442449

443450
alert = {

‎socket_basics/core/connector/trufflehog/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,9 @@ def _output_file_patterns(self) -> List[str]:
184184
if not output_dir:
185185
return []
186186
try:
187-
output_path = Path(output_dir) / output_name
187+
output_path = Path(
188+
self._absolute_scan_target(Path(output_dir) / output_name)
189+
)
188190
except (TypeError, ValueError):
189191
return []
190192

‎socket_basics/core/utils/redaction.py‎

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
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

2728
import re
@@ -32,6 +33,7 @@
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

138144
def 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

192196
def 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+
220250
def redact_dataflow_trace(trace: Any) -> Any:
221251
"""Scrub the code fragments carried by a taint-mode dataflow trace."""
222252
if not isinstance(trace, dict):

‎tests/test_secret_redaction.py‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
mask_value,
2525
redact_dataflow_trace,
2626
redact_literals,
27+
redact_message,
2728
redact_snippet,
2829
scrub_tokens,
2930
)
@@ -123,6 +124,11 @@ def test_credentials_in_a_url_authority_are_masked(self):
123124
# The host stays readable so the finding still points somewhere.
124125
assert "db.internal:5432/app" in scrubbed
125126

127+
def test_url_password_is_masked_when_it_matches_the_username(self):
128+
scrubbed = scrub_tokens("postgres://admin:admin@db.internal/app")
129+
assert scrubbed.startswith("postgres://admin:")
130+
assert ":admin@" not in scrubbed
131+
126132
def test_ordinary_code_is_left_alone(self):
127133
for snippet in (
128134
"eval(rawConfigStr)",
@@ -159,6 +165,18 @@ def test_unquoted_assignments_fall_back_to_masking_the_value(self):
159165
assert "SuperSecret123!" not in redacted
160166
assert redacted.startswith("password: ")
161167

168+
def test_quoted_text_does_not_skip_an_unquoted_value(self):
169+
redacted = redact_literals('password: hunter2 # "temporary"')
170+
assert "hunter2" not in redacted
171+
assert redacted.startswith("password: ")
172+
173+
def test_each_line_uses_the_appropriate_redaction(self):
174+
redacted = redact_literals(
175+
'credentials:\n user: "admin"\n password: hunter2'
176+
)
177+
assert "admin" not in redacted
178+
assert "hunter2" not in redacted
179+
162180
def test_empty_literals_are_left_as_they_are(self):
163181
assert redact_literals('password = ""') == 'password = ""'
164182

@@ -240,6 +258,25 @@ def test_a_non_credential_finding_still_loses_a_recognizable_token(self):
240258
assert "console.log" in redacted
241259

242260

261+
class TestRedactMessage:
262+
def test_interpolated_metavariables_are_masked_for_credential_findings(self):
263+
secret = "SuperSecret123!"
264+
redacted = redact_message(
265+
f"Hardcoded credential {secret} assigned to database_password",
266+
{
267+
"$VALUE": {"abstract_content": secret},
268+
"$VAR": {"abstract_content": "database_password"},
269+
},
270+
credential_finding=True,
271+
)
272+
assert secret not in redacted
273+
assert "database_password" not in redacted
274+
assert redacted.startswith("Hardcoded credential ")
275+
276+
def test_known_tokens_are_scrubbed_from_every_message(self):
277+
assert AWS_KEY_ID not in redact_message(f"Logged value: {AWS_KEY_ID}")
278+
279+
243280
class TestRedactDataflowTrace:
244281
def test_trace_steps_are_scrubbed(self):
245282
trace = {
@@ -275,8 +312,11 @@ def test_opengrep_alerts_carry_no_credential(self, tmp_path):
275312
"end": {"line": 3},
276313
"extra": {
277314
"severity": "ERROR",
278-
"message": "Hardcoded secret or credential detected",
279-
"lines": 'STRIPE_SECRET_KEY = STRIPE_KEY',
315+
"message": f"Hardcoded credential detected: {STRIPE_KEY}",
316+
"lines": f'STRIPE_SECRET_KEY = "{STRIPE_KEY}"',
317+
"metavars": {
318+
"$VALUE": {"abstract_content": STRIPE_KEY},
319+
},
280320
"metadata": {"cwe": "CWE-798", "confidence": "medium"},
281321
},
282322
}

‎tests/test_trufflehog_excludes.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,17 @@ def fake_run(command, **kwargs):
212212
assert command[-1] == str(tmp_path)
213213

214214

215+
def test_output_file_patterns_are_absolute_for_relative_output_dir(tmp_path, monkeypatch):
216+
monkeypatch.chdir(tmp_path)
217+
scanner = _scanner(Path("."), "")
218+
scanner.config.output_dir = Path(".")
219+
220+
patterns = scanner._output_file_patterns()
221+
222+
assert scanner._path_matches_patterns(str(tmp_path / ".socket.facts.json"), patterns)
223+
assert scanner._path_matches_patterns(str(tmp_path / ".socket.facts.json.tmp"), patterns)
224+
225+
215226
def test_process_results_strips_absolute_workspace_from_output_and_id(tmp_path):
216227
scanner = _scanner(tmp_path, "")
217228
scanner.generate_notifications = lambda components: {}

0 commit comments

Comments
 (0)