Skip to content

Commit 6b2e753

Browse files
author
Johan Broberg
committed
feat: Enhance content extraction with type validation and security logging
1 parent ae1927c commit 6b2e753

4 files changed

Lines changed: 87 additions & 13 deletions

File tree

‎docs/prd/semantic-kernel-chat-history-api-tasks.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,15 @@ The implementation is scoped as a minor version bump (1.0.0 to 1.1.0) since it a
9090

9191
**Acceptance Criteria**:
9292
- [ ] Implement `_map_author_role(self, role: AuthorRole) -> str` that converts AuthorRole enum to lowercase string
93-
- [ ] Implement `_extract_content(self, message: ChatMessageContent) -> str` that extracts text content
93+
- [ ] Implement `_extract_content(self, message: ChatMessageContent) -> str` that extracts text content with type validation
9494
- [ ] Implement `_extract_or_generate_id(self, message: ChatMessageContent, index: int) -> str` that extracts ID from metadata or generates UUID
9595
- [ ] Implement `_extract_or_generate_timestamp(self, message: ChatMessageContent, index: int) -> datetime` that extracts timestamp or uses current UTC
9696
- [ ] Implement `_convert_single_sk_message(self, message: ChatMessageContent, index: int) -> Optional[ChatHistoryMessage]` that converts a single message
9797
- [ ] Implement `_convert_sk_messages_to_chat_history(self, messages: Sequence[ChatMessageContent]) -> List[ChatHistoryMessage]` that converts all messages
9898
- [ ] All methods include appropriate debug/warning logging
9999
- [ ] Methods handle None messages gracefully (skip with warning)
100100
- [ ] Methods handle empty/whitespace content gracefully (skip with warning)
101+
- [ ] **Security**: `_extract_content` returns empty string for unexpected content types (non-string) to prevent sensitive data exposure
101102
- [ ] Copyright header is present at file top
102103

103104
**Technical Guidance**:
@@ -106,6 +107,7 @@ The implementation is scoped as a minor version bump (1.0.0 to 1.1.0) since it a
106107
- Use `message.metadata.get("id")` for existing IDs, `message.metadata.get("timestamp")` for timestamps
107108
- Support timestamp formats: datetime objects, Unix timestamps (int/float), ISO strings
108109
- Place these methods in a section marked `# PRIVATE HELPER METHODS - Message Conversion`
110+
- **Security**: In `_extract_content`, only return content if it's a string; for unexpected types (int, dict, objects), return empty string and log warning to avoid exposing sensitive data via `__str__` or `__repr__`
109111

110112
**Dependencies**: Task 1 (imports must be in place)
111113

‎docs/prd/semantic-kernel-chat-history-api.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,11 @@ The following fields SHALL be mapped from Semantic Kernel `ChatMessageContent` t
246246
|-----------------------------|----------------------------|----------------|
247247
| `metadata.get("id")` or generated UUID | `id` | Extract from metadata or generate UUID |
248248
| `role` | `role` | Convert AuthorRole enum to lowercase string |
249-
| `content` | `content` | Direct copy (must be non-empty string) |
249+
| `content` | `content` | Direct copy (must be string type and non-empty) |
250250
| `metadata.get("timestamp")` or current UTC | `timestamp` | Extract from metadata or use current UTC time |
251251

252+
**Security Note**: The `content` field MUST be a string type. If `content` is an unexpected type (not `str` or `None`), the method SHALL return empty string and log a warning. This prevents unintentionally exposing sensitive data that might be present in an object's `__str__` or `__repr__` methods.
253+
252254
### 4.4 Message Filtering Requirements
253255

254256
Messages SHALL be filtered (skipped with warning log) if they are **invalid**:
@@ -257,6 +259,7 @@ Messages SHALL be filtered (skipped with warning log) if they are **invalid**:
257259
|-----------|--------|-------------|
258260
| Message is `None` | Cannot process null message | "Skipping null message at index {index}" |
259261
| Message `content` is None, empty, or whitespace-only | Content is required for chat history | "Skipping message at index {index} with empty content" |
262+
| Message `content` is unexpected type (not `str`) | Security: prevent data exposure from object stringification | "Unexpected content type '{type}' encountered. Returning empty string to avoid potential data exposure." |
260263

261264
### 4.5 Default Behavior
262265

@@ -345,6 +348,7 @@ The implementation SHALL use the following types from the Semantic Kernel SDK:
345348
| Limit applied | INFO | "Applying limit of {limit} to {total} messages" |
346349
| Message skipped (null) | WARNING | "Skipping null message at index {index}" |
347350
| Message skipped (empty content) | WARNING | "Skipping message at index {index} with empty content" |
351+
| Unexpected content type | WARNING | "Unexpected content type '{type}' encountered. Returning empty string to avoid potential data exposure." |
348352
| All messages filtered | WARNING | "All messages were filtered out during conversion" |
349353
| ID generated | DEBUG | "Generated UUID {id} for message at index {index}" |
350354
| Timestamp generated | DEBUG | "Using current UTC time for message at index {index}" |

‎libraries/microsoft-agents-a365-tooling-extensions-semantickernel/microsoft_agents_a365/tooling/extensions/semantickernel/services/mcp_tool_registration_service.py‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -456,11 +456,26 @@ def _extract_content(self, message: ChatMessageContent) -> str:
456456
message: Semantic Kernel ChatMessageContent object.
457457
458458
Returns:
459-
Content string (may be empty).
459+
Content string (may be empty). Returns empty string for unexpected
460+
types to avoid unintentionally exposing sensitive data.
460461
"""
461-
if message.content is None:
462+
content = message.content
463+
464+
if content is None:
462465
return ""
463-
return str(message.content)
466+
467+
# If content is already a string, return it directly
468+
if isinstance(content, str):
469+
return content
470+
471+
# For unexpected types, log a warning and return empty string to avoid
472+
# unintentionally stringifying objects that might contain sensitive data
473+
content_type = type(content).__name__
474+
self._logger.warning(
475+
f"Unexpected content type '{content_type}' encountered. "
476+
"Returning empty string to avoid potential data exposure."
477+
)
478+
return ""
464479

465480
def _extract_or_generate_id(
466481
self,
@@ -517,8 +532,10 @@ def _extract_or_generate_timestamp(
517532
elif isinstance(existing_timestamp, str):
518533
try:
519534
return datetime.fromisoformat(existing_timestamp.replace("Z", "+00:00"))
520-
except ValueError:
521-
pass
535+
except (ValueError, TypeError) as ex:
536+
self._logger.debug(
537+
f"Failed to parse timestamp '{existing_timestamp}' at index {index}: {ex}"
538+
)
522539

523540
# Use current UTC time
524541
self._logger.debug(f"Using current UTC time for message at index {index}")

‎tests/tooling/extensions/semantickernel/services/test_send_chat_history.py‎

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,28 @@ def test_extract_timestamp_from_created_at(self, service):
382382

383383
assert result == timestamp
384384

385+
@pytest.mark.unit
386+
def test_extract_timestamp_logs_on_invalid_iso_string(self, service, caplog):
387+
"""Test that invalid ISO string timestamp logs debug message and generates new timestamp."""
388+
import logging
389+
390+
invalid_iso_string = "not-a-valid-timestamp"
391+
message = MockChatMessageContent(
392+
role=MockAuthorRole.USER,
393+
content="Hello",
394+
metadata={"timestamp": invalid_iso_string},
395+
)
396+
397+
with caplog.at_level(logging.DEBUG):
398+
result = service._extract_or_generate_timestamp(message, 5)
399+
400+
# Should generate a new timestamp (approximately now)
401+
assert isinstance(result, datetime)
402+
# Verify debug log was emitted
403+
assert any("Failed to parse timestamp" in record.message for record in caplog.records)
404+
assert any("not-a-valid-timestamp" in record.message for record in caplog.records)
405+
assert any("index 5" in record.message for record in caplog.records)
406+
385407

386408
# =============================================================================
387409
# SUCCESS PATH TESTS (SP-01 to SP-06)
@@ -777,17 +799,46 @@ def test_extract_content_from_none(self, service):
777799
assert result == ""
778800

779801
@pytest.mark.unit
780-
def test_extract_content_converts_to_string(self, service):
781-
"""Test that content is converted to string."""
782-
# Create a message with non-string content
802+
def test_extract_content_returns_empty_for_unexpected_type(self, service, caplog):
803+
"""Test that unexpected content types return empty string for security."""
804+
import logging
805+
806+
# Create a message with non-string content (integer)
783807
message = MockChatMessageContent(
784808
role=MockAuthorRole.USER,
785-
content=12345, # Integer
809+
content=12345, # Integer - unexpected type
786810
)
787811

788-
result = service._extract_content(message)
812+
with caplog.at_level(logging.WARNING):
813+
result = service._extract_content(message)
789814

790-
assert result == "12345"
815+
# Should return empty string to avoid potential data exposure
816+
assert result == ""
817+
818+
# Should log warning about unexpected type
819+
assert any("Unexpected content type" in record.message for record in caplog.records)
820+
assert any("int" in record.message for record in caplog.records)
821+
822+
@pytest.mark.unit
823+
def test_extract_content_logs_warning_for_dict_type(self, service, caplog):
824+
"""Test that dict content type logs warning and returns empty string."""
825+
import logging
826+
827+
# Create a message with dict content
828+
message = MockChatMessageContent(
829+
role=MockAuthorRole.USER,
830+
content={"key": "value"}, # Dict - unexpected type
831+
)
832+
833+
with caplog.at_level(logging.WARNING):
834+
result = service._extract_content(message)
835+
836+
# Should return empty string to avoid potential data exposure
837+
assert result == ""
838+
839+
# Should log warning about unexpected type
840+
assert any("Unexpected content type" in record.message for record in caplog.records)
841+
assert any("dict" in record.message for record in caplog.records)
791842

792843

793844
# =============================================================================

0 commit comments

Comments
 (0)