From dd9e7f4c33b630234b24ae167b6ae713603beca6 Mon Sep 17 00:00:00 2001 From: Dev Chiniwala Date: Thu, 17 Sep 2026 16:17:16 +0530 Subject: [PATCH] fix: redact secrets from audit errors --- datamind/capabilities/hooks/audit.py | 21 +++++++++++++++++---- datamind/tests/test_hooks.py | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/datamind/capabilities/hooks/audit.py b/datamind/capabilities/hooks/audit.py index b1b69dd..e22b622 100644 --- a/datamind/capabilities/hooks/audit.py +++ b/datamind/capabilities/hooks/audit.py @@ -16,9 +16,9 @@ Secret redaction: arg values whose KEY matches a redaction regex (`api_key`, `password`, `token`, `secret`, ...) are replaced with -`"[REDACTED]"` before logging. Values are not scanned (we trust the -caller to put secrets in well-named fields, which is true across -DataMind's own tools). +`"[REDACTED]"` before logging. Credential-shaped values in exception +text are redacted as well because provider and transport errors commonly +include the rejected request detail. Concurrency: writes are serialised via an asyncio.Lock per AuditLogHook instance. For multi-process deployments use one process per profile @@ -45,6 +45,11 @@ r"(?i)(api[_-]?key|password|passwd|token|secret|authorization|bearer|access[_-]?key|client[_-]?secret)" ) _REDACTED = "[REDACTED]" +_BEARER_TEXT_RE = re.compile(r"(?i)\bBearer\s+([^\s,;]+)") +_SECRET_TEXT_RE = re.compile( + r"(?i)\b(api[_-]?key|password|passwd|token|secret|authorization|access[_-]?key|client[_-]?secret)\b" + r"(\s*[:=]\s*)([^\s,;]+)" +) _HASH_HEX_LEN = 16 # truncated SHA-256 hex; 64 bits of collision resistance @@ -74,6 +79,14 @@ def _redact(value: Any) -> Any: return value +def _redact_text(value: str) -> str: + """Redact common credential formats embedded in diagnostic text.""" + value = _BEARER_TEXT_RE.sub(f"Bearer {_REDACTED}", value) + return _SECRET_TEXT_RE.sub( + lambda match: f"{match.group(1)}{match.group(2)}{_REDACTED}", value + ) + + def _decision_to_record(decision: HookDecision) -> dict[str, Any]: if isinstance(decision, Allow): return {"kind": "allow"} @@ -163,7 +176,7 @@ async def post_tool_use( "error": ( None if error is None - else f"{type(error).__name__}: {error}" + else _redact_text(f"{type(error).__name__}: {error}") ), } await self._append(record) diff --git a/datamind/tests/test_hooks.py b/datamind/tests/test_hooks.py index 55b5567..cf8e2e0 100644 --- a/datamind/tests/test_hooks.py +++ b/datamind/tests/test_hooks.py @@ -322,6 +322,26 @@ async def test_audit_log_redacts_secret_keys(tmp_path: Path): assert rec["args"]["username"] == "ann" +@pytest.mark.asyncio +async def test_audit_log_redacts_secrets_in_errors(tmp_path: Path): + audit = tmp_path / "audit.jsonl" + hook = AuditLogHook(audit_path=audit) + await hook.post_tool_use( + _ctx(), + "fake_tool", + {}, + result=None, + error=RuntimeError( + "request failed: api_key=sk-live-secret Authorization: Bearer bearer-secret" + ), + ) + + rec = json.loads(audit.read_text().splitlines()[-1]) + assert "sk-live-secret" not in rec["error"] + assert "bearer-secret" not in rec["error"] + assert "[REDACTED]" in rec["error"] + + @pytest.mark.asyncio async def test_audit_log_records_denied_decision(tmp_path: Path): audit = tmp_path / "audit.jsonl"