Skip to content

Commit 8b9ad4c

Browse files
committed
[FEAT]: Add Base64Converter and Rot13Converter payload text transforms
1 parent eea314e commit 8b9ad4c

5 files changed

Lines changed: 417 additions & 1 deletion

File tree

‎rampart/converters/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
from __future__ import annotations
1010

11+
from rampart.converters.base64 import Base64Converter
1112
from rampart.converters.docx import DocxConverter
13+
from rampart.converters.rot13 import Rot13Converter
1214

13-
__all__ = ["DocxConverter"]
15+
__all__ = ["Base64Converter", "DocxConverter", "Rot13Converter"]

‎rampart/converters/base64.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Base64Converter — encode text payloads into Base64 format.
5+
6+
Adapts PyRIT's ``Base64Converter`` to RAMPART's ``PayloadConverter``
7+
protocol. Converts a text ``Payload`` into a Base64-encoded text ``Payload``.
8+
9+
PyRIT types do not leak into the public interface — callers interact
10+
only with RAMPART's ``Payload`` and ``PayloadConverter`` protocol.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import TYPE_CHECKING
16+
17+
from rampart.core.types import Payload, PayloadFormat
18+
19+
if TYPE_CHECKING:
20+
from pyrit.prompt_converter.base64_converter import (
21+
Base64Converter as _PyritBase64Converter,
22+
)
23+
24+
25+
class Base64Converter:
26+
"""Encode text payloads into Base64.
27+
28+
Thin wrapper around PyRIT's ``Base64Converter``. Accepts a
29+
RAMPART ``Payload`` (text format) and returns a new ``Payload``
30+
with ``format=TEXT``, encoded ``content``, and ``artifact=None``.
31+
32+
PyRIT's import chain is heavy, so initialization is deferred until
33+
the first ``convert_async`` call.
34+
"""
35+
36+
def __init__(self) -> None:
37+
"""Initialize with deferred PyRIT converter."""
38+
self._pyrit_converter: _PyritBase64Converter | None = None
39+
40+
def _get_converter(self) -> _PyritBase64Converter:
41+
"""Lazily import and instantiate the PyRIT converter.
42+
43+
Returns:
44+
_PyritBase64Converter: The PyRIT Base64Converter instance, either
45+
cached or newly created on first call.
46+
"""
47+
if self._pyrit_converter is None:
48+
from pyrit.prompt_converter.base64_converter import ( # ruff: ignore[import-outside-top-level]
49+
Base64Converter as _Converter,
50+
)
51+
52+
self._pyrit_converter = _Converter()
53+
return self._pyrit_converter
54+
55+
async def convert_async(self, *, payload: Payload) -> Payload:
56+
"""Convert a text payload into a Base64-encoded text payload.
57+
58+
Delegates encoding to PyRIT's ``Base64Converter``.
59+
Preserves ``payload.id`` for traceability and carries forward metadata.
60+
61+
Args:
62+
payload (Payload): A text-format payload to convert.
63+
64+
Returns:
65+
Payload: A new payload with ``format=TEXT`` and encoded content.
66+
67+
Raises:
68+
ValueError: If the payload format is not a text format.
69+
"""
70+
if not payload.format.is_text:
71+
msg = (
72+
f"Base64Converter requires a text payload, got {payload.format.value}."
73+
)
74+
raise ValueError(msg)
75+
76+
result = await self._get_converter().convert_async(
77+
prompt=payload.content,
78+
input_type="text",
79+
)
80+
81+
metadata = {**payload.metadata, "converter": "Base64Converter"}
82+
83+
return Payload(
84+
content=result.output_text,
85+
id=payload.id,
86+
format=PayloadFormat.TEXT,
87+
artifact=None,
88+
metadata=metadata,
89+
)

‎rampart/converters/rot13.py‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Rot13Converter — obfuscate text payloads with ROT13 cipher.
5+
6+
Adapts PyRIT's ``ROT13Converter`` to RAMPART's ``PayloadConverter``
7+
protocol. Converts a text ``Payload`` into a ROT13-obfuscated text ``Payload``.
8+
9+
PyRIT types do not leak into the public interface — callers interact
10+
only with RAMPART's ``Payload`` and ``PayloadConverter`` protocol.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import TYPE_CHECKING
16+
17+
from rampart.core.types import Payload, PayloadFormat
18+
19+
if TYPE_CHECKING:
20+
from pyrit.prompt_converter.rot13_converter import (
21+
ROT13Converter as _PyritROT13Converter,
22+
)
23+
24+
25+
class Rot13Converter:
26+
"""Obfuscate text payloads using the ROT13 cipher.
27+
28+
Thin wrapper around PyRIT's ``ROT13Converter``. Accepts a
29+
RAMPART ``Payload`` (text format) and returns a new ``Payload``
30+
with ``format=TEXT``, ROT13-encoded ``content``, and ``artifact=None``.
31+
32+
PyRIT's import chain is heavy, so initialization is deferred until
33+
the first ``convert_async`` call.
34+
"""
35+
36+
def __init__(self) -> None:
37+
"""Initialize with deferred PyRIT converter."""
38+
self._pyrit_converter: _PyritROT13Converter | None = None
39+
40+
def _get_converter(self) -> _PyritROT13Converter:
41+
"""Lazily import and instantiate the PyRIT converter.
42+
43+
Returns:
44+
_PyritROT13Converter: The PyRIT ROT13Converter instance, either
45+
cached or newly created on first call.
46+
"""
47+
if self._pyrit_converter is None:
48+
from pyrit.prompt_converter.rot13_converter import ( # ruff: ignore[import-outside-top-level]
49+
ROT13Converter as _Converter,
50+
)
51+
52+
self._pyrit_converter = _Converter()
53+
return self._pyrit_converter
54+
55+
async def convert_async(self, *, payload: Payload) -> Payload:
56+
"""Convert a text payload into a ROT13-obfuscated text payload.
57+
58+
Delegates transformation to PyRIT's ``ROT13Converter``.
59+
Preserves ``payload.id`` for traceability and carries forward metadata.
60+
61+
Args:
62+
payload (Payload): A text-format payload to convert.
63+
64+
Returns:
65+
Payload: A new payload with ``format=TEXT`` and ROT13-transformed content.
66+
67+
Raises:
68+
ValueError: If the payload format is not a text format.
69+
"""
70+
if not payload.format.is_text:
71+
msg = f"Rot13Converter requires a text payload, got {payload.format.value}."
72+
raise ValueError(msg)
73+
74+
result = await self._get_converter().convert_async(
75+
prompt=payload.content,
76+
input_type="text",
77+
)
78+
79+
metadata = {**payload.metadata, "converter": "Rot13Converter"}
80+
81+
return Payload(
82+
content=result.output_text,
83+
id=payload.id,
84+
format=PayloadFormat.TEXT,
85+
artifact=None,
86+
metadata=metadata,
87+
)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Tests for Base64Converter."""
5+
6+
from __future__ import annotations
7+
8+
from typing import TYPE_CHECKING
9+
from unittest.mock import AsyncMock, MagicMock, patch
10+
11+
if TYPE_CHECKING:
12+
from pathlib import Path
13+
14+
import pytest
15+
16+
from rampart.converters.base64 import Base64Converter
17+
from rampart.core.types import Payload, PayloadFormat
18+
19+
_PATCH_TARGET = "pyrit.prompt_converter.base64_converter.Base64Converter"
20+
21+
22+
def _text_payload(content: str = "test content", payload_id: str = "p-1") -> Payload:
23+
return Payload(content=content, id=payload_id)
24+
25+
26+
class TestBase64ConverterInit:
27+
"""Construction defers PyRIT import until first use."""
28+
29+
def test_no_pyrit_import_at_construction(self) -> None:
30+
with patch(_PATCH_TARGET) as mock_cls:
31+
Base64Converter()
32+
mock_cls.assert_not_called()
33+
34+
async def test_creates_pyrit_converter_on_first_use_async(self) -> None:
35+
mock_result = MagicMock(output_text="dGVzdA==", output_type="text")
36+
37+
with patch(_PATCH_TARGET) as mock_cls:
38+
mock_cls.return_value.convert_async = AsyncMock(return_value=mock_result)
39+
converter = Base64Converter()
40+
await converter.convert_async(payload=_text_payload())
41+
mock_cls.assert_called_once()
42+
43+
44+
class TestBase64ConverterConversion:
45+
"""Conversion delegates to PyRIT Base64Converter and maps result."""
46+
47+
async def test_produces_text_payload_async(self) -> None:
48+
mock_result = MagicMock(output_text="dGVzdA==", output_type="text")
49+
50+
with patch(_PATCH_TARGET) as mock_cls:
51+
mock_cls.return_value.convert_async = AsyncMock(return_value=mock_result)
52+
converter = Base64Converter()
53+
result = await converter.convert_async(payload=_text_payload())
54+
55+
assert result.format is PayloadFormat.TEXT
56+
assert result.artifact is None
57+
assert result.content == "dGVzdA=="
58+
59+
async def test_preserves_id_async(self) -> None:
60+
mock_result = MagicMock(output_text="dGVzdA==", output_type="text")
61+
62+
with patch(_PATCH_TARGET) as mock_cls:
63+
mock_cls.return_value.convert_async = AsyncMock(return_value=mock_result)
64+
converter = Base64Converter()
65+
result = await converter.convert_async(
66+
payload=_text_payload(payload_id="keep-me")
67+
)
68+
69+
assert result.id == "keep-me"
70+
71+
async def test_metadata_includes_converter_name_async(self) -> None:
72+
mock_result = MagicMock(output_text="dGVzdA==", output_type="text")
73+
74+
with patch(_PATCH_TARGET) as mock_cls:
75+
mock_cls.return_value.convert_async = AsyncMock(return_value=mock_result)
76+
converter = Base64Converter()
77+
result = await converter.convert_async(payload=_text_payload())
78+
79+
assert result.metadata["converter"] == "Base64Converter"
80+
81+
async def test_source_metadata_carried_forward_async(self) -> None:
82+
mock_result = MagicMock(output_text="dGVzdA==", output_type="text")
83+
84+
with patch(_PATCH_TARGET) as mock_cls:
85+
mock_cls.return_value.convert_async = AsyncMock(return_value=mock_result)
86+
converter = Base64Converter()
87+
source = Payload(content="x", id="m-1", metadata={"origin": "adversarial"})
88+
result = await converter.convert_async(payload=source)
89+
90+
assert result.metadata["origin"] == "adversarial"
91+
assert result.metadata["converter"] == "Base64Converter"
92+
93+
94+
class TestBase64ConverterValidation:
95+
"""Input validation."""
96+
97+
async def test_rejects_binary_payload_async(self, tmp_path: Path) -> None:
98+
artifact = tmp_path / "existing.docx"
99+
artifact.write_bytes(b"PK")
100+
101+
binary_payload = Payload(
102+
content="already docx",
103+
format=PayloadFormat.DOCX,
104+
artifact=artifact,
105+
)
106+
107+
converter = Base64Converter()
108+
with pytest.raises(ValueError, match="text payload"):
109+
await converter.convert_async(payload=binary_payload)
110+
111+
112+
class TestBase64ConverterProtocol:
113+
"""Verify the converter satisfies PayloadConverter protocol."""
114+
115+
def test_satisfies_protocol(self) -> None:
116+
from rampart.core.converter import PayloadConverter
117+
118+
converter = Base64Converter()
119+
assert isinstance(converter, PayloadConverter)

0 commit comments

Comments
 (0)