Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/splunk_ao/handlers/crewai/handler.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import hashlib
import json
import logging
from collections.abc import Callable
Expand All @@ -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__)

Expand Down Expand Up @@ -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."""
Expand Down
26 changes: 21 additions & 5 deletions src/splunk_ao/utils/uuid_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions tests/test_crewai_handler.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import hashlib
import inspect
import sys
import threading
Expand Down Expand Up @@ -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"}]
Expand Down
25 changes: 24 additions & 1 deletion tests/test_uuid_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading