diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e2a41e..af0e5b02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- CrewAI run IDs are now derived from SHA-256 (as RFC 4122 version 4 UUIDs) instead of MD5, resolving a + SAST finding. IDs for the same inputs differ from earlier releases. + ## [0.6.1] ### Fixed diff --git a/src/splunk_ao/handlers/crewai/handler.py b/src/splunk_ao/handlers/crewai/handler.py index 2374e48a..e1c6de87 100644 --- a/src/splunk_ao/handlers/crewai/handler.py +++ b/src/splunk_ao/handlers/crewai/handler.py @@ -1,4 +1,3 @@ -import hashlib import json import logging from collections.abc import Callable @@ -15,6 +14,7 @@ from splunk_ao.logger import SplunkAOLogger from splunk_ao.schema.handlers import NodeType from splunk_ao.utils.serialization import convert_time_delta_to_ns, serialize_to_str +from splunk_ao.utils.uuid_utils import deterministic_uuid4 _logger = logging.getLogger(__name__) @@ -436,7 +436,7 @@ def on_memory_retrieval_failed(source: Any, event: MemoryRetrievalFailedEvent) - @staticmethod def _hash_to_uuid(value: str) -> UUID: """Hash a string to a deterministic UUID.""" - return UUID(hashlib.md5(value.encode()).hexdigest()) + return deterministic_uuid4(value) def _generate_run_id(self, source: Any, event: Any) -> UUID: """Generate a consistent UUID for event tracing.""" diff --git a/src/splunk_ao/utils/uuid_utils.py b/src/splunk_ao/utils/uuid_utils.py index 460a5eda..62d8c640 100644 --- a/src/splunk_ao/utils/uuid_utils.py +++ b/src/splunk_ao/utils/uuid_utils.py @@ -34,6 +34,26 @@ def is_uuid7(uuid_obj: UUID | str) -> bool: return False +def deterministic_uuid4(value: str) -> UUID: + """ + Derive a deterministic UUID4 from a string. + + The first 16 bytes of the SHA-256 digest of the UTF-8 encoded string are used, + with the RFC 4122 version and variant bits set. + + Parameters + ---------- + value : str + The string to derive the UUID from. + + Returns + ------- + UUID + A version 4 UUID that is stable for a given input. + """ + return UUID(bytes=hashlib.sha256(value.encode()).digest()[:16], version=4) + + def uuid7_to_uuid4(uuid_obj: UUID | str) -> UUID: """ Convert a UUID7 to a UUID4 by hashing its string representation. @@ -67,11 +87,7 @@ def uuid7_to_uuid4(uuid_obj: UUID | str) -> UUID: if uuid_obj.version != 7: raise ValueError(f"Input UUID is version {uuid_obj.version}, not version 7") - # Hash the UUID string using SHA-256 - hash_digest = hashlib.sha256(uuid_str.encode()).digest() - - # Use the first 16 bytes of the hash to create a version 4 UUID - new_uuid = UUID(bytes=hash_digest[:16], version=4) + new_uuid = deterministic_uuid4(uuid_str) _logger.debug(f"Converted UUID7 {uuid_str} to UUID4 {new_uuid}") return new_uuid diff --git a/tests/test_crewai_handler.py b/tests/test_crewai_handler.py index b4b8895f..b76261a6 100644 --- a/tests/test_crewai_handler.py +++ b/tests/test_crewai_handler.py @@ -1,4 +1,5 @@ import asyncio +import hashlib import inspect import sys import threading @@ -991,6 +992,20 @@ def test_generate_run_id_with_source_id(crewai_callback) -> None: assert result == source_id +def test_hash_to_uuid_is_sha256_v4() -> None: + # Given: a fixed input string + value = "agent_task" + + # When: it is hashed to a run ID + result = CrewAIEventListener._hash_to_uuid(value) + + # Then: the ID is the stable SHA-256-derived UUIDv4 + assert result == uuid.UUID("6b088e07-2934-42ca-8982-7184d89520ca") + assert result == uuid.UUID(bytes=hashlib.sha256(value.encode()).digest()[:16], version=4) + assert result.version == 4 + assert result == CrewAIEventListener._hash_to_uuid(value) + + def test_generate_run_id_with_messages(crewai_callback) -> None: """Test UUID generation from event messages.""" messages = [{"role": "user", "content": "test"}] diff --git a/tests/test_uuid_utils.py b/tests/test_uuid_utils.py index 85bd5ab3..00a0f378 100644 --- a/tests/test_uuid_utils.py +++ b/tests/test_uuid_utils.py @@ -6,7 +6,7 @@ import pytest -from splunk_ao.utils.uuid_utils import convert_uuid_if_uuid7, uuid7_to_uuid4 +from splunk_ao.utils.uuid_utils import convert_uuid_if_uuid7, deterministic_uuid4, uuid7_to_uuid4 @pytest.fixture @@ -47,3 +47,26 @@ def test_deterministic_conversion(uuid7: UUID) -> None: # All results should be identical assert result1 == result2 == result3 assert result1.version == 4 + + +def test_deterministic_uuid4_matches_pinned_sha256_value() -> None: + # Given: a fixed input string + value = "agent_task" + + # When: it is converted to a UUID + result = deterministic_uuid4(value) + + # Then: the ID is the pinned SHA-256-derived UUIDv4, independent of the implementation + assert result == UUID("6b088e07-2934-42ca-8982-7184d89520ca") + assert result.version == 4 + assert result == deterministic_uuid4(value) + + +def test_uuid7_to_uuid4_matches_pinned_sha256_value(uuid7: UUID) -> None: + # Given: a fixed UUIDv7 + # When: it is converted to a UUIDv4 + result = uuid7_to_uuid4(uuid7) + + # Then: the ID is the pinned SHA-256-derived UUIDv4, so a change to the shared helper is caught + assert result == UUID("a88ebe92-6503-45b7-bd2f-8ea97b486f95") + assert result.version == 4