Skip to content

Commit 2dedd6d

Browse files
Merge pull request #28 from NVIDIA-NeMo/fix/secret-log-redaction
2 parents 75e7f57 + fea72c0 commit 2dedd6d

5 files changed

Lines changed: 223 additions & 27 deletions

File tree

src/nooa/mcp/oauth.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -550,19 +550,14 @@ async def exchange_code_for_token(self, code: str) -> OAuthToken:
550550
client_secret=self.config.client_secret,
551551
)
552552
except httpx.HTTPStatusError as e:
553-
error_detail = e.response.text if e.response else str(e)
554553
error_json = None
555554
try:
556555
if e.response:
557556
error_json = e.response.json()
558557
except Exception:
559558
pass
560559

561-
logger.error(
562-
f"Token exchange failed: {e.response.status_code}, Request data: {data}, Response: {error_detail}",
563-
)
564-
if error_json:
565-
logger.error(f"Error details: {error_json}")
560+
logger.error("Token exchange failed (HTTP %s)", e.response.status_code)
566561

567562
# Provide helpful error message for common issues
568563
error_msg = f"Failed to exchange authorization code for token (HTTP {e.response.status_code})"
@@ -572,9 +567,6 @@ async def exchange_code_for_token(self, code: str) -> OAuthToken:
572567
"Authorization codes are typically valid for only a few minutes. "
573568
"Please try the OAuth flow again to get a fresh code."
574569
)
575-
else:
576-
error_msg += f": {error_detail}"
577-
578570
raise RuntimeError(error_msg) from e
579571

580572
async def client_credentials_token(self) -> OAuthToken:
@@ -643,7 +635,7 @@ async def complete_flow(self, open_browser: bool = True) -> OAuthToken:
643635
logger.info("OAuth flow completed successfully")
644636
return token
645637
except Exception as e:
646-
logger.error(f"OAuth flow failed: {e}")
638+
logger.error("OAuth flow failed (%s)", type(e).__name__)
647639
raise
648640

649641

src/nooa/tracing/_secret_scrubber.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,37 @@
2424

2525
REDACTED = "[REDACTED]"
2626

27+
_SENSITIVE_KEYS = frozenset(
28+
{
29+
"authorization",
30+
"proxy_authorization",
31+
"api_key",
32+
"x_api_key",
33+
"access_token",
34+
"refresh_token",
35+
"auth_token",
36+
"session_token",
37+
"id_token",
38+
"client_secret",
39+
"client_assertion",
40+
"password",
41+
"passwd",
42+
"private_key",
43+
"secret_key",
44+
"code_verifier",
45+
"cookie",
46+
"set_cookie",
47+
}
48+
)
49+
50+
51+
def _is_sensitive_key(key: Any) -> bool:
52+
normalized = re.sub(r"[^a-z0-9]+", "_", str(key).lower()).strip("_")
53+
return normalized in _SENSITIVE_KEYS or any(
54+
normalized.endswith(f"_{sensitive}") for sensitive in _SENSITIVE_KEYS
55+
)
56+
57+
2758
# ---------------------------------------------------------------------------
2859
# Regex-based secret patterns — high precision, low false positives
2960
# ---------------------------------------------------------------------------
@@ -62,8 +93,9 @@
6293
"generic_api_key",
6394
re.compile(
6495
r"(?:api[_-]?key|api[_-]?token|auth[_-]?token|access[_-]?token|"
96+
r"client[_-]?secret|refresh[_-]?token|code[_-]?verifier|"
6597
r"secret[_-]?key|private[_-]?key|password|passwd)"
66-
r"[\"\']?\s*[=:]+\s*[\"\'\s]*(?P<secret>[A-Za-z0-9_.\-/+=]{20,})",
98+
r"[\"\']?\s*[=:]+\s*[\"\'\s]*(?P<secret>[A-Za-z0-9_.\-/+=]{1,})",
6799
re.IGNORECASE,
68100
),
69101
),
@@ -153,7 +185,7 @@ def scrub_string(text: str) -> tuple[str, int]:
153185
span processing). Returns the original string unchanged with a count of 0
154186
if no secrets are found (fast path).
155187
"""
156-
if not text or len(text) < 10:
188+
if not text:
157189
return text, 0
158190

159191
count = 0
@@ -176,12 +208,25 @@ def _replace(m: re.Match, _name: str = name) -> str:
176208
def scrub_value(value: Any) -> tuple[Any, int]:
177209
"""Scrub secrets from a span attribute value.
178210
179-
Handles strings and sequences of strings. Other types pass through.
211+
Handles strings, mappings, and sequences. Values under credential-bearing
212+
keys are always replaced, including short or provider-specific secrets.
180213
Returns ``(scrubbed_value, redaction_count)`` where the count is the
181214
number of secrets redacted within this value.
182215
"""
183216
if isinstance(value, str):
184217
return scrub_string(value)
218+
if isinstance(value, dict):
219+
scrubbed: dict[Any, Any] = {}
220+
count = 0
221+
for key, item in value.items():
222+
if _is_sensitive_key(key):
223+
scrubbed[key] = REDACTED
224+
stats.record("sensitive_key")
225+
count += 1
226+
else:
227+
scrubbed[key], n = scrub_value(item)
228+
count += n
229+
return scrubbed, count
185230
if isinstance(value, (list, tuple)):
186231
new_items = []
187232
count = 0
@@ -223,7 +268,11 @@ def on_end(self, span: ReadableSpan) -> None:
223268
redacted_count = 0
224269

225270
for key, value in span.attributes.items():
226-
new_value, n = scrub_value(value)
271+
if _is_sensitive_key(key):
272+
new_value, n = REDACTED, 1
273+
stats.record("sensitive_key")
274+
else:
275+
new_value, n = scrub_value(value)
227276
scrubbed[key] = new_value
228277
redacted_count += n
229278

src/nooa/unifiedllm/http_logging.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from datetime import UTC, datetime
66
from pathlib import Path
77

8+
from nooa.tracing._secret_scrubber import REDACTED, _is_sensitive_key, scrub_value
9+
810

911
def enable_http_request_logging(
1012
output_dir: str | Path = ".",
@@ -89,12 +91,24 @@ def enable_http_request_logging(
8991

9092
def _redact_headers(headers: dict) -> dict:
9193
"""Redact sensitive headers like Authorization."""
92-
redacted = dict(headers)
93-
for key in list(redacted.keys()):
94-
if key.lower() in ["authorization", "api-key", "x-api-key"]:
94+
redacted = {}
95+
for key, value in headers.items():
96+
if _is_sensitive_key(key):
9597
redacted[key] = "***REDACTED***"
98+
else:
99+
redacted[key], _ = scrub_value(value)
96100
return redacted
97101

102+
def _redact_body(body):
103+
"""Recursively scrub secrets before a parsed HTTP body is logged."""
104+
scrubbed, _ = scrub_value(body)
105+
if isinstance(scrubbed, dict) and scrubbed.get("grant_type") == "authorization_code":
106+
# "code" is too generic for global key matching, but is a credential
107+
# in an OAuth authorization-code exchange.
108+
if "code" in scrubbed:
109+
scrubbed["code"] = REDACTED
110+
return scrubbed
111+
98112
def _write_jsonl_entry(entry: dict):
99113
"""Append a JSON entry to the JSONL error file."""
100114
if jsonl_file is None:
@@ -113,7 +127,7 @@ def _log_request(request, counter):
113127
filename = output_path / f"request_{counter}_{model_name}.json"
114128

115129
with open(filename, "w") as f:
116-
json.dump(body_dict, f, indent=2)
130+
json.dump(_redact_body(body_dict), f, indent=2)
117131

118132
if verbose:
119133
print(f"\n💾 Saved HTTP request to: {filename}")
@@ -144,7 +158,7 @@ def _log_response_sync(response, request, counter, model_name):
144158
try:
145159
response_dict = {
146160
"status_code": response.status_code,
147-
"headers": dict(response.headers),
161+
"headers": _redact_headers(dict(response.headers)),
148162
}
149163

150164
try:
@@ -169,6 +183,8 @@ def _log_response_sync(response, request, counter, model_name):
169183
except Exception as read_error:
170184
response_dict["body"] = f"[Error reading response: {read_error}]"
171185

186+
response_dict["body"] = _redact_body(response_dict["body"])
187+
172188
_save_response_file(response_dict, counter, model_name)
173189
except Exception as e:
174190
if verbose:
@@ -180,7 +196,7 @@ async def _log_response_async(response, request, counter, model_name):
180196
try:
181197
response_dict = {
182198
"status_code": response.status_code,
183-
"headers": dict(response.headers),
199+
"headers": _redact_headers(dict(response.headers)),
184200
}
185201

186202
try:
@@ -206,6 +222,8 @@ async def _log_response_async(response, request, counter, model_name):
206222
except Exception as read_error:
207223
response_dict["body"] = f"[Error reading response: {read_error}]"
208224

225+
response_dict["body"] = _redact_body(response_dict["body"])
226+
209227
_save_response_file(response_dict, counter, model_name)
210228
except Exception as e:
211229
if verbose:
@@ -236,7 +254,7 @@ def logging_send(self, request):
236254
"url": str(request.url),
237255
"method": request.method,
238256
"headers": _redact_headers(dict(request.headers)),
239-
"body": body_dict,
257+
"body": _redact_body(body_dict),
240258
}
241259
except Exception as e:
242260
if verbose:
@@ -265,8 +283,8 @@ def logging_send(self, request):
265283

266284
response_data = {
267285
"status_code": response.status_code,
268-
"headers": dict(response.headers),
269-
"body": body_content,
286+
"headers": _redact_headers(dict(response.headers)),
287+
"body": _redact_body(body_content),
270288
}
271289

272290
# Write JSONL entry
@@ -324,7 +342,7 @@ async def async_logging_send(self, request):
324342
"url": str(request.url),
325343
"method": request.method,
326344
"headers": _redact_headers(dict(request.headers)),
327-
"body": body_dict,
345+
"body": _redact_body(body_dict),
328346
}
329347
except Exception as e:
330348
if verbose:
@@ -353,8 +371,8 @@ async def async_logging_send(self, request):
353371

354372
response_data = {
355373
"status_code": response.status_code,
356-
"headers": dict(response.headers),
357-
"body": body_content,
374+
"headers": _redact_headers(dict(response.headers)),
375+
"body": _redact_body(body_content),
358376
}
359377

360378
# Write JSONL entry

tests/tracing/test_secret_scrubber.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,14 @@ def test_empty_string(self):
139139
assert scrub_string("") == ("", 0)
140140

141141
def test_short_string(self):
142-
"""Strings below the minimum length are skipped (fast path)."""
142+
"""A clean short string passes through unchanged."""
143143
assert scrub_string("hello") == ("hello", 0)
144144

145+
def test_short_generic_secret(self):
146+
result, count = scrub_string("client_secret=s3cr3t")
147+
assert "s3cr3t" not in result
148+
assert count == 1
149+
145150
def test_preserves_surrounding_text(self):
146151
"""Surrounding non-secret text is preserved around a redaction."""
147152
text = "before AKIAIOSFODNN7EXAMPLE after"
@@ -203,6 +208,13 @@ def test_none_passthrough(self):
203208
"""None passes through unchanged with a zero count."""
204209
assert scrub_value(None) == (None, 0)
205210

211+
def test_nested_sensitive_keys(self):
212+
result, count = scrub_value(
213+
{"safe": {"client_secret": "short", "refresh_token": "provider-specific"}}
214+
)
215+
assert result == {"safe": {"client_secret": REDACTED, "refresh_token": REDACTED}}
216+
assert count == 2
217+
206218

207219
class TestScrubStats:
208220
def test_record_and_snapshot(self):

0 commit comments

Comments
 (0)