From 918682ea0288e66ef781a2ecc6386116b29a28e7 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 29 May 2026 20:55:27 -0700 Subject: [PATCH 1/6] BUG Stop leaking media file paths in Attack History 'Last Message' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For attacks whose final message piece is a media response (TTS / image / video / binary), the `last_message_preview` field on `AttackSummary` and `ConversationSummary` previously returned the raw absolute on-disk path, e.g. `C:\Users\\git\PyRIT\dbdata\prompt-memory-entries\ audio\1780010098266691.mp3` — leaking it into Attack History, the Home recent-attacks list, and the Conversation panel branch list. Root cause: `get_conversation_stats` in both `sqlite_memory` and `azure_sql_memory` selected `converted_value` from `PromptMemoryEntries` without consulting `converted_value_data_type`. For media-path types, `converted_value` *is* the path. Fix: also fetch `converted_value_data_type` for the last piece and run the value through a shared `format_last_message_preview` helper. Media types now render as `[Image: ]` / `[Audio: ]` / `[Video: ]` / `[File: ]`, hiding the username, install layout, and deployment topology that the absolute path exposes. Text behavior (truncation + ellipsis) is unchanged. Also promotes `_MEDIA_PATH_TYPES` (previously private to `attack_mappers`) to `pyrit.models.MEDIA_PATH_DATA_TYPES` so memory and backend layers share a single source of truth and can't drift. No DTO/API schema changes; no frontend changes required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/backend/mappers/attack_mappers.py | 7 +- pyrit/memory/_preview.py | 98 +++++++++++++++++++++++++ pyrit/memory/azure_sql_memory.py | 19 +++-- pyrit/memory/sqlite_memory.py | 20 +++-- pyrit/models/__init__.py | 3 +- pyrit/models/literals.py | 6 ++ tests/unit/memory/test_preview.py | 95 ++++++++++++++++++++++++ tests/unit/memory/test_sqlite_memory.py | 73 ++++++++++++++++++ 8 files changed, 305 insertions(+), 16 deletions(-) create mode 100644 pyrit/memory/_preview.py create mode 100644 tests/unit/memory/test_preview.py diff --git a/pyrit/backend/mappers/attack_mappers.py b/pyrit/backend/mappers/attack_mappers.py index acebb40e60..3a179bdc3d 100644 --- a/pyrit/backend/mappers/attack_mappers.py +++ b/pyrit/backend/mappers/attack_mappers.py @@ -35,7 +35,7 @@ TargetInfo, ) from pyrit.common.deprecation import print_deprecation_message -from pyrit.models import AttackResult, ChatMessageRole, PromptDataType +from pyrit.models import MEDIA_PATH_DATA_TYPES, AttackResult, ChatMessageRole, PromptDataType from pyrit.models import Message as PyritMessage from pyrit.models import MessagePiece as PyritMessagePiece from pyrit.models import Score as PyritScore @@ -50,9 +50,6 @@ # Domain → DTO (for API responses) # ============================================================================ -# Media data types whose values are file paths (local or Azure Blob URLs) -_MEDIA_PATH_TYPES = frozenset({"image_path", "audio_path", "video_path", "binary_path"}) - # --------------------------------------------------------------------------- # Azure Blob SAS token cache # --------------------------------------------------------------------------- @@ -172,7 +169,7 @@ def _resolve_media_url(*, value: Optional[str], data_type: str) -> Optional[str] The value unchanged for non-media types, a ``/api/media?path=...`` URL for local file paths, or the original value for blob URLs / data URIs. """ - if not value or data_type not in _MEDIA_PATH_TYPES: + if not value or data_type not in MEDIA_PATH_DATA_TYPES: return value # Already a URL or data URI — pass through if value.startswith(("http://", "https://", "data:")): diff --git a/pyrit/memory/_preview.py b/pyrit/memory/_preview.py new file mode 100644 index 0000000000..2242b51624 --- /dev/null +++ b/pyrit/memory/_preview.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Helpers for converting raw last-message values into human-readable previews +for ``ConversationStats``. + +Lives in its own module so the same formatting logic is shared between the +SQLite and Azure SQL memory backends. The motivating bug: ``converted_value`` +for media-path data types (``image_path`` / ``audio_path`` / ``video_path`` / +``binary_path``) is a filesystem path or blob URL. Rendering it raw in the +Attack History preview leaks the absolute on-disk location of memory +artifacts (e.g. ``C:\\Users\\\\git\\PyRIT\\dbdata\\...\\1780.mp3``). +""" + +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +from pyrit.models.literals import MEDIA_PATH_DATA_TYPES + +# Upper bound (in characters) of the raw ``converted_value`` slice fetched +# from ``PromptMemoryEntries`` for preview purposes. Large enough to fit any +# reasonable filesystem path or signed Azure Blob URL while still bounding +# the per-row payload for very long text responses. +PREVIEW_FETCH_MAX_LEN = 1024 + +# Friendly label per media-path data type. Kept here next to the formatter +# so adding a new media type only requires updating one place. +_MEDIA_LABEL: dict[str, str] = { + "image_path": "Image", + "audio_path": "Audio", + "video_path": "Video", + "binary_path": "File", +} + + +def _derive_basename(value: str) -> Optional[str]: + """ + Return a display-safe basename for *value*. + + Args: + value: A filesystem path, URL, or other reference. + + Returns: + The basename (filename portion) of *value*, or ``None`` if one can't + be derived (e.g. data URI, empty value). + """ + if not value or value.startswith("data:"): + return None + if value.startswith(("http://", "https://")): + # Strip query string (e.g. SAS tokens) before taking the basename. + parsed = urlparse(value) + name = Path(parsed.path).name + return name or None + # Local path — Path handles both POSIX and Windows separators. + return Path(value).name or None + + +def format_last_message_preview( + *, + value: Optional[str], + data_type: Optional[str], + max_len: int, +) -> Optional[str]: + """ + Build the ``ConversationStats.last_message_preview`` string from raw + storage values. + + Media-path data types are rendered as ``[Image: ]`` (and + variants) so the absolute filesystem path of memory artifacts is never + exposed through API responses or UI previews. Text-like data types pass + through with truncation and an ellipsis suffix when they exceed + *max_len*. + + Args: + value: Raw ``converted_value`` for the last piece (or ``None``). + data_type: ``converted_value_data_type`` for that piece. ``None`` + falls back to the text path. + max_len: Maximum length for text previews before truncation. + + Returns: + The formatted preview string, or ``None`` when there is nothing + meaningful to show. + """ + if data_type in MEDIA_PATH_DATA_TYPES: + # MEDIA_PATH_DATA_TYPES guarantees ``data_type`` is a key in + # ``_MEDIA_LABEL`` — both are derived from the same source list. + label = _MEDIA_LABEL[data_type] + basename = _derive_basename(value or "") + return f"[{label}: {basename}]" if basename else f"[{label}]" + + if not value: + return None + + if len(value) > max_len: + return value[:max_len] + "..." + return value diff --git a/pyrit/memory/azure_sql_memory.py b/pyrit/memory/azure_sql_memory.py index ecdee782f7..782a0d66a1 100644 --- a/pyrit/memory/azure_sql_memory.py +++ b/pyrit/memory/azure_sql_memory.py @@ -19,6 +19,7 @@ from pyrit.auth.azure_auth import AzureAuth from pyrit.common import default_values from pyrit.common.singleton import Singleton +from pyrit.memory._preview import PREVIEW_FETCH_MAX_LEN, format_last_message_preview from pyrit.memory.memory_interface import MemoryInterface from pyrit.memory.memory_models import ( AttackResultEntry, @@ -620,11 +621,17 @@ def get_conversation_stats(self, *, conversation_ids: Sequence[str]) -> dict[str pme.conversation_id, COUNT(DISTINCT pme.sequence) AS msg_count, ( - SELECT TOP 1 LEFT(p2.converted_value, {max_len + 3}) + SELECT TOP 1 LEFT(p2.converted_value, {PREVIEW_FETCH_MAX_LEN}) FROM "PromptMemoryEntries" p2 WHERE p2.conversation_id = pme.conversation_id ORDER BY p2.sequence DESC, p2.id DESC ) AS last_preview, + ( + SELECT TOP 1 p2b.converted_value_data_type + FROM "PromptMemoryEntries" p2b + WHERE p2b.conversation_id = pme.conversation_id + ORDER BY p2b.sequence DESC, p2b.id DESC + ) AS last_data_type, ( SELECT TOP 1 p3.labels FROM "PromptMemoryEntries" p3 @@ -646,11 +653,13 @@ def get_conversation_stats(self, *, conversation_ids: Sequence[str]) -> dict[str result: dict[str, ConversationStats] = {} for row in rows: - conv_id, msg_count, last_preview, raw_labels, raw_created_at = row + conv_id, msg_count, last_preview, last_data_type, raw_labels, raw_created_at = row - preview = None - if last_preview: - preview = last_preview[:max_len] + "..." if len(last_preview) > max_len else last_preview + preview = format_last_message_preview( + value=last_preview, + data_type=last_data_type, + max_len=max_len, + ) labels: dict[str, str] = {} if raw_labels and raw_labels not in ("null", "{}"): diff --git a/pyrit/memory/sqlite_memory.py b/pyrit/memory/sqlite_memory.py index 8b63fb7ca8..e1bc4827d4 100644 --- a/pyrit/memory/sqlite_memory.py +++ b/pyrit/memory/sqlite_memory.py @@ -20,6 +20,7 @@ from pyrit.common.path import DB_DATA_PATH from pyrit.common.singleton import Singleton +from pyrit.memory._preview import PREVIEW_FETCH_MAX_LEN, format_last_message_preview from pyrit.memory.memory_interface import MemoryInterface from pyrit.memory.memory_models import ( AttackResultEntry, @@ -725,12 +726,19 @@ def get_conversation_stats(self, *, conversation_ids: Sequence[str]) -> dict[str pme.conversation_id, COUNT(DISTINCT pme.sequence) AS msg_count, ( - SELECT SUBSTR(p2.converted_value, 1, {max_len + 3}) + SELECT SUBSTR(p2.converted_value, 1, {PREVIEW_FETCH_MAX_LEN}) FROM "PromptMemoryEntries" p2 WHERE p2.conversation_id = pme.conversation_id ORDER BY p2.sequence DESC, p2.id DESC LIMIT 1 ) AS last_preview, + ( + SELECT p2b.converted_value_data_type + FROM "PromptMemoryEntries" p2b + WHERE p2b.conversation_id = pme.conversation_id + ORDER BY p2b.sequence DESC, p2b.id DESC + LIMIT 1 + ) AS last_data_type, ( SELECT p3.labels FROM "PromptMemoryEntries" p3 @@ -753,11 +761,13 @@ def get_conversation_stats(self, *, conversation_ids: Sequence[str]) -> dict[str result: dict[str, ConversationStats] = {} for row in rows: - conv_id, msg_count, last_preview, raw_labels, raw_created_at = row + conv_id, msg_count, last_preview, last_data_type, raw_labels, raw_created_at = row - preview = None - if last_preview: - preview = last_preview[:max_len] + "..." if len(last_preview) > max_len else last_preview + preview = format_last_message_preview( + value=last_preview, + data_type=last_data_type, + max_len=max_len, + ) labels: dict[str, str] = {} if raw_labels and raw_labels not in ("null", "{}"): diff --git a/pyrit/models/__init__.py b/pyrit/models/__init__.py index 1a0343aa85..4f53d4cf00 100644 --- a/pyrit/models/__init__.py +++ b/pyrit/models/__init__.py @@ -32,7 +32,7 @@ ) from pyrit.models.embeddings import EmbeddingData, EmbeddingResponse, EmbeddingSupport, EmbeddingUsageInformation from pyrit.models.harm_definition import HarmDefinition, ScaleDescription, get_all_harm_definitions -from pyrit.models.literals import ChatMessageRole, PromptDataType, PromptResponseError, SeedType +from pyrit.models.literals import MEDIA_PATH_DATA_TYPES, ChatMessageRole, PromptDataType, PromptResponseError, SeedType from pyrit.models.message import ( Message, construct_response_from_request, @@ -94,6 +94,7 @@ "group_message_pieces_into_conversations", "HarmDefinition", "ImagePathDataTypeSerializer", + "MEDIA_PATH_DATA_TYPES", "Message", "MessagePiece", "NextMessageSystemPromptPaths", diff --git a/pyrit/models/literals.py b/pyrit/models/literals.py index 8c488eebe1..315e70ccdf 100644 --- a/pyrit/models/literals.py +++ b/pyrit/models/literals.py @@ -17,6 +17,12 @@ "function_call_output", ] +# Subset of ``PromptDataType`` values whose stored ``value`` is a path or URL +# pointing at media content (rather than the content itself). Useful for +# treating these specially — e.g. avoiding raw filesystem-path leaks in API +# previews, or signing blob storage URLs before exposing them to the frontend. +MEDIA_PATH_DATA_TYPES: frozenset[PromptDataType] = frozenset({"image_path", "audio_path", "video_path", "binary_path"}) + """ The type of the error in the prompt response blocked: blocked by an external filter e.g. Azure Filters diff --git a/tests/unit/memory/test_preview.py b/tests/unit/memory/test_preview.py new file mode 100644 index 0000000000..4818fb5560 --- /dev/null +++ b/tests/unit/memory/test_preview.py @@ -0,0 +1,95 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Unit tests for ``pyrit.memory._preview.format_last_message_preview``.""" + +import pytest + +from pyrit.memory._preview import PREVIEW_FETCH_MAX_LEN, format_last_message_preview + + +class TestFormatLastMessagePreview: + def test_text_short_value_passes_through(self) -> None: + result = format_last_message_preview(value="hello", data_type="text", max_len=100) + assert result == "hello" + + def test_text_long_value_is_truncated_with_ellipsis(self) -> None: + long_text = "x" * 200 + result = format_last_message_preview(value=long_text, data_type="text", max_len=100) + assert result is not None + assert len(result) == 103 + assert result.endswith("...") + assert result.startswith("x" * 100) + + def test_text_none_value_returns_none(self) -> None: + assert format_last_message_preview(value=None, data_type="text", max_len=100) is None + + def test_text_empty_value_returns_none(self) -> None: + assert format_last_message_preview(value="", data_type="text", max_len=100) is None + + def test_unknown_data_type_treated_as_text(self) -> None: + result = format_last_message_preview(value="hello", data_type=None, max_len=100) + assert result == "hello" + + @pytest.mark.parametrize( + ("data_type", "label"), + [ + ("image_path", "Image"), + ("audio_path", "Audio"), + ("video_path", "Video"), + ("binary_path", "File"), + ], + ) + def test_media_windows_absolute_path_renders_basename_only(self, data_type: str, label: str) -> None: + path = r"C:\Users\someone\git\PyRIT\dbdata\prompt-memory-entries\audio\1780010098266691.mp3" + result = format_last_message_preview(value=path, data_type=data_type, max_len=100) + assert result == f"[{label}: 1780010098266691.mp3]" + assert "C:\\" not in (result or "") + assert "Users" not in (result or "") + + def test_media_posix_absolute_path_renders_basename_only(self) -> None: + path = "/home/someone/PyRIT/dbdata/prompt-memory-entries/images/abcdef.png" + result = format_last_message_preview(value=path, data_type="image_path", max_len=100) + assert result == "[Image: abcdef.png]" + assert "/home/" not in (result or "") + + def test_media_relative_path_renders_basename(self) -> None: + result = format_last_message_preview(value="audio/foo.mp3", data_type="audio_path", max_len=100) + assert result == "[Audio: foo.mp3]" + + def test_media_azure_blob_url_strips_query_and_keeps_filename(self) -> None: + url = "https://acct.blob.core.windows.net/container/folder/file.png?sv=2024-01-01&sig=secrettoken" + result = format_last_message_preview(value=url, data_type="image_path", max_len=100) + assert result == "[Image: file.png]" + assert "sig=" not in (result or "") + assert "blob.core.windows.net" not in (result or "") + + def test_media_empty_value_falls_back_to_label_only(self) -> None: + result = format_last_message_preview(value="", data_type="image_path", max_len=100) + assert result == "[Image]" + + def test_media_none_value_falls_back_to_label_only(self) -> None: + result = format_last_message_preview(value=None, data_type="audio_path", max_len=100) + assert result == "[Audio]" + + def test_media_data_uri_falls_back_to_label_only(self) -> None: + # Defensive: data URIs aren't expected as stored converted_value for + # media-path types, but if one shows up we should not try to derive a + # nonsensical basename from it. + result = format_last_message_preview( + value="data:image/png;base64,iVBORw0KGgo=", data_type="image_path", max_len=100 + ) + assert result == "[Image]" + + def test_media_long_path_basename_not_truncated(self) -> None: + # Even with a 100-char text limit, the basename label should not be + # truncated. The PREVIEW_FETCH_MAX_LEN cap (1024 chars) is far above + # any realistic path length. + deep = "C:\\very\\deep\\nested\\directory\\structure\\that\\is\\quite\\long\\file_name_that_is_also_long.png" + result = format_last_message_preview(value=deep, data_type="image_path", max_len=20) + assert result == "[Image: file_name_that_is_also_long.png]" + + def test_preview_fetch_max_len_is_generous(self) -> None: + # Sanity check: the per-row fetch cap must be large enough to + # accommodate realistic filesystem paths and signed blob URLs. + assert PREVIEW_FETCH_MAX_LEN >= 512 diff --git a/tests/unit/memory/test_sqlite_memory.py b/tests/unit/memory/test_sqlite_memory.py index 9b5bacd33d..a3251012de 100644 --- a/tests/unit/memory/test_sqlite_memory.py +++ b/tests/unit/memory/test_sqlite_memory.py @@ -852,6 +852,79 @@ def test_get_conversation_stats_batches_multiple_conversations(sqlite_instance): assert result[conv_ids[2]].message_count == 3 +@pytest.mark.parametrize( + ("data_type", "expected_prefix"), + [ + ("image_path", "[Image:"), + ("audio_path", "[Audio:"), + ("video_path", "[Video:"), + ("binary_path", "[File:"), + ], +) +def test_get_conversation_stats_media_preview_hides_absolute_path(sqlite_instance, data_type, expected_prefix): + """Media-path last messages render as ``[Image: ]`` etc. + instead of leaking the absolute on-disk path.""" + import uuid + + from pyrit.models import MessagePiece + + conv_id = str(uuid.uuid4()) + path = r"C:\Users\someone\git\PyRIT\dbdata\prompt-memory-entries\media\1780010098266691.bin" + piece = MessagePiece( + role="assistant", + original_value=path, + original_value_data_type=data_type, + converted_value=path, + converted_value_data_type=data_type, + conversation_id=conv_id, + sequence=0, + ) + sqlite_instance._insert_entry(PromptMemoryEntry(entry=piece)) + + result = sqlite_instance.get_conversation_stats(conversation_ids=[conv_id]) + preview = result[conv_id].last_message_preview + + assert preview is not None + assert preview.startswith(expected_prefix) + assert preview.endswith("1780010098266691.bin]") + assert "C:\\" not in preview + assert "Users" not in preview + + +def test_get_conversation_stats_uses_last_piece_data_type_for_preview(sqlite_instance): + """Preview formatting picks up the data type of the most recent message, + not the first one.""" + import uuid + + from pyrit.models import MessagePiece + + conv_id = str(uuid.uuid4()) + text_piece = MessagePiece( + role="user", + original_value="hi there", + original_value_data_type="text", + converted_value="hi there", + converted_value_data_type="text", + conversation_id=conv_id, + sequence=0, + ) + audio_path = r"C:\dbdata\prompt-memory-entries\audio\response.mp3" + media_piece = MessagePiece( + role="assistant", + original_value=audio_path, + original_value_data_type="audio_path", + converted_value=audio_path, + converted_value_data_type="audio_path", + conversation_id=conv_id, + sequence=1, + ) + sqlite_instance._insert_entries(entries=[PromptMemoryEntry(entry=text_piece), PromptMemoryEntry(entry=media_piece)]) + + result = sqlite_instance.get_conversation_stats(conversation_ids=[conv_id]) + + assert result[conv_id].last_message_preview == "[Audio: response.mp3]" + + def test_dispose_engine_tolerates_closed_log_stream(sqlite_instance, capsys): """Verify dispose_engine does not raise or emit 'Logging error' when streams are closed (GH-1520).""" pyrit_logger = logging.getLogger("pyrit") From f9e3a60753857adc18e39e89ced556bd1ce38452 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 29 May 2026 21:19:02 -0700 Subject: [PATCH 2/6] refactor: move last-message preview formatting from memory to backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pyrit/memory/_preview.py` produced display strings ("[Image: ]") which are presentation-layer text — the memory layer should stay data-agnostic. SQL truncation of long values still belongs in memory (don't pull MB blobs over the wire), but English labels and basename extraction are a GUI concern and belong next to the API mappers. Changes: - Add `last_message_data_type: Optional[PromptDataType]` to `ConversationStats`. Add `PREVIEW_FETCH_MAX_LEN` ClassVar so memory backends have a documented contract for the storage-fetch cap without importing from `pyrit/backend/`. - Memory backends (sqlite + azure_sql) now populate the raw (truncated-to-1024) `last_message_preview` plus the data type. No formatting, no `[Image: ...]` labels. - Move `_preview.py` to `pyrit/backend/mappers/`. The formatter's `max_len` defaults to `ConversationStats.PREVIEW_MAX_LEN` so callers don't have to plumb it through. - `attack_mappers.attack_result_to_summary` applies the formatter when building `AttackSummary.last_message_preview`. - `attack_service.get_conversations_async` applies the formatter when building `ConversationSummary.last_message_preview`. - `attack_service.list_attacks_async` propagates the data type into the merged on-the-fly `ConversationStats` so the mapper has what it needs. Tests: - Move `test_preview.py` from `tests/unit/memory/` to `tests/unit/backend/`. - Memory tests assert raw value + correct `last_message_data_type` (formatting is no longer memory's job). The 200-char truncation test now verifies the storage-fetch cap (`PREVIEW_FETCH_MAX_LEN`) instead of the obsolete 103-char SQL output. - Add mapper tests proving media paths are formatted and never leak absolute paths through `AttackSummary`. - Add `attack_service` tests proving the formatter is applied for both `AttackSummary` (list endpoint) and `ConversationSummary` (detail endpoint). - Update `test_conversation_stats.py` to cover the new field and reject unknown data types. API contract unchanged: `AttackSummary.last_message_preview` and `ConversationSummary.last_message_preview` still carry the friendly display string; only the layer that produces them changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/{memory => backend/mappers}/_preview.py | 33 +++++------ pyrit/backend/mappers/attack_mappers.py | 6 +- pyrit/backend/services/attack_service.py | 8 ++- pyrit/memory/azure_sql_memory.py | 13 +--- pyrit/memory/sqlite_memory.py | 13 +--- pyrit/models/conversation_stats.py | 11 ++++ tests/unit/backend/test_attack_service.py | 40 +++++++++++++ tests/unit/backend/test_mappers.py | 31 +++++++++- .../unit/{memory => backend}/test_preview.py | 26 +++++--- tests/unit/memory/test_sqlite_memory.py | 59 ++++++++++--------- tests/unit/models/test_conversation_stats.py | 24 ++++++++ 11 files changed, 185 insertions(+), 79 deletions(-) rename pyrit/{memory => backend/mappers}/_preview.py (72%) rename tests/unit/{memory => backend}/test_preview.py (78%) diff --git a/pyrit/memory/_preview.py b/pyrit/backend/mappers/_preview.py similarity index 72% rename from pyrit/memory/_preview.py rename to pyrit/backend/mappers/_preview.py index 2242b51624..929e355bcc 100644 --- a/pyrit/memory/_preview.py +++ b/pyrit/backend/mappers/_preview.py @@ -2,29 +2,27 @@ # Licensed under the MIT license. """ -Helpers for converting raw last-message values into human-readable previews -for ``ConversationStats``. - -Lives in its own module so the same formatting logic is shared between the -SQLite and Azure SQL memory backends. The motivating bug: ``converted_value`` -for media-path data types (``image_path`` / ``audio_path`` / ``video_path`` / -``binary_path``) is a filesystem path or blob URL. Rendering it raw in the -Attack History preview leaks the absolute on-disk location of memory -artifacts (e.g. ``C:\\Users\\\\git\\PyRIT\\dbdata\\...\\1780.mp3``). +Presentation-layer formatter for ``ConversationStats.last_message_preview``. + +Lives in the backend mapper package because the formatting it produces +(``[Image: ]`` etc.) is purely a display concern for the GUI API +responses — the memory layer stays data-agnostic and just stores the raw +value + data type. + +The motivating bug: ``converted_value`` for media-path data types +(``image_path`` / ``audio_path`` / ``video_path`` / ``binary_path``) is a +filesystem path or blob URL. Rendering it raw in the Attack History preview +leaks the absolute on-disk location of memory artifacts +(e.g. ``C:\\Users\\\\git\\PyRIT\\dbdata\\...\\1780.mp3``). """ from pathlib import Path from typing import Optional from urllib.parse import urlparse +from pyrit.models import ConversationStats from pyrit.models.literals import MEDIA_PATH_DATA_TYPES -# Upper bound (in characters) of the raw ``converted_value`` slice fetched -# from ``PromptMemoryEntries`` for preview purposes. Large enough to fit any -# reasonable filesystem path or signed Azure Blob URL while still bounding -# the per-row payload for very long text responses. -PREVIEW_FETCH_MAX_LEN = 1024 - # Friendly label per media-path data type. Kept here next to the formatter # so adding a new media type only requires updating one place. _MEDIA_LABEL: dict[str, str] = { @@ -61,11 +59,10 @@ def format_last_message_preview( *, value: Optional[str], data_type: Optional[str], - max_len: int, + max_len: int = ConversationStats.PREVIEW_MAX_LEN, ) -> Optional[str]: """ - Build the ``ConversationStats.last_message_preview`` string from raw - storage values. + Build a display string for ``ConversationStats.last_message_preview``. Media-path data types are rendered as ``[Image: ]`` (and variants) so the absolute filesystem path of memory artifacts is never diff --git a/pyrit/backend/mappers/attack_mappers.py b/pyrit/backend/mappers/attack_mappers.py index 3a179bdc3d..60e4ea8bc3 100644 --- a/pyrit/backend/mappers/attack_mappers.py +++ b/pyrit/backend/mappers/attack_mappers.py @@ -24,6 +24,7 @@ from azure.storage.blob import ContainerSasPermissions, generate_container_sas from azure.storage.blob.aio import BlobServiceClient +from pyrit.backend.mappers._preview import format_last_message_preview from pyrit.backend.models.attacks import ( AddMessageRequest, AttackSummary, @@ -224,7 +225,10 @@ def attack_result_to_summary( AttackSummary DTO ready for the API response. """ message_count = stats.message_count - last_preview = stats.last_message_preview + last_preview = format_last_message_preview( + value=stats.last_message_preview, + data_type=stats.last_message_data_type, + ) # Merge attack-result labels with conversation-level labels. # Conversation labels take precedence on key collision. diff --git a/pyrit/backend/services/attack_service.py b/pyrit/backend/services/attack_service.py index d602f27ed1..d185f7fec8 100644 --- a/pyrit/backend/services/attack_service.py +++ b/pyrit/backend/services/attack_service.py @@ -24,6 +24,7 @@ from typing import Any, Literal, cast from urllib.parse import parse_qs, urlparse +from pyrit.backend.mappers._preview import format_last_message_preview from pyrit.backend.mappers.attack_mappers import ( attack_result_to_summary, pyrit_messages_to_dto_async, @@ -177,11 +178,13 @@ async def list_attacks_async( total_count = (main_stats.message_count if main_stats else 0) + sum(s.message_count for s in pruned_stats) preview = main_stats.last_message_preview if main_stats else None + preview_data_type = main_stats.last_message_data_type if main_stats else None conv_labels = (main_stats.labels if main_stats else None) or {} merged = ConversationStats( message_count=total_count, last_message_preview=preview, + last_message_data_type=preview_data_type, labels=conv_labels, ) @@ -419,7 +422,10 @@ async def get_conversations_async(self, *, attack_result_id: str) -> AttackConve ConversationSummary( conversation_id=conv_id, message_count=stats.message_count if stats else 0, - last_message_preview=stats.last_message_preview if stats else None, + last_message_preview=format_last_message_preview( + value=stats.last_message_preview if stats else None, + data_type=stats.last_message_data_type if stats else None, + ), created_at=created_at, ) ) diff --git a/pyrit/memory/azure_sql_memory.py b/pyrit/memory/azure_sql_memory.py index 782a0d66a1..bc4132a5cf 100644 --- a/pyrit/memory/azure_sql_memory.py +++ b/pyrit/memory/azure_sql_memory.py @@ -19,7 +19,6 @@ from pyrit.auth.azure_auth import AzureAuth from pyrit.common import default_values from pyrit.common.singleton import Singleton -from pyrit.memory._preview import PREVIEW_FETCH_MAX_LEN, format_last_message_preview from pyrit.memory.memory_interface import MemoryInterface from pyrit.memory.memory_models import ( AttackResultEntry, @@ -614,14 +613,13 @@ def get_conversation_stats(self, *, conversation_ids: Sequence[str]) -> dict[str placeholders = ", ".join(f":cid{i}" for i in range(len(conversation_ids))) params = {f"cid{i}": cid for i, cid in enumerate(conversation_ids)} - max_len = ConversationStats.PREVIEW_MAX_LEN sql = text( f""" SELECT pme.conversation_id, COUNT(DISTINCT pme.sequence) AS msg_count, ( - SELECT TOP 1 LEFT(p2.converted_value, {PREVIEW_FETCH_MAX_LEN}) + SELECT TOP 1 LEFT(p2.converted_value, {ConversationStats.PREVIEW_FETCH_MAX_LEN}) FROM "PromptMemoryEntries" p2 WHERE p2.conversation_id = pme.conversation_id ORDER BY p2.sequence DESC, p2.id DESC @@ -655,12 +653,6 @@ def get_conversation_stats(self, *, conversation_ids: Sequence[str]) -> dict[str for row in rows: conv_id, msg_count, last_preview, last_data_type, raw_labels, raw_created_at = row - preview = format_last_message_preview( - value=last_preview, - data_type=last_data_type, - max_len=max_len, - ) - labels: dict[str, str] = {} if raw_labels and raw_labels not in ("null", "{}"): with suppress(ValueError, TypeError): @@ -675,7 +667,8 @@ def get_conversation_stats(self, *, conversation_ids: Sequence[str]) -> dict[str result[conv_id] = ConversationStats( message_count=msg_count, - last_message_preview=preview, + last_message_preview=last_preview, + last_message_data_type=last_data_type, labels=labels, created_at=created_at, ) diff --git a/pyrit/memory/sqlite_memory.py b/pyrit/memory/sqlite_memory.py index e1bc4827d4..44d93c9276 100644 --- a/pyrit/memory/sqlite_memory.py +++ b/pyrit/memory/sqlite_memory.py @@ -20,7 +20,6 @@ from pyrit.common.path import DB_DATA_PATH from pyrit.common.singleton import Singleton -from pyrit.memory._preview import PREVIEW_FETCH_MAX_LEN, format_last_message_preview from pyrit.memory.memory_interface import MemoryInterface from pyrit.memory.memory_models import ( AttackResultEntry, @@ -719,14 +718,13 @@ def get_conversation_stats(self, *, conversation_ids: Sequence[str]) -> dict[str placeholders = ", ".join(f":cid{i}" for i in range(len(conversation_ids))) params = {f"cid{i}": cid for i, cid in enumerate(conversation_ids)} - max_len = ConversationStats.PREVIEW_MAX_LEN sql = text( f""" SELECT pme.conversation_id, COUNT(DISTINCT pme.sequence) AS msg_count, ( - SELECT SUBSTR(p2.converted_value, 1, {PREVIEW_FETCH_MAX_LEN}) + SELECT SUBSTR(p2.converted_value, 1, {ConversationStats.PREVIEW_FETCH_MAX_LEN}) FROM "PromptMemoryEntries" p2 WHERE p2.conversation_id = pme.conversation_id ORDER BY p2.sequence DESC, p2.id DESC @@ -763,12 +761,6 @@ def get_conversation_stats(self, *, conversation_ids: Sequence[str]) -> dict[str for row in rows: conv_id, msg_count, last_preview, last_data_type, raw_labels, raw_created_at = row - preview = format_last_message_preview( - value=last_preview, - data_type=last_data_type, - max_len=max_len, - ) - labels: dict[str, str] = {} if raw_labels and raw_labels not in ("null", "{}"): with suppress(ValueError, TypeError): @@ -783,7 +775,8 @@ def get_conversation_stats(self, *, conversation_ids: Sequence[str]) -> dict[str result[conv_id] = ConversationStats( message_count=msg_count, - last_message_preview=preview, + last_message_preview=last_preview, + last_message_data_type=last_data_type, labels=labels, created_at=created_at, ) diff --git a/pyrit/models/conversation_stats.py b/pyrit/models/conversation_stats.py index 22dcefd6ea..67b09e24be 100644 --- a/pyrit/models/conversation_stats.py +++ b/pyrit/models/conversation_stats.py @@ -6,6 +6,8 @@ from pydantic import BaseModel, ConfigDict, Field +from pyrit.models.literals import PromptDataType + class ConversationStats(BaseModel): """ @@ -17,8 +19,17 @@ class ConversationStats(BaseModel): model_config = ConfigDict(frozen=True) PREVIEW_MAX_LEN: ClassVar[int] = 100 + PREVIEW_FETCH_MAX_LEN: ClassVar[int] = 1024 + """ + Upper bound (in characters) for the raw ``last_message_preview`` value + fetched from storage. Larger than ``PREVIEW_MAX_LEN`` so that downstream + presentation code (see ``pyrit.backend.mappers._preview``) has enough + characters to extract a basename from a long media path or signed blob + URL before applying display-level truncation. + """ message_count: int = 0 last_message_preview: Optional[str] = None + last_message_data_type: Optional[PromptDataType] = None labels: dict[str, str] = Field(default_factory=dict) created_at: Optional[datetime] = None diff --git a/tests/unit/backend/test_attack_service.py b/tests/unit/backend/test_attack_service.py index b44145c7d6..4fbf2f841a 100644 --- a/tests/unit/backend/test_attack_service.py +++ b/tests/unit/backend/test_attack_service.py @@ -418,6 +418,26 @@ async def test_list_attacks_includes_labels_in_summary(self, attack_service, moc assert len(result.items) == 1 assert result.items[0].labels == {"env": "prod", "team": "red", "test_ar_label": "test_ar_value"} + async def test_list_attacks_formats_media_preview(self, attack_service, mock_memory) -> None: + """list_attacks AttackSummary previews must not leak absolute media paths.""" + ar = make_attack_result(conversation_id="attack-1") + mock_memory.get_attack_results.return_value = [ar] + path = r"C:\Users\someone\PyRIT\dbdata\prompt-memory-entries\images\1780010098266691.png" + mock_memory.get_conversation_stats.return_value = { + "attack-1": ConversationStats( + message_count=1, + last_message_preview=path, + last_message_data_type="image_path", + ), + } + + result = await attack_service.list_attacks_async() + + assert len(result.items) == 1 + preview = result.items[0].last_message_preview + assert preview == "[Image: 1780010098266691.png]" + assert "C:\\" not in (preview or "") + async def test_list_attacks_filters_by_labels_directly(self, attack_service, mock_memory) -> None: """Test that label filters are passed directly to the DB query (no legacy expansion).""" ar = make_attack_result(conversation_id="attack-canonical") @@ -1713,6 +1733,26 @@ async def test_returns_main_conversation_only(self, attack_service, mock_memory) assert len(result.conversations) == 1 assert result.conversations[0].message_count == 2 + async def test_conversation_summary_formats_media_preview(self, attack_service, mock_memory): + """ConversationSummary previews must not leak absolute media paths.""" + ar = make_attack_result(conversation_id="attack-1") + mock_memory.get_attack_results.return_value = [ar] + path = r"C:\Users\someone\PyRIT\dbdata\prompt-memory-entries\audio\1780010098266691.mp3" + mock_memory.get_conversation_stats.return_value = { + "attack-1": ConversationStats( + message_count=1, + last_message_preview=path, + last_message_data_type="audio_path", + ), + } + + result = await attack_service.get_conversations_async(attack_result_id="attack-1") + + assert result is not None + preview = result.conversations[0].last_message_preview + assert preview == "[Audio: 1780010098266691.mp3]" + assert "C:\\" not in (preview or "") + async def test_returns_main_and_related_conversations(self, attack_service, mock_memory): """Should return main and PRUNED conversations sorted by timestamp.""" from pyrit.models.conversation_reference import ConversationReference, ConversationType diff --git a/tests/unit/backend/test_mappers.py b/tests/unit/backend/test_mappers.py index d3aef9a4ed..6a8e0dd8a1 100644 --- a/tests/unit/backend/test_mappers.py +++ b/tests/unit/backend/test_mappers.py @@ -157,11 +157,11 @@ def test_empty_pieces_gives_zero_messages(self) -> None: assert summary.message_count == 0 assert summary.last_message_preview is None - def test_last_message_preview_truncated(self) -> None: - """Test that long messages are truncated in stats.""" + def test_last_message_preview_truncates_long_raw_text(self) -> None: + """The mapper applies the preview formatter, which truncates long raw text.""" ar = _make_attack_result() long_text = "x" * 200 - stats = ConversationStats(message_count=1, last_message_preview=long_text[:100] + "...") + stats = ConversationStats(message_count=1, last_message_preview=long_text, last_message_data_type="text") summary = attack_result_to_summary(ar, stats=stats) @@ -169,6 +169,31 @@ def test_last_message_preview_truncated(self) -> None: assert len(summary.last_message_preview) == 103 # 100 + "..." assert summary.last_message_preview.endswith("...") + @pytest.mark.parametrize( + ("data_type", "expected"), + [ + ("image_path", "[Image: 1780010098266691.png]"), + ("audio_path", "[Audio: 1780010098266691.png]"), + ("video_path", "[Video: 1780010098266691.png]"), + ("binary_path", "[File: 1780010098266691.png]"), + ], + ) + def test_media_last_message_preview_hides_absolute_path(self, data_type: str, expected: str) -> None: + """The mapper renders media-type previews as friendly labels rather + than leaking the raw on-disk path it receives from memory.""" + ar = _make_attack_result() + path = r"C:\Users\someone\git\PyRIT\dbdata\prompt-memory-entries\media\1780010098266691.png" + stats = ConversationStats( + message_count=1, + last_message_preview=path, + last_message_data_type=data_type, + ) + + summary = attack_result_to_summary(ar, stats=stats) + + assert summary.last_message_preview == expected + assert "C:\\" not in (summary.last_message_preview or "") + def test_labels_are_mapped(self) -> None: """Test that labels are derived from stats.""" ar = _make_attack_result() diff --git a/tests/unit/memory/test_preview.py b/tests/unit/backend/test_preview.py similarity index 78% rename from tests/unit/memory/test_preview.py rename to tests/unit/backend/test_preview.py index 4818fb5560..b5f1c00fd9 100644 --- a/tests/unit/memory/test_preview.py +++ b/tests/unit/backend/test_preview.py @@ -1,11 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Unit tests for ``pyrit.memory._preview.format_last_message_preview``.""" +"""Unit tests for ``pyrit.backend.mappers._preview.format_last_message_preview``.""" import pytest -from pyrit.memory._preview import PREVIEW_FETCH_MAX_LEN, format_last_message_preview +from pyrit.backend.mappers._preview import format_last_message_preview +from pyrit.models import ConversationStats class TestFormatLastMessagePreview: @@ -31,6 +32,15 @@ def test_unknown_data_type_treated_as_text(self) -> None: result = format_last_message_preview(value="hello", data_type=None, max_len=100) assert result == "hello" + def test_default_max_len_matches_conversation_stats_contract(self) -> None: + # The formatter's default truncation length should track the model + # constant so callers don't have to plumb it through manually. + long_text = "y" * (ConversationStats.PREVIEW_MAX_LEN + 50) + result = format_last_message_preview(value=long_text, data_type="text") + assert result is not None + assert len(result) == ConversationStats.PREVIEW_MAX_LEN + 3 + assert result.endswith("...") + @pytest.mark.parametrize( ("data_type", "label"), [ @@ -83,13 +93,13 @@ def test_media_data_uri_falls_back_to_label_only(self) -> None: def test_media_long_path_basename_not_truncated(self) -> None: # Even with a 100-char text limit, the basename label should not be - # truncated. The PREVIEW_FETCH_MAX_LEN cap (1024 chars) is far above - # any realistic path length. + # truncated. Memory layer fetches up to PREVIEW_FETCH_MAX_LEN chars so + # the basename survives even very deep paths. deep = "C:\\very\\deep\\nested\\directory\\structure\\that\\is\\quite\\long\\file_name_that_is_also_long.png" result = format_last_message_preview(value=deep, data_type="image_path", max_len=20) assert result == "[Image: file_name_that_is_also_long.png]" - def test_preview_fetch_max_len_is_generous(self) -> None: - # Sanity check: the per-row fetch cap must be large enough to - # accommodate realistic filesystem paths and signed blob URLs. - assert PREVIEW_FETCH_MAX_LEN >= 512 + def test_preview_fetch_max_len_contract_is_generous(self) -> None: + # Sanity check on the model-side constant: must be large enough to fit + # realistic filesystem paths and signed blob URLs in a single fetch. + assert ConversationStats.PREVIEW_FETCH_MAX_LEN >= 512 diff --git a/tests/unit/memory/test_sqlite_memory.py b/tests/unit/memory/test_sqlite_memory.py index a3251012de..3eddf11133 100644 --- a/tests/unit/memory/test_sqlite_memory.py +++ b/tests/unit/memory/test_sqlite_memory.py @@ -793,19 +793,24 @@ def test_get_conversation_stats_returns_labels(sqlite_instance): assert result[conv_id].labels == {"env": "prod", "source": "gui"} -def test_get_conversation_stats_preview_truncates(sqlite_instance): - """Test that last_message_preview is truncated to 100 chars + ellipsis.""" +def test_get_conversation_stats_preview_caps_raw_value_at_fetch_limit(sqlite_instance): + """Memory caps the raw last_message_preview at PREVIEW_FETCH_MAX_LEN. + + Display-level truncation to PREVIEW_MAX_LEN happens later in the backend + mapper. This test verifies the storage-fetch contract: very long values + are bounded so a multi-MB text response doesn't bloat ``ConversationStats``. + """ import uuid - from pyrit.models import MessagePiece + from pyrit.models import ConversationStats, MessagePiece conv_id = str(uuid.uuid4()) - long_text = "x" * 200 + huge_text = "x" * (ConversationStats.PREVIEW_FETCH_MAX_LEN * 3) piece = MessagePiece( role="assistant", - original_value=long_text, + original_value=huge_text, original_value_data_type="text", - converted_value=long_text, + converted_value=huge_text, converted_value_data_type="text", conversation_id=conv_id, sequence=0, @@ -817,8 +822,9 @@ def test_get_conversation_stats_preview_truncates(sqlite_instance): assert conv_id in result preview = result[conv_id].last_message_preview assert preview is not None - assert len(preview) == 103 # 100 chars + "..." - assert preview.endswith("...") + assert len(preview) == ConversationStats.PREVIEW_FETCH_MAX_LEN + assert preview == "x" * ConversationStats.PREVIEW_FETCH_MAX_LEN + assert result[conv_id].last_message_data_type == "text" def test_get_conversation_stats_batches_multiple_conversations(sqlite_instance): @@ -853,17 +859,13 @@ def test_get_conversation_stats_batches_multiple_conversations(sqlite_instance): @pytest.mark.parametrize( - ("data_type", "expected_prefix"), - [ - ("image_path", "[Image:"), - ("audio_path", "[Audio:"), - ("video_path", "[Video:"), - ("binary_path", "[File:"), - ], + "data_type", + ["image_path", "audio_path", "video_path", "binary_path"], ) -def test_get_conversation_stats_media_preview_hides_absolute_path(sqlite_instance, data_type, expected_prefix): - """Media-path last messages render as ``[Image: ]`` etc. - instead of leaking the absolute on-disk path.""" +def test_get_conversation_stats_returns_media_data_type(sqlite_instance, data_type): + """Memory exposes the raw value + data type for the last piece — the + backend mapper handles display formatting. Verifies the data type is + propagated so downstream consumers can render media previews safely.""" import uuid from pyrit.models import MessagePiece @@ -882,18 +884,17 @@ def test_get_conversation_stats_media_preview_hides_absolute_path(sqlite_instanc sqlite_instance._insert_entry(PromptMemoryEntry(entry=piece)) result = sqlite_instance.get_conversation_stats(conversation_ids=[conv_id]) - preview = result[conv_id].last_message_preview + stats = result[conv_id] - assert preview is not None - assert preview.startswith(expected_prefix) - assert preview.endswith("1780010098266691.bin]") - assert "C:\\" not in preview - assert "Users" not in preview + assert stats.last_message_data_type == data_type + # Memory returns the raw value (truncated up to PREVIEW_FETCH_MAX_LEN); + # formatting/labeling is the backend mapper's responsibility. + assert stats.last_message_preview == path -def test_get_conversation_stats_uses_last_piece_data_type_for_preview(sqlite_instance): - """Preview formatting picks up the data type of the most recent message, - not the first one.""" +def test_get_conversation_stats_uses_last_piece_data_type(sqlite_instance): + """Stats reflect the data type of the most recent message, not the + first one, so the backend mapper picks the right rendering.""" import uuid from pyrit.models import MessagePiece @@ -921,8 +922,10 @@ def test_get_conversation_stats_uses_last_piece_data_type_for_preview(sqlite_ins sqlite_instance._insert_entries(entries=[PromptMemoryEntry(entry=text_piece), PromptMemoryEntry(entry=media_piece)]) result = sqlite_instance.get_conversation_stats(conversation_ids=[conv_id]) + stats = result[conv_id] - assert result[conv_id].last_message_preview == "[Audio: response.mp3]" + assert stats.last_message_data_type == "audio_path" + assert stats.last_message_preview == audio_path def test_dispose_engine_tolerates_closed_log_stream(sqlite_instance, capsys): diff --git a/tests/unit/models/test_conversation_stats.py b/tests/unit/models/test_conversation_stats.py index 44a58a82f9..f7bb64d85d 100644 --- a/tests/unit/models/test_conversation_stats.py +++ b/tests/unit/models/test_conversation_stats.py @@ -13,6 +13,7 @@ def test_conversation_stats_defaults(): stats = ConversationStats() assert stats.message_count == 0 assert stats.last_message_preview is None + assert stats.last_message_data_type is None assert stats.labels == {} assert stats.created_at is None @@ -22,11 +23,13 @@ def test_conversation_stats_with_values(): stats = ConversationStats( message_count=5, last_message_preview="Hello world", + last_message_data_type="text", labels={"env": "test"}, created_at=now, ) assert stats.message_count == 5 assert stats.last_message_preview == "Hello world" + assert stats.last_message_data_type == "text" assert stats.labels == {"env": "test"} assert stats.created_at == now @@ -41,6 +44,27 @@ def test_conversation_stats_preview_max_len_class_var(): assert ConversationStats.PREVIEW_MAX_LEN == 100 +def test_conversation_stats_preview_fetch_max_len_class_var(): + # Storage-fetch cap must be strictly larger than the display-truncation + # cap so downstream formatters can extract a basename / preview from a + # long media path before further truncation. + assert ConversationStats.PREVIEW_FETCH_MAX_LEN > ConversationStats.PREVIEW_MAX_LEN + + +def test_conversation_stats_accepts_media_data_type(): + stats = ConversationStats( + message_count=1, + last_message_preview=r"C:\foo\bar.png", + last_message_data_type="image_path", + ) + assert stats.last_message_data_type == "image_path" + + +def test_conversation_stats_rejects_unknown_data_type(): + with pytest.raises(ValidationError): + ConversationStats(last_message_data_type="not_a_real_type") # type: ignore[arg-type] + + def test_conversation_stats_labels_default_factory(): stats1 = ConversationStats() stats2 = ConversationStats() From 1d701d4142bd0522de1c00057f93ecbdff477abc Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Sat, 30 May 2026 17:46:43 -0700 Subject: [PATCH 3/6] FIX: Use PureWindowsPath to derive basename on non-Windows hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On POSIX hosts (Linux CI, macOS), pathlib.Path treats backslashes as part of the filename, so Windows-style paths stored from a Windows host (e.g. C:\Users\...\1780.png) were passed through to the preview unchanged — defeating the SAS/absolute-path leak fix. PureWindowsPath recognises both '/' and '\' as separators on every platform, so a single code path correctly extracts the basename regardless of which host wrote the path to memory and which host renders it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/backend/mappers/_preview.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pyrit/backend/mappers/_preview.py b/pyrit/backend/mappers/_preview.py index 929e355bcc..c2bdd49098 100644 --- a/pyrit/backend/mappers/_preview.py +++ b/pyrit/backend/mappers/_preview.py @@ -16,7 +16,7 @@ (e.g. ``C:\\Users\\\\git\\PyRIT\\dbdata\\...\\1780.mp3``). """ -from pathlib import Path +from pathlib import PureWindowsPath from typing import Optional from urllib.parse import urlparse @@ -49,10 +49,12 @@ def _derive_basename(value: str) -> Optional[str]: if value.startswith(("http://", "https://")): # Strip query string (e.g. SAS tokens) before taking the basename. parsed = urlparse(value) - name = Path(parsed.path).name + name = PureWindowsPath(parsed.path).name return name or None - # Local path — Path handles both POSIX and Windows separators. - return Path(value).name or None + # Local path — PureWindowsPath treats both ``/`` and ``\`` as separators, + # so Windows-style paths stored from a Windows host are split correctly + # even when this code runs on a POSIX host (CI, Linux deployments). + return PureWindowsPath(value).name or None def format_last_message_preview( From 99d1ae14e7d3838e1187d1d33eeba095bec9602d Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 2 Jun 2026 15:46:17 -0700 Subject: [PATCH 4/6] STYLE: use PEP 604 union syntax in _preview.py Replace Optional[str] with str | None and drop the now-unused typing.Optional import. Matches the style guide convention (list[X], str | None) enforced across the codebase. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/backend/mappers/_preview.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pyrit/backend/mappers/_preview.py b/pyrit/backend/mappers/_preview.py index c2bdd49098..1a3d5b2c24 100644 --- a/pyrit/backend/mappers/_preview.py +++ b/pyrit/backend/mappers/_preview.py @@ -17,7 +17,6 @@ """ from pathlib import PureWindowsPath -from typing import Optional from urllib.parse import urlparse from pyrit.models import ConversationStats @@ -33,7 +32,7 @@ } -def _derive_basename(value: str) -> Optional[str]: +def _derive_basename(value: str) -> str | None: """ Return a display-safe basename for *value*. @@ -59,10 +58,10 @@ def _derive_basename(value: str) -> Optional[str]: def format_last_message_preview( *, - value: Optional[str], - data_type: Optional[str], + value: str | None, + data_type: str | None, max_len: int = ConversationStats.PREVIEW_MAX_LEN, -) -> Optional[str]: +) -> str | None: """ Build a display string for ``ConversationStats.last_message_preview``. From ffea6f0c77ee24f728995bf4932fdb58d2c0f415 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 2 Jun 2026 15:58:17 -0700 Subject: [PATCH 5/6] MAINT: Re-export format_last_message_preview from pyrit.backend.mappers package Addresses review feedback on PR #1865: - Collapse adjacent pyrit.models imports in _preview.py into a single line. - Expose format_last_message_preview via pyrit.backend.mappers package symbol so external consumers (attack_service, tests) no longer reach into the private _preview module. The _preview.py filename stays as an implementation-detail marker; only the package's public API is used from outside. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/backend/mappers/__init__.py | 2 ++ pyrit/backend/mappers/_preview.py | 3 +-- pyrit/backend/services/attack_service.py | 4 ++-- tests/unit/backend/test_preview.py | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pyrit/backend/mappers/__init__.py b/pyrit/backend/mappers/__init__.py index 55c8b2af83..310b04e916 100644 --- a/pyrit/backend/mappers/__init__.py +++ b/pyrit/backend/mappers/__init__.py @@ -8,6 +8,7 @@ Centralizes all translation logic so domain models can evolve independently of the API contract. """ +from pyrit.backend.mappers._preview import format_last_message_preview from pyrit.backend.mappers.attack_mappers import ( attack_result_to_summary, pyrit_messages_to_dto_async, @@ -25,6 +26,7 @@ __all__ = [ "attack_result_to_summary", "converter_object_to_instance", + "format_last_message_preview", "pyrit_messages_to_dto_async", "pyrit_scores_to_dto", "request_piece_to_pyrit_message_piece", diff --git a/pyrit/backend/mappers/_preview.py b/pyrit/backend/mappers/_preview.py index 1a3d5b2c24..33e5f1ea73 100644 --- a/pyrit/backend/mappers/_preview.py +++ b/pyrit/backend/mappers/_preview.py @@ -19,8 +19,7 @@ from pathlib import PureWindowsPath from urllib.parse import urlparse -from pyrit.models import ConversationStats -from pyrit.models.literals import MEDIA_PATH_DATA_TYPES +from pyrit.models import MEDIA_PATH_DATA_TYPES, ConversationStats # Friendly label per media-path data type. Kept here next to the formatter # so adding a new media type only requires updating one place. diff --git a/pyrit/backend/services/attack_service.py b/pyrit/backend/services/attack_service.py index d185f7fec8..49c1f25f2c 100644 --- a/pyrit/backend/services/attack_service.py +++ b/pyrit/backend/services/attack_service.py @@ -24,9 +24,9 @@ from typing import Any, Literal, cast from urllib.parse import parse_qs, urlparse -from pyrit.backend.mappers._preview import format_last_message_preview -from pyrit.backend.mappers.attack_mappers import ( +from pyrit.backend.mappers import ( attack_result_to_summary, + format_last_message_preview, pyrit_messages_to_dto_async, request_piece_to_pyrit_message_piece, request_to_pyrit_message, diff --git a/tests/unit/backend/test_preview.py b/tests/unit/backend/test_preview.py index b5f1c00fd9..29e1dc498b 100644 --- a/tests/unit/backend/test_preview.py +++ b/tests/unit/backend/test_preview.py @@ -1,11 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Unit tests for ``pyrit.backend.mappers._preview.format_last_message_preview``.""" +"""Unit tests for ``pyrit.backend.mappers.format_last_message_preview``.""" import pytest -from pyrit.backend.mappers._preview import format_last_message_preview +from pyrit.backend.mappers import format_last_message_preview from pyrit.models import ConversationStats From 08b027d8c3c43e8a20f5a1d5ca1b6816fb51af87 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 2 Jun 2026 16:23:39 -0700 Subject: [PATCH 6/6] fix: handle optional scorer_class_identifier in pyrit_scores_to_dto After the Pydantic refactor of Score, scorer_class_identifier is Optional[ComponentIdentifier]. Guard the access and fall back to 'Unknown' to match the convention used in Score.__str__ and score_utils.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/backend/mappers/attack_mappers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyrit/backend/mappers/attack_mappers.py b/pyrit/backend/mappers/attack_mappers.py index 7b5983814c..5807c27bef 100644 --- a/pyrit/backend/mappers/attack_mappers.py +++ b/pyrit/backend/mappers/attack_mappers.py @@ -298,7 +298,9 @@ def pyrit_scores_to_dto(scores: list[PyritScore]) -> list[Score]: return [ Score( score_id=str(score.id), - scorer_type=score.scorer_class_identifier.class_name, + scorer_type=( + score.scorer_class_identifier.class_name or "Unknown" if score.scorer_class_identifier else "Unknown" + ), score_type=score.score_type, score_value=score.score_value, score_category=score.score_category,