diff --git a/src/sap_cloud_sdk/core/auditlog_ng/client.py b/src/sap_cloud_sdk/core/auditlog_ng/client.py index 196ca2e9..a3f63c04 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/client.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/client.py @@ -76,6 +76,29 @@ def _fill_common_from_auth_context(event: Message) -> None: common.timestamp.FromDatetime(datetime.now(timezone.utc)) +def _strip_message_context_text(event: Message) -> None: + """Remove free-text fields from an event's ``message_context``, if present. + + Several AI audit events embed an ``AiMessageContext`` (proto field + ``message_context``) whose ``prompt_text`` and ``response_text`` fields hold + the verbatim model input/output and may contain sensitive data. We never + forward that content to the audit log; correlation fields (uid, name, + ai_role_id) and token counts are left intact. + + Safe to call on any event: events without a ``message_context`` field, or + with one that was never set, are left untouched (we only act when the field + is actually present, so we never auto-create an empty sub-message). + """ + if not hasattr(event, "message_context"): + return + event_any = cast(Any, event) + if not event_any.HasField("message_context"): + return + message_context = event_any.message_context + message_context.ClearField("prompt_text") + message_context.ClearField("response_text") + + def _create_log_exporter( config: AuditLogNGConfig, credentials: Optional[grpc.ChannelCredentials], @@ -216,6 +239,7 @@ def send( raise ValueError("format must be 'protobuf-binary' or 'json'") _fill_common_from_auth_context(event) + _strip_message_context_text(event) try: protovalidate.validate(event) diff --git a/tests/core/unit/auditlog_ng/unit/test_client.py b/tests/core/unit/auditlog_ng/unit/test_client.py index af77c61e..60df54a6 100644 --- a/tests/core/unit/auditlog_ng/unit/test_client.py +++ b/tests/core/unit/auditlog_ng/unit/test_client.py @@ -11,6 +11,7 @@ from sap_cloud_sdk.core.auditlog_ng.client import ( AuditClient, _fill_common_from_auth_context, + _strip_message_context_text, ) from sap_cloud_sdk.core.auditlog_ng.config import AuditLogNGConfig, SCHEMA_URL from sap_cloud_sdk.core.auditlog_ng.exceptions import ValidationError @@ -401,3 +402,56 @@ def test_does_not_overwrite_explicit_timestamp(self): def test_event_without_common_field_is_skipped(self): event = MagicMock(spec=[]) # no 'common' attribute _fill_common_from_auth_context(event) # must not raise + + +class TestStripMessageContextText: + def test_strips_prompt_and_response_text(self): + event = pb.AiPromptReceived() + event.message_context.prompt_text = "verbatim user prompt" + event.message_context.response_text = "verbatim model response" + _strip_message_context_text(event) + assert event.message_context.prompt_text == "" + assert event.message_context.response_text == "" + + def test_preserves_correlation_and_token_fields(self): + event = pb.AiPromptReceived() + event.message_context.prompt_text = "secret" + event.message_context.response_text = "secret" + event.message_context.uid = "conversation-123" + event.message_context.name = "thread-name" + event.message_context.prompt_tokens = 42 + event.message_context.completion_tokens = 7 + event.message_context.total_tokens = 49 + _strip_message_context_text(event) + assert event.message_context.uid == "conversation-123" + assert event.message_context.name == "thread-name" + assert event.message_context.prompt_tokens == 42 + assert event.message_context.completion_tokens == 7 + assert event.message_context.total_tokens == 49 + + def test_unset_message_context_is_not_created(self): + event = pb.AiPromptReceived() + _strip_message_context_text(event) + assert not event.HasField("message_context") + + def test_event_without_message_context_field_is_skipped(self): + # AiSkillSelected has no message_context field at all. + event = pb.AiSkillSelected() + _strip_message_context_text(event) # must not raise + + def test_send_strips_message_context_text_before_emit(self): + client, mock_logger, _, _, patcher, _ = _make_mocked_client() + try: + event = pb.AiPromptReceived() + event.common.tenant_id = "tenant-123" + event.message_context.prompt_text = "sensitive prompt" + event.message_context.response_text = "sensitive response" + + client.send(event) + + # The event object itself is scrubbed in place before serialization. + assert event.message_context.prompt_text == "" + assert event.message_context.response_text == "" + mock_logger.emit.assert_called_once() + finally: + patcher.stop()