diff --git a/pyrit/common/utils.py b/pyrit/common/utils.py index b01fbc8fe..2df9304a2 100644 --- a/pyrit/common/utils.py +++ b/pyrit/common/utils.py @@ -1,7 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. - +import logging +import math +import random from typing import List, Union @@ -42,3 +44,30 @@ def combine_list(list1: Union[str, List[str]], list2: Union[str, List[str]]) -> # Merge and keep only unique values combined = list(set(list1 + list2)) return combined + + +def get_random_indices(low: int, high: int, sample_ratio: float) -> list[int]: + """ + Generate a list of random indices within a given range based on a sample ratio. + + Args: + low: Lower bound of the range (inclusive). + high: Upper bound of the range (exclusive). + sample_ratio: Ratio of range to sample (0.0 to 1.0). + """ + # Special case: return empty list + if sample_ratio == 0: + return [] + + result = [] + n = math.ceil((high - low) * sample_ratio) + + # Ensure at least 1 index for non-zero sample ratio + if sample_ratio > 0 and n == 0: + n = 1 + + try: + result = random.sample(range(low, high), n) + except ValueError: + logging.getLogger(__name__).debug(f"Sample size of {n} exceeds population size of {high - low}") + return result diff --git a/pyrit/prompt_converter/__init__.py b/pyrit/prompt_converter/__init__.py index 73b57bd7c..008e752a9 100644 --- a/pyrit/prompt_converter/__init__.py +++ b/pyrit/prompt_converter/__init__.py @@ -47,6 +47,7 @@ from pyrit.prompt_converter.search_replace_converter import SearchReplaceConverter from pyrit.prompt_converter.string_join_converter import StringJoinConverter from pyrit.prompt_converter.suffix_append_converter import SuffixAppendConverter +from pyrit.prompt_converter.superscript_converter import SuperscriptConverter from pyrit.prompt_converter.tense_converter import TenseConverter from pyrit.prompt_converter.text_to_hex_converter import TextToHexConverter from pyrit.prompt_converter.tone_converter import ToneConverter @@ -102,6 +103,7 @@ "SearchReplaceConverter", "StringJoinConverter", "SuffixAppendConverter", + "SuperscriptConverter", "TextToHexConverter", "TenseConverter", "ToneConverter", diff --git a/pyrit/prompt_converter/charswap_attack_converter.py b/pyrit/prompt_converter/charswap_attack_converter.py index f009eba68..a0af2e907 100644 --- a/pyrit/prompt_converter/charswap_attack_converter.py +++ b/pyrit/prompt_converter/charswap_attack_converter.py @@ -1,18 +1,14 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -import logging -import math import random import re import string +from pyrit.common.utils import get_random_indices from pyrit.models import PromptDataType from pyrit.prompt_converter import ConverterResult, PromptConverter -# Use logger -logger = logging.getLogger(__name__) - class CharSwapGenerator(PromptConverter): """ @@ -79,14 +75,12 @@ async def convert_async(self, *, prompt: str, input_type="text") -> ConverterRes # Tokenize the prompt into words and punctuation using regex words = re.findall(r"\w+|\S+", prompt) - word_list_len = len(words) - num_perturb_words = max(1, math.ceil(word_list_len * self.word_swap_ratio)) # Copy the original word list for perturbation perturbed_word_list = words.copy() # Get random indices of words to undergo swapping - random_words_idx = self._get_n_random(0, word_list_len, num_perturb_words) + random_words_idx = get_random_indices(0, len(words), self.word_swap_ratio) # Apply perturbation by swapping characters in the selected words for idx in random_words_idx: @@ -99,15 +93,3 @@ async def convert_async(self, *, prompt: str, input_type="text") -> ConverterRes output_text = re.sub(r'\s([?.!,\'"])', r"\1", new_prompt).strip() return ConverterResult(output_text=output_text, output_type="text") - - def _get_n_random(self, low: int, high: int, n: int) -> list: - """ - Utility function to generate random indices. - Words at these indices will be subjected to perturbation. - """ - result = [] - try: - result = random.sample(range(low, high), n) - except ValueError: - logger.debug(f"[CharSwapConverter] Sample size of {n} exceeds population size of {high - low}") - return result diff --git a/pyrit/prompt_converter/superscript_converter.py b/pyrit/prompt_converter/superscript_converter.py new file mode 100644 index 000000000..c95debf7f --- /dev/null +++ b/pyrit/prompt_converter/superscript_converter.py @@ -0,0 +1,145 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import Optional + +from pyrit.common.utils import get_random_indices +from pyrit.models import PromptDataType +from pyrit.prompt_converter import ConverterResult, PromptConverter + + +class SuperscriptConverter(PromptConverter): + """ + Converts the input text to superscript text. Supports various modes for conversion. + + Supported modes: + - 'all': Converts all words. The default mode. + - 'alternate': Converts every other word. Configurable. + - 'random': Converts a random selection of words based on a percentage. + + Note: + This converter leaves characters that do not have a superscript equivalent unchanged. + """ + + def __init__( + self, + mode: Optional[str] = "all", + alternate_step: Optional[int] = 2, + random_percentage: Optional[int] = 50, + ): + """ + Initialize the SuperscriptConverter. + + Args: + mode (Optional[str]): Conversion mode - 'all', or 'alternate'. Defaults to 'all'. + alternate_step (Optional[int]): For 'alternate' mode, convert every nth word. Defaults to 2. + random_percentage (Optional[int]): For 'random' mode, percentage of words to convert. Defaults to 50. + """ + self.mode = mode + self.alternate_step = alternate_step + self.random_percentage = random_percentage + self._superscript_map = { + "0": "\u2070", + "1": "\u00b9", + "2": "\u00b2", + "3": "\u00b3", + "4": "\u2074", + "5": "\u2075", + "6": "\u2076", + "7": "\u2077", + "8": "\u2078", + "9": "\u2079", + "a": "\u1d43", + "b": "\u1d47", + "c": "\u1d9c", + "d": "\u1d48", + "e": "\u1d49", + "f": "\u1da0", + "g": "\u1d4d", + "h": "\u02b0", + "i": "\u2071", + "j": "\u02b2", + "k": "\u1d4f", + "l": "\u02e1", + "m": "\u1d50", + "n": "\u207f", + "o": "\u1d52", + "p": "\u1d56", + "r": "\u02b3", + "s": "\u02e2", + "t": "\u1d57", + "u": "\u1d58", + "v": "\u1d5b", + "w": "\u02b7", + "x": "\u02e3", + "y": "\u02b8", + "z": "\u1dbb", + "A": "\u1d2c", + "B": "\u1d2d", + "D": "\u1d30", + "E": "\u1d31", + "G": "\u1d33", + "H": "\u1d34", + "I": "\u1d35", + "J": "\u1d36", + "K": "\u1d37", + "L": "\u1d38", + "M": "\u1d39", + "N": "\u1d3a", + "O": "\u1d3c", + "P": "\u1d3e", + "R": "\u1d3f", + "T": "\u1d40", + "U": "\u1d41", + "V": "\u2c7d", + "W": "\u1d42", + "+": "\u207a", + "-": "\u207b", + "=": "\u207c", + "(": "\u207d", + ")": "\u207e", + } + + def _to_superscript(self, text: str) -> str: + return "".join(self._superscript_map.get(char, char) for char in text) + + async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text") -> ConverterResult: + if not self.input_supported(input_type): + raise ValueError("Input type not supported") + + words = prompt.split() + result = [] + + if self.mode == "alternate": + # Convert every nth word + for i, word in enumerate(words): + if i % self.alternate_step == 0: + result.append(self._to_superscript(word)) + else: + result.append(word) + + elif self.mode == "random": + # Convert random words based on percentage + word_count = len(words) + random_indices = get_random_indices(0, word_count, self.random_percentage / 100.0) + for i, word in enumerate(words): + if i in random_indices: + result.append(self._to_superscript(word)) + else: + result.append(word) + + # TODO: add more modes here + + else: + # Convert every word if mode is not recognized or it's actually 'all' + for word in words: + result.append(self._to_superscript(word)) + + converted_text = " ".join(result) + return ConverterResult(output_text=converted_text, output_type="text") + + def input_supported(self, input_type: PromptDataType) -> bool: + return input_type == "text" + + def output_supported(self, output_type: PromptDataType) -> bool: + return output_type == "text" diff --git a/tests/unit/converter/test_superscript_converter.py b/tests/unit/converter/test_superscript_converter.py new file mode 100644 index 000000000..247cbcb49 --- /dev/null +++ b/tests/unit/converter/test_superscript_converter.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import math +import random + +import pytest + +from pyrit.prompt_converter import ConverterResult, SuperscriptConverter + + +async def _check_conversion(converter, prompts, expected_outputs): + for prompt, expected_output in zip(prompts, expected_outputs): + result = await converter.convert_async(prompt=prompt, input_type="text") + assert isinstance(result, ConverterResult) + assert result.output_text == expected_output + + +@pytest.mark.asyncio +async def test_superscript_converter(): + defalut_converter = SuperscriptConverter() + await _check_conversion( + defalut_converter, + ["Let's test this converter!", "Unsupported characters stay the same: qCFQSXYZ"], + [ + "\u1d38\u1d49\u1d57'\u02e2 \u1d57\u1d49\u02e2\u1d57 \u1d57\u02b0\u2071\u02e2 " + "\u1d9c\u1d52\u207f\u1d5b\u1d49\u02b3\u1d57\u1d49\u02b3!", + "\u1d41\u207f\u02e2\u1d58\u1d56\u1d56\u1d52\u02b3\u1d57\u1d49\u1d48 " + "\u1d9c\u02b0\u1d43\u02b3\u1d43\u1d9c\u1d57\u1d49\u02b3\u02e2 " + "\u02e2\u1d57\u1d43\u02b8 \u1d57\u02b0\u1d49 \u02e2\u1d43\u1d50\u1d49: qCFQSXYZ", + ], + ) + + alternate_converter = SuperscriptConverter(mode="alternate") + await _check_conversion( + alternate_converter, + ["word1 word2 word3 word4 word5"], + ["\u02b7\u1d52\u02b3\u1d48\u00b9 word2 \u02b7\u1d52\u02b3\u1d48\u00b3 word4 \u02b7\u1d52\u02b3\u1d48\u2075"], + ) + + +@pytest.mark.asyncio +async def test_random_superscript_converter(): + full_random_converter = SuperscriptConverter(mode="random", random_percentage=100) + await _check_conversion( + full_random_converter, + ["Let's test random mode"], + [ + "\u1d38\u1d49\u1d57'\u02e2 \u1d57\u1d49\u02e2\u1d57 " + "\u02b3\u1d43\u207f\u1d48\u1d52\u1d50 \u1d50\u1d52\u1d48\u1d49" + ], + ) + zero_random_converter = SuperscriptConverter(mode="random", random_percentage=0) + await _check_conversion( + zero_random_converter, + ["Let's test random mode"], + ["Let's test random mode"], + ) + + random.seed(32) # with seed=32 and 6 words, words at [1,2,5] will be converted + half_random_converter = SuperscriptConverter(mode="random", random_percentage=50) + test_text = "one two three four five six" + expected_output = "\u1d52\u207f\u1d49 \u1d57\u02b7\u1d52 three four \u1da0\u2071\u1d5b\u1d49 six" + result = await half_random_converter.convert_async(prompt=test_text, input_type="text") + assert result.output_text == expected_output + + # Test with a longer text (37 words) and 20% conversion rate + + random.seed() + twenty_percent_converter = SuperscriptConverter(mode="random", random_percentage=20) + + long_text = ( + "Prompt: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor " + "incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud " + "exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + ) + word_count = len(long_text.split()) + assert word_count == 37 + + result = await twenty_percent_converter.convert_async(prompt=long_text, input_type="text") + original_words = long_text.split() + converted_words = result.output_text.split() + assert len(converted_words) == len(original_words) + + # Count words that were actually converted + converted_count = sum(1 for original, converted in zip(original_words, converted_words) if original != converted) + + # With 37 words and 20%, math.ceil(37 * 0.2) = 8 words should be converted + expected_conversion_count = math.ceil(word_count * 0.2) + assert converted_count == expected_conversion_count