From 1908fec2229e4167c61c874d621918bc8a5edc8e Mon Sep 17 00:00:00 2001 From: Jiayue Date: Wed, 16 Oct 2024 21:24:44 +1100 Subject: [PATCH 01/10] add converter and test --- .../insert_punctuation_attack_converter.py | 153 ++++++++++++++++++ .../test_insert_punctuation_converter.py | 85 ++++++++++ 2 files changed, 238 insertions(+) create mode 100644 pyrit/prompt_converter/insert_punctuation_attack_converter.py create mode 100644 tests/converter/test_insert_punctuation_converter.py diff --git a/pyrit/prompt_converter/insert_punctuation_attack_converter.py b/pyrit/prompt_converter/insert_punctuation_attack_converter.py new file mode 100644 index 0000000000..b0334be0bc --- /dev/null +++ b/pyrit/prompt_converter/insert_punctuation_attack_converter.py @@ -0,0 +1,153 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import random +import string +import re +from typing import List, Optional +from pyrit.models import PromptDataType +from pyrit.prompt_converter import PromptConverter, ConverterResult + + +class InsertPunctuationGenerator(PromptConverter): + """ + Inserts punctuation into a prompt to test robustness. + Punctuation insertion: inserting single punctuations in string.punctuation. + Words in a prompt: a word does not contain any punctuation and space. + "a1b2c3" is a word; "a1 2" are 2 words; "a1,b,3" are 3 words. + """ + + def __init__(self, max_iterations: int = 10, word_swap_ratio: float = 0.2, between_words: bool = True) -> None: + """ + Initialize the converter with optional max iterations and word swap ratio. + Args: + max_iterations (int): Number of prompts to generate. Defaults to 10. + word_swap_ratio (float): Percentage of words to perturb. Defaults to 0.2. + between_words (bool): If True, insert punctuation only between words. + If False, insert punctuation within words. Defaults to True. + """ + # swap ratio cannot be 0 or larger than 1 + if not 0 < word_swap_ratio <= 1: + raise ValueError("word_swap_ratio must be between 0 and 1") + self.max_iterations = max_iterations + self.word_swap_ratio = word_swap_ratio + self.between_words = between_words + + def _is_valid_punctuation(self, punctuation_list: List[str]) -> bool: + """ + Check if all items in the list are valid punctuation characters in string.punctuation. + Space, letters, numbers, double punctuations are all invalid. + Args: + punctuation_list (List[str]): List of punctuations to validate. + Raise an ValueError if args or invalid punctuation. + """ + if not punctuation_list or not all(str in string.punctuation for str in punctuation_list): + raise ValueError(f"punctuation_list must only include single punctuations within {string.punctuation}") + + async def convert_async( + self, *, prompt: str, input_type: PromptDataType = "text", punctuation_list: Optional[List[str]] = None + ) -> ConverterResult: + """ + Convert the given prompt by inserting punctuation. + Args: + prompt (str): The text to convert. + input_type (PromptDataType): The type of input data. + punctuation_list (Optional[List[str]]): List of punctuations to use for insertion. + Returns: + ConverterResult: A ConverterResult containing the interations of modified prompts. + """ + if not self.input_supported(input_type): + raise ValueError("Input type not supported") + + # initialize default punctuation list + if not punctuation_list: + punctuation_list = [",", ".", "!", "?", ":", ";", "-"] + else: + self._is_valid_punctuation(punctuation_list) + # generate number of max_iterations modified prompts with punctuation insertions. + modified_prompts = [self._insert_punctuation(prompt, punctuation_list) for _ in range(self.max_iterations)] + # combine all modified prompts into a single result + final_prompt = "\n".join(modified_prompts) + + return ConverterResult(output_text=final_prompt, output_type="text") + + def _insert_punctuation(self, prompt: str, punctuation_list: List[str]) -> str: + """ + Insert punctuation into the prompt. + Args: + prompt (str): The text to modify. + punctuation_list (List[str]): List of punctuations for insertion. + Returns: + str: The modified prompt with inserted punctuation from helper method. + """ + # words list contains single spaces, single word without punctuations, single punctuations + words = re.findall(r"\w+|[^\w\s]|\s", prompt) + # maintains indicies for actual "words", i.e. letters and numbers not divided by punctuations + word_indices = [i for i in range(0, len(words)) if not re.match(r"\W", words[i])] + # calculate the number of insertion + num_insertions = max( + 1, round(len(word_indices) * self.word_swap_ratio) + ) # Ensure at least one punctuation is inserted + + # intert between words if between_words = True + if self.between_words: + # if there's no actual word without punctuations in the list, insert random punctuation at position 0 + return ( + self._insert_between_words(words, word_indices, num_insertions, punctuation_list) + if len(word_indices) > 0 + else random.choice(punctuation_list) + prompt + ) + else: + return self._insert_within_words(prompt, num_insertions, punctuation_list) + + def _insert_between_words( + self, words: List[str], word_indices: List[int], num_insertions: int, punctuation_list: List[str] + ) -> str: + """ + Insert punctuation between words in the prompt. + Args: + words (List[str]): List of words and punctuations. + word_indices (List[int]): Indices of the actual words without punctuations in words list. + num_insertions (int): Number of punctuations to insert. + punctuation_list (List[str]): punctuations for insertion. + + Returns: + str: The modified prompt with inserted punctuation. + """ + insert_indices = random.sample(word_indices, num_insertions) + # randomly choose num_insertions indicies from actual word indicies. + for index in insert_indices: + # either insert random punctuation before or at the end of random actual word in words list. + if random.randint(0, 1): + words[index] += random.choice(punctuation_list) + else: + words[index] = random.choice(punctuation_list) + words[index] + # join the words list and return a modified prompt + return "".join(words).strip() + + def _insert_within_words(self, prompt: str, num_insertions: int, punctuation_list: List[str]) -> str: + """ + Insert punctuation at any indicies in the prompt, can insert into a word. + Args: + promp str: The prompt string + num_insertions (int): Number of punctuations to insert. + punctuation_list (List[str]): punctuations for insertion. + Returns: + str: The modified prompt with inserted punctuation. + """ + # list of chars in the prompt string + prompt_list = list(prompt) + # store random indicies of prompt_list into insert_indicies + # if the prompt has only 0 or 1 chars, insert at the end of the prompt + insert_indices = ( + [1] if len(prompt_list) <= num_insertions else random.sample(range(0, len(prompt_list) - 1), num_insertions) + ) + + for index in insert_indices: + # insert into prompt_list at the insert_indices with random punctuation from the punctuaion_list + prompt_list.insert(index, random.choice(punctuation_list)) + + return "".join(prompt_list).strip() + + def input_supported(self, input_type: PromptDataType) -> bool: + return input_type == "text" diff --git a/tests/converter/test_insert_punctuation_converter.py b/tests/converter/test_insert_punctuation_converter.py new file mode 100644 index 0000000000..945015f8fc --- /dev/null +++ b/tests/converter/test_insert_punctuation_converter.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import re +import pytest +from pyrit.prompt_converter.insert_punctuation_attack_converter import InsertPunctuationGenerator + + +# Test for correctness +# Long prompt, short prompt, weird spacing and punctuation, non-wordy prompt, short and empty prompt +@pytest.mark.parametrize( + "input_prompt,between_words,punctuation_list,max_iterations,word_swap_ratio,expected_punctuation_count", + [ + ("Despite the rain, we decided to go hiking; it was a refreshing experience.", True, [",", "!", "]"], 3, 1, 16), + ("Quick!", False, [",", "~", "]"], 6, 1, 2), + (" Hello, world! ", True, [",", "[", "]"], 5, 0.3, 3), + ("....", True, [",", "[", ">"], 2, 0.2, 5), + ("Numbers are also words, 1234 not intuitive, not symbols $@.", True, [",", "[", "]"], 7, 0.6, 10), + ("", True, [",", "$", "]"], 2, 0.9, 1), + ("a b", False, [",", "^", "]"], 3, 1, 2), + ("I can't wait!!!", False, [",", "/", "]"], 9, 0.4, 6), + ], +) +@pytest.mark.asyncio +async def test_max_iteration_ratio( + input_prompt, between_words, punctuation_list, max_iterations, word_swap_ratio, expected_punctuation_count +): + converter = InsertPunctuationGenerator( + max_iterations=max_iterations, word_swap_ratio=word_swap_ratio, between_words=between_words + ) + result = await converter.convert_async(prompt=input_prompt, punctuation_list=punctuation_list) + modified_prompts = result.output_text.split("\n") + assert ( + len(modified_prompts) == max_iterations + ), f"Expected {max_iterations} modified prompts, got {len(modified_prompts)}" + + for modified_prompt in modified_prompts: + assert ( + punctuation_count := len(re.findall(r"[^\w\s]", modified_prompt)) + ) == expected_punctuation_count, ( + f"Expect {expected_punctuation_count} punctuations found in prompt: {punctuation_count}" + ) + + +# test default max_iteration=10, and swap ratio = 0.2 +@pytest.mark.parametrize( + "input_prompt, expected_punctuation_count", + [("count 1 2 3 4 5 6 7 8 9 and 10.", 3), ("Aha!", 2)], +) +@pytest.mark.asyncio +async def test_default_interation_swap(input_prompt, expected_punctuation_count): + converter = InsertPunctuationGenerator() + result = await converter.convert_async(prompt=input_prompt) + modified_prompts = result.output_text.split("\n") + assert len(modified_prompts) == 10, f"Expected 10 modified prompts, got {len(modified_prompts)}" + + for modified_prompt in modified_prompts: + assert ( + punctuation_count := len(re.findall(r"[^\w\s]", modified_prompt)) + ) == expected_punctuation_count, ( + f"Expect {expected_punctuation_count} punctuations found in prompt: {punctuation_count}" + ) + + +# test value error raising for invalid swap ratio +@pytest.mark.parametrize( + "word_swap_ratio", + [-0.1, 1.5], +) +@pytest.mark.asyncio +async def test_invalid_word_swap_ratio(word_swap_ratio): + with pytest.raises(ValueError): + InsertPunctuationGenerator(word_swap_ratio=word_swap_ratio) + + +# test value error raising for invalid punctuations +@pytest.mark.parametrize( + "punctuation_list", + ["~~", " ", "1", "a", "//"], +) +@pytest.mark.asyncio +async def test_invalid_punctuation_list(punctuation_list): + with pytest.raises(ValueError): + converter = InsertPunctuationGenerator() + await converter.convert_async(prompt="prompt", punctuation_list=[punctuation_list]) From e3df95cf985644a22958d7dc8058ef4adb20a92b Mon Sep 17 00:00:00 2001 From: Jiayue Date: Thu, 17 Oct 2024 10:42:02 +1100 Subject: [PATCH 02/10] change the _is_valid_punctuation to return a bool and raise ValueError if it returns false in convert_asyn --- .../insert_punctuation_attack_converter.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyrit/prompt_converter/insert_punctuation_attack_converter.py b/pyrit/prompt_converter/insert_punctuation_attack_converter.py index b0334be0bc..797a66896b 100644 --- a/pyrit/prompt_converter/insert_punctuation_attack_converter.py +++ b/pyrit/prompt_converter/insert_punctuation_attack_converter.py @@ -41,8 +41,7 @@ def _is_valid_punctuation(self, punctuation_list: List[str]) -> bool: punctuation_list (List[str]): List of punctuations to validate. Raise an ValueError if args or invalid punctuation. """ - if not punctuation_list or not all(str in string.punctuation for str in punctuation_list): - raise ValueError(f"punctuation_list must only include single punctuations within {string.punctuation}") + return bool(punctuation_list) and all(str in string.punctuation for str in punctuation_list) async def convert_async( self, *, prompt: str, input_type: PromptDataType = "text", punctuation_list: Optional[List[str]] = None @@ -62,8 +61,9 @@ async def convert_async( # initialize default punctuation list if not punctuation_list: punctuation_list = [",", ".", "!", "?", ":", ";", "-"] - else: - self._is_valid_punctuation(punctuation_list) + elif not self._is_valid_punctuation(punctuation_list): + raise ValueError(f"punctuation_list must only include single punctuations within {string.punctuation}") + # generate number of max_iterations modified prompts with punctuation insertions. modified_prompts = [self._insert_punctuation(prompt, punctuation_list) for _ in range(self.max_iterations)] # combine all modified prompts into a single result From 7b1d91cadbc922da444226b4698bdf82877666b2 Mon Sep 17 00:00:00 2001 From: Jiayue Date: Thu, 17 Oct 2024 12:07:41 +1100 Subject: [PATCH 03/10] fix typos and missing returns in docs --- .../insert_punctuation_attack_converter.py | 17 +++++++++-------- .../test_insert_punctuation_converter.py | 12 +++++++----- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/pyrit/prompt_converter/insert_punctuation_attack_converter.py b/pyrit/prompt_converter/insert_punctuation_attack_converter.py index 797a66896b..49b49870da 100644 --- a/pyrit/prompt_converter/insert_punctuation_attack_converter.py +++ b/pyrit/prompt_converter/insert_punctuation_attack_converter.py @@ -9,7 +9,7 @@ from pyrit.prompt_converter import PromptConverter, ConverterResult -class InsertPunctuationGenerator(PromptConverter): +class InsertPunctuationConverter(PromptConverter): """ Inserts punctuation into a prompt to test robustness. Punctuation insertion: inserting single punctuations in string.punctuation. @@ -39,7 +39,8 @@ def _is_valid_punctuation(self, punctuation_list: List[str]) -> bool: Space, letters, numbers, double punctuations are all invalid. Args: punctuation_list (List[str]): List of punctuations to validate. - Raise an ValueError if args or invalid punctuation. + Returns: + bool: valid list and valid punctuations """ return bool(punctuation_list) and all(str in string.punctuation for str in punctuation_list) @@ -82,14 +83,14 @@ def _insert_punctuation(self, prompt: str, punctuation_list: List[str]) -> str: """ # words list contains single spaces, single word without punctuations, single punctuations words = re.findall(r"\w+|[^\w\s]|\s", prompt) - # maintains indicies for actual "words", i.e. letters and numbers not divided by punctuations + # maintains indices for actual "words", i.e. letters and numbers not divided by punctuations word_indices = [i for i in range(0, len(words)) if not re.match(r"\W", words[i])] - # calculate the number of insertion + # calculate the number of insertions num_insertions = max( 1, round(len(word_indices) * self.word_swap_ratio) ) # Ensure at least one punctuation is inserted - # intert between words if between_words = True + # insert between words if between_words = True if self.between_words: # if there's no actual word without punctuations in the list, insert random punctuation at position 0 return ( @@ -115,7 +116,7 @@ def _insert_between_words( str: The modified prompt with inserted punctuation. """ insert_indices = random.sample(word_indices, num_insertions) - # randomly choose num_insertions indicies from actual word indicies. + # randomly choose num_insertions indices from actual word indices. for index in insert_indices: # either insert random punctuation before or at the end of random actual word in words list. if random.randint(0, 1): @@ -127,7 +128,7 @@ def _insert_between_words( def _insert_within_words(self, prompt: str, num_insertions: int, punctuation_list: List[str]) -> str: """ - Insert punctuation at any indicies in the prompt, can insert into a word. + Insert punctuation at any indices in the prompt, can insert into a word. Args: promp str: The prompt string num_insertions (int): Number of punctuations to insert. @@ -137,7 +138,7 @@ def _insert_within_words(self, prompt: str, num_insertions: int, punctuation_lis """ # list of chars in the prompt string prompt_list = list(prompt) - # store random indicies of prompt_list into insert_indicies + # store random indices of prompt_list into insert_indices # if the prompt has only 0 or 1 chars, insert at the end of the prompt insert_indices = ( [1] if len(prompt_list) <= num_insertions else random.sample(range(0, len(prompt_list) - 1), num_insertions) diff --git a/tests/converter/test_insert_punctuation_converter.py b/tests/converter/test_insert_punctuation_converter.py index 945015f8fc..1f3bcc91b3 100644 --- a/tests/converter/test_insert_punctuation_converter.py +++ b/tests/converter/test_insert_punctuation_converter.py @@ -3,7 +3,7 @@ import re import pytest -from pyrit.prompt_converter.insert_punctuation_attack_converter import InsertPunctuationGenerator +from pyrit.prompt_converter.insert_punctuation_attack_converter import InsertPunctuationConverter # Test for correctness @@ -25,11 +25,13 @@ async def test_max_iteration_ratio( input_prompt, between_words, punctuation_list, max_iterations, word_swap_ratio, expected_punctuation_count ): - converter = InsertPunctuationGenerator( + converter = InsertPunctuationConverter( max_iterations=max_iterations, word_swap_ratio=word_swap_ratio, between_words=between_words ) result = await converter.convert_async(prompt=input_prompt, punctuation_list=punctuation_list) + print(result) modified_prompts = result.output_text.split("\n") + print(type(modified_prompts)) assert ( len(modified_prompts) == max_iterations ), f"Expected {max_iterations} modified prompts, got {len(modified_prompts)}" @@ -49,7 +51,7 @@ async def test_max_iteration_ratio( ) @pytest.mark.asyncio async def test_default_interation_swap(input_prompt, expected_punctuation_count): - converter = InsertPunctuationGenerator() + converter = InsertPunctuationConverter() result = await converter.convert_async(prompt=input_prompt) modified_prompts = result.output_text.split("\n") assert len(modified_prompts) == 10, f"Expected 10 modified prompts, got {len(modified_prompts)}" @@ -70,7 +72,7 @@ async def test_default_interation_swap(input_prompt, expected_punctuation_count) @pytest.mark.asyncio async def test_invalid_word_swap_ratio(word_swap_ratio): with pytest.raises(ValueError): - InsertPunctuationGenerator(word_swap_ratio=word_swap_ratio) + InsertPunctuationConverter(word_swap_ratio=word_swap_ratio) # test value error raising for invalid punctuations @@ -81,5 +83,5 @@ async def test_invalid_word_swap_ratio(word_swap_ratio): @pytest.mark.asyncio async def test_invalid_punctuation_list(punctuation_list): with pytest.raises(ValueError): - converter = InsertPunctuationGenerator() + converter = InsertPunctuationConverter() await converter.convert_async(prompt="prompt", punctuation_list=[punctuation_list]) From ce163cd09f5a8e0a260bfd218ca5598482c8bdec Mon Sep 17 00:00:00 2001 From: Jiayue Date: Fri, 18 Oct 2024 16:08:08 +1100 Subject: [PATCH 04/10] improve logics and formats. Making default_punctuation_list class variable --- .../insert_punctuation_attack_converter.py | 54 ++++++++++++------- .../test_insert_punctuation_converter.py | 4 +- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/pyrit/prompt_converter/insert_punctuation_attack_converter.py b/pyrit/prompt_converter/insert_punctuation_attack_converter.py index 49b49870da..4f853324a5 100644 --- a/pyrit/prompt_converter/insert_punctuation_attack_converter.py +++ b/pyrit/prompt_converter/insert_punctuation_attack_converter.py @@ -5,6 +5,7 @@ import string import re from typing import List, Optional + from pyrit.models import PromptDataType from pyrit.prompt_converter import PromptConverter, ConverterResult @@ -17,6 +18,8 @@ class InsertPunctuationConverter(PromptConverter): "a1b2c3" is a word; "a1 2" are 2 words; "a1,b,3" are 3 words. """ + default_punctuation_list = [",", ".", "!", "?", ":", ";", "-"] + def __init__(self, max_iterations: int = 10, word_swap_ratio: float = 0.2, between_words: bool = True) -> None: """ Initialize the converter with optional max iterations and word swap ratio. @@ -28,10 +31,15 @@ def __init__(self, max_iterations: int = 10, word_swap_ratio: float = 0.2, betwe """ # swap ratio cannot be 0 or larger than 1 if not 0 < word_swap_ratio <= 1: - raise ValueError("word_swap_ratio must be between 0 and 1") - self.max_iterations = max_iterations - self.word_swap_ratio = word_swap_ratio - self.between_words = between_words + raise ValueError("word_swap_ratio must be between 0 to 1, as (0, 1].") + + # max iterations should be at least 1 + if max_iterations < 1: + raise ValueError("max_iterations must be greater than 0.") + + self._max_iterations = max_iterations + self._word_swap_ratio = word_swap_ratio + self._between_words = between_words def _is_valid_punctuation(self, punctuation_list: List[str]) -> bool: """ @@ -42,7 +50,7 @@ def _is_valid_punctuation(self, punctuation_list: List[str]) -> bool: Returns: bool: valid list and valid punctuations """ - return bool(punctuation_list) and all(str in string.punctuation for str in punctuation_list) + return all(str in string.punctuation for str in punctuation_list) async def convert_async( self, *, prompt: str, input_type: PromptDataType = "text", punctuation_list: Optional[List[str]] = None @@ -60,13 +68,21 @@ async def convert_async( raise ValueError("Input type not supported") # initialize default punctuation list - if not punctuation_list: - punctuation_list = [",", ".", "!", "?", ":", ";", "-"] + # if not specified, defaults to defaul_punctuation_list + if punctuation_list is None: + punctuation_list = self.default_punctuation_list + # if empty, raise ValueError + elif not punctuation_list: + raise ValueError("punctuation_list cannot be empty") + # check if provided punctuations are valid elif not self._is_valid_punctuation(punctuation_list): - raise ValueError(f"punctuation_list must only include single punctuations within {string.punctuation}") + raise ValueError( + f"Invalid punctuations: {punctuation_list}.\ + Only single characters from {string.punctuation} are allowed." + ) # generate number of max_iterations modified prompts with punctuation insertions. - modified_prompts = [self._insert_punctuation(prompt, punctuation_list) for _ in range(self.max_iterations)] + modified_prompts = [self._insert_punctuation(prompt, punctuation_list) for _ in range(self._max_iterations)] # combine all modified prompts into a single result final_prompt = "\n".join(modified_prompts) @@ -87,17 +103,15 @@ def _insert_punctuation(self, prompt: str, punctuation_list: List[str]) -> str: word_indices = [i for i in range(0, len(words)) if not re.match(r"\W", words[i])] # calculate the number of insertions num_insertions = max( - 1, round(len(word_indices) * self.word_swap_ratio) + 1, round(len(word_indices) * self._word_swap_ratio) ) # Ensure at least one punctuation is inserted - # insert between words if between_words = True - if self.between_words: - # if there's no actual word without punctuations in the list, insert random punctuation at position 0 - return ( - self._insert_between_words(words, word_indices, num_insertions, punctuation_list) - if len(word_indices) > 0 - else random.choice(punctuation_list) + prompt - ) + # if there's no actual word without punctuations in the list, insert random punctuation at position 0 + if not word_indices: + return random.choice(punctuation_list) + prompt + # insert between words if _between_words = True + if self._between_words: + return self._insert_between_words(words, word_indices, num_insertions, punctuation_list) else: return self._insert_within_words(prompt, num_insertions, punctuation_list) @@ -116,10 +130,12 @@ def _insert_between_words( str: The modified prompt with inserted punctuation. """ insert_indices = random.sample(word_indices, num_insertions) + INSERT_BEFORE = 0 + INSERT_AFTER = 1 # randomly choose num_insertions indices from actual word indices. for index in insert_indices: # either insert random punctuation before or at the end of random actual word in words list. - if random.randint(0, 1): + if random.randint(INSERT_BEFORE, INSERT_AFTER) == INSERT_AFTER: words[index] += random.choice(punctuation_list) else: words[index] = random.choice(punctuation_list) + words[index] diff --git a/tests/converter/test_insert_punctuation_converter.py b/tests/converter/test_insert_punctuation_converter.py index 1f3bcc91b3..07182179fe 100644 --- a/tests/converter/test_insert_punctuation_converter.py +++ b/tests/converter/test_insert_punctuation_converter.py @@ -29,14 +29,13 @@ async def test_max_iteration_ratio( max_iterations=max_iterations, word_swap_ratio=word_swap_ratio, between_words=between_words ) result = await converter.convert_async(prompt=input_prompt, punctuation_list=punctuation_list) - print(result) modified_prompts = result.output_text.split("\n") - print(type(modified_prompts)) assert ( len(modified_prompts) == max_iterations ), f"Expected {max_iterations} modified prompts, got {len(modified_prompts)}" for modified_prompt in modified_prompts: + print(modified_prompt) assert ( punctuation_count := len(re.findall(r"[^\w\s]", modified_prompt)) ) == expected_punctuation_count, ( @@ -57,6 +56,7 @@ async def test_default_interation_swap(input_prompt, expected_punctuation_count) assert len(modified_prompts) == 10, f"Expected 10 modified prompts, got {len(modified_prompts)}" for modified_prompt in modified_prompts: + print(modified_prompt) assert ( punctuation_count := len(re.findall(r"[^\w\s]", modified_prompt)) ) == expected_punctuation_count, ( From 70add5d5e738f49eea41131f086bcfb4c609a93e Mon Sep 17 00:00:00 2001 From: Jiayue Date: Fri, 18 Oct 2024 16:08:39 +1100 Subject: [PATCH 05/10] improve logics and formats. Making default_punctuation_list class variable --- pyrit/prompt_converter/insert_punctuation_attack_converter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyrit/prompt_converter/insert_punctuation_attack_converter.py b/pyrit/prompt_converter/insert_punctuation_attack_converter.py index 4f853324a5..4a93f87fc7 100644 --- a/pyrit/prompt_converter/insert_punctuation_attack_converter.py +++ b/pyrit/prompt_converter/insert_punctuation_attack_converter.py @@ -77,8 +77,8 @@ async def convert_async( # check if provided punctuations are valid elif not self._is_valid_punctuation(punctuation_list): raise ValueError( - f"Invalid punctuations: {punctuation_list}.\ - Only single characters from {string.punctuation} are allowed." + f"Invalid punctuations: {punctuation_list}." + f" Only single characters from {string.punctuation} are allowed." ) # generate number of max_iterations modified prompts with punctuation insertions. From 60cfd374b12d0c3fe3378795d784b54dd2dc5ec0 Mon Sep 17 00:00:00 2001 From: Jiayue Date: Fri, 18 Oct 2024 17:43:45 +1100 Subject: [PATCH 06/10] fix typos. remove unnecessary comments. --- .../insert_punctuation_attack_converter.py | 37 +++++++++---------- .../test_insert_punctuation_converter.py | 8 ++-- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/pyrit/prompt_converter/insert_punctuation_attack_converter.py b/pyrit/prompt_converter/insert_punctuation_attack_converter.py index 4a93f87fc7..c20386e1d6 100644 --- a/pyrit/prompt_converter/insert_punctuation_attack_converter.py +++ b/pyrit/prompt_converter/insert_punctuation_attack_converter.py @@ -29,11 +29,11 @@ def __init__(self, max_iterations: int = 10, word_swap_ratio: float = 0.2, betwe between_words (bool): If True, insert punctuation only between words. If False, insert punctuation within words. Defaults to True. """ - # swap ratio cannot be 0 or larger than 1 + # Swap ratio cannot be 0 or larger than 1 if not 0 < word_swap_ratio <= 1: raise ValueError("word_swap_ratio must be between 0 to 1, as (0, 1].") - # max iterations should be at least 1 + # Max iterations should be at least 1 if max_iterations < 1: raise ValueError("max_iterations must be greater than 0.") @@ -67,23 +67,21 @@ async def convert_async( if not self.input_supported(input_type): raise ValueError("Input type not supported") - # initialize default punctuation list - # if not specified, defaults to defaul_punctuation_list + # Initialize default punctuation list + # If not specified, defaults to default_punctuation_list if punctuation_list is None: punctuation_list = self.default_punctuation_list - # if empty, raise ValueError elif not punctuation_list: raise ValueError("punctuation_list cannot be empty") - # check if provided punctuations are valid elif not self._is_valid_punctuation(punctuation_list): raise ValueError( f"Invalid punctuations: {punctuation_list}." f" Only single characters from {string.punctuation} are allowed." ) - # generate number of max_iterations modified prompts with punctuation insertions. + # Generate number of max_iterations modified prompts with punctuation insertions. modified_prompts = [self._insert_punctuation(prompt, punctuation_list) for _ in range(self._max_iterations)] - # combine all modified prompts into a single result + # Combine all modified prompts into a single result final_prompt = "\n".join(modified_prompts) return ConverterResult(output_text=final_prompt, output_type="text") @@ -97,19 +95,19 @@ def _insert_punctuation(self, prompt: str, punctuation_list: List[str]) -> str: Returns: str: The modified prompt with inserted punctuation from helper method. """ - # words list contains single spaces, single word without punctuations, single punctuations + # Words list contains single spaces, single word without punctuations, single punctuations words = re.findall(r"\w+|[^\w\s]|\s", prompt) - # maintains indices for actual "words", i.e. letters and numbers not divided by punctuations + # Maintains indices for actual "words", i.e. letters and numbers not divided by punctuations word_indices = [i for i in range(0, len(words)) if not re.match(r"\W", words[i])] - # calculate the number of insertions + # Calculate the number of insertions num_insertions = max( 1, round(len(word_indices) * self._word_swap_ratio) ) # Ensure at least one punctuation is inserted - # if there's no actual word without punctuations in the list, insert random punctuation at position 0 + # If there's no actual word without punctuations in the list, insert random punctuation at position 0 if not word_indices: return random.choice(punctuation_list) + prompt - # insert between words if _between_words = True + if self._between_words: return self._insert_between_words(words, word_indices, num_insertions, punctuation_list) else: @@ -130,16 +128,15 @@ def _insert_between_words( str: The modified prompt with inserted punctuation. """ insert_indices = random.sample(word_indices, num_insertions) + # Randomly choose num_insertions indices from actual word indices. INSERT_BEFORE = 0 INSERT_AFTER = 1 - # randomly choose num_insertions indices from actual word indices. for index in insert_indices: - # either insert random punctuation before or at the end of random actual word in words list. if random.randint(INSERT_BEFORE, INSERT_AFTER) == INSERT_AFTER: words[index] += random.choice(punctuation_list) else: words[index] = random.choice(punctuation_list) + words[index] - # join the words list and return a modified prompt + # Join the words list and return a modified prompt return "".join(words).strip() def _insert_within_words(self, prompt: str, num_insertions: int, punctuation_list: List[str]) -> str: @@ -152,16 +149,16 @@ def _insert_within_words(self, prompt: str, num_insertions: int, punctuation_lis Returns: str: The modified prompt with inserted punctuation. """ - # list of chars in the prompt string + # List of chars in the prompt string prompt_list = list(prompt) - # store random indices of prompt_list into insert_indices - # if the prompt has only 0 or 1 chars, insert at the end of the prompt + # Store random indices of prompt_list into insert_indices + # If the prompt has only 0 or 1 chars, insert at the end of the prompt insert_indices = ( [1] if len(prompt_list) <= num_insertions else random.sample(range(0, len(prompt_list) - 1), num_insertions) ) for index in insert_indices: - # insert into prompt_list at the insert_indices with random punctuation from the punctuaion_list + # Insert into prompt_list at the insert_indices with random punctuation from the punctuation_list prompt_list.insert(index, random.choice(punctuation_list)) return "".join(prompt_list).strip() diff --git a/tests/converter/test_insert_punctuation_converter.py b/tests/converter/test_insert_punctuation_converter.py index 07182179fe..351c32e1e4 100644 --- a/tests/converter/test_insert_punctuation_converter.py +++ b/tests/converter/test_insert_punctuation_converter.py @@ -35,7 +35,6 @@ async def test_max_iteration_ratio( ), f"Expected {max_iterations} modified prompts, got {len(modified_prompts)}" for modified_prompt in modified_prompts: - print(modified_prompt) assert ( punctuation_count := len(re.findall(r"[^\w\s]", modified_prompt)) ) == expected_punctuation_count, ( @@ -43,7 +42,7 @@ async def test_max_iteration_ratio( ) -# test default max_iteration=10, and swap ratio = 0.2 +# Test default max_iterations =10, and swap ratio = 0.2 @pytest.mark.parametrize( "input_prompt, expected_punctuation_count", [("count 1 2 3 4 5 6 7 8 9 and 10.", 3), ("Aha!", 2)], @@ -56,7 +55,6 @@ async def test_default_interation_swap(input_prompt, expected_punctuation_count) assert len(modified_prompts) == 10, f"Expected 10 modified prompts, got {len(modified_prompts)}" for modified_prompt in modified_prompts: - print(modified_prompt) assert ( punctuation_count := len(re.findall(r"[^\w\s]", modified_prompt)) ) == expected_punctuation_count, ( @@ -64,7 +62,7 @@ async def test_default_interation_swap(input_prompt, expected_punctuation_count) ) -# test value error raising for invalid swap ratio +# Test value error raising for invalid swap ratio @pytest.mark.parametrize( "word_swap_ratio", [-0.1, 1.5], @@ -75,7 +73,7 @@ async def test_invalid_word_swap_ratio(word_swap_ratio): InsertPunctuationConverter(word_swap_ratio=word_swap_ratio) -# test value error raising for invalid punctuations +# Test value error raising for invalid punctuations @pytest.mark.parametrize( "punctuation_list", ["~~", " ", "1", "a", "//"], From 1c129c9c75ceaad65874b36b033ebd03f5943dba Mon Sep 17 00:00:00 2001 From: Jiayue Date: Fri, 18 Oct 2024 17:47:55 +1100 Subject: [PATCH 07/10] fix typo --- tests/converter/test_insert_punctuation_converter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/converter/test_insert_punctuation_converter.py b/tests/converter/test_insert_punctuation_converter.py index 351c32e1e4..27ae4f52d8 100644 --- a/tests/converter/test_insert_punctuation_converter.py +++ b/tests/converter/test_insert_punctuation_converter.py @@ -48,7 +48,7 @@ async def test_max_iteration_ratio( [("count 1 2 3 4 5 6 7 8 9 and 10.", 3), ("Aha!", 2)], ) @pytest.mark.asyncio -async def test_default_interation_swap(input_prompt, expected_punctuation_count): +async def test_default_iteration_swap(input_prompt, expected_punctuation_count): converter = InsertPunctuationConverter() result = await converter.convert_async(prompt=input_prompt) modified_prompts = result.output_text.split("\n") From 55fdf91978f29328939d1ba8e7e33ea5b5f4b454 Mon Sep 17 00:00:00 2001 From: Jiayue Date: Fri, 18 Oct 2024 18:19:39 +1100 Subject: [PATCH 08/10] change convert_async logic: should only convert 1 interation for each time calling convert_asnyc --- .../insert_punctuation_attack_converter.py | 10 ++---- .../test_insert_punctuation_converter.py | 32 +++++++------------ 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/pyrit/prompt_converter/insert_punctuation_attack_converter.py b/pyrit/prompt_converter/insert_punctuation_attack_converter.py index c20386e1d6..7c7fed9343 100644 --- a/pyrit/prompt_converter/insert_punctuation_attack_converter.py +++ b/pyrit/prompt_converter/insert_punctuation_attack_converter.py @@ -62,7 +62,7 @@ async def convert_async( input_type (PromptDataType): The type of input data. punctuation_list (Optional[List[str]]): List of punctuations to use for insertion. Returns: - ConverterResult: A ConverterResult containing the interations of modified prompts. + ConverterResult: A ConverterResult containing a interation of modified prompts. """ if not self.input_supported(input_type): raise ValueError("Input type not supported") @@ -79,12 +79,8 @@ async def convert_async( f" Only single characters from {string.punctuation} are allowed." ) - # Generate number of max_iterations modified prompts with punctuation insertions. - modified_prompts = [self._insert_punctuation(prompt, punctuation_list) for _ in range(self._max_iterations)] - # Combine all modified prompts into a single result - final_prompt = "\n".join(modified_prompts) - - return ConverterResult(output_text=final_prompt, output_type="text") + modified_prompt = self._insert_punctuation(prompt, punctuation_list) + return ConverterResult(output_text=modified_prompt, output_type="text") def _insert_punctuation(self, prompt: str, punctuation_list: List[str]) -> str: """ diff --git a/tests/converter/test_insert_punctuation_converter.py b/tests/converter/test_insert_punctuation_converter.py index 27ae4f52d8..e13e73c4a3 100644 --- a/tests/converter/test_insert_punctuation_converter.py +++ b/tests/converter/test_insert_punctuation_converter.py @@ -22,24 +22,19 @@ ], ) @pytest.mark.asyncio -async def test_max_iteration_ratio( +async def test_word_swap_ratio( input_prompt, between_words, punctuation_list, max_iterations, word_swap_ratio, expected_punctuation_count ): converter = InsertPunctuationConverter( max_iterations=max_iterations, word_swap_ratio=word_swap_ratio, between_words=between_words ) result = await converter.convert_async(prompt=input_prompt, punctuation_list=punctuation_list) - modified_prompts = result.output_text.split("\n") + modified_prompt = result.output_text assert ( - len(modified_prompts) == max_iterations - ), f"Expected {max_iterations} modified prompts, got {len(modified_prompts)}" - - for modified_prompt in modified_prompts: - assert ( - punctuation_count := len(re.findall(r"[^\w\s]", modified_prompt)) - ) == expected_punctuation_count, ( - f"Expect {expected_punctuation_count} punctuations found in prompt: {punctuation_count}" - ) + punctuation_count := len(re.findall(r"[^\w\s]", modified_prompt)) + ) == expected_punctuation_count, ( + f"Expect {expected_punctuation_count} punctuations found in prompt: {punctuation_count}" + ) # Test default max_iterations =10, and swap ratio = 0.2 @@ -51,15 +46,12 @@ async def test_max_iteration_ratio( async def test_default_iteration_swap(input_prompt, expected_punctuation_count): converter = InsertPunctuationConverter() result = await converter.convert_async(prompt=input_prompt) - modified_prompts = result.output_text.split("\n") - assert len(modified_prompts) == 10, f"Expected 10 modified prompts, got {len(modified_prompts)}" - - for modified_prompt in modified_prompts: - assert ( - punctuation_count := len(re.findall(r"[^\w\s]", modified_prompt)) - ) == expected_punctuation_count, ( - f"Expect {expected_punctuation_count} punctuations found in prompt: {punctuation_count}" - ) + modified_prompt = result.output_text + assert ( + punctuation_count := len(re.findall(r"[^\w\s]", modified_prompt)) + ) == expected_punctuation_count, ( + f"Expect {expected_punctuation_count} punctuations found in prompt: {punctuation_count}" + ) # Test value error raising for invalid swap ratio From 3e8ebb92ffcf64f4ec8ed3bb7563cf8ff0a7424f Mon Sep 17 00:00:00 2001 From: Jiayue Date: Sun, 10 Nov 2024 14:10:28 +1100 Subject: [PATCH 09/10] fixed file name. removed instance max_iteration. --- ...ter.py => insert_punctuation_converter.py} | 12 ++------ .../test_insert_punctuation_converter.py | 30 +++++++++---------- 2 files changed, 16 insertions(+), 26 deletions(-) rename pyrit/prompt_converter/{insert_punctuation_attack_converter.py => insert_punctuation_converter.py} (92%) diff --git a/pyrit/prompt_converter/insert_punctuation_attack_converter.py b/pyrit/prompt_converter/insert_punctuation_converter.py similarity index 92% rename from pyrit/prompt_converter/insert_punctuation_attack_converter.py rename to pyrit/prompt_converter/insert_punctuation_converter.py index 7c7fed9343..7c79cd5d4d 100644 --- a/pyrit/prompt_converter/insert_punctuation_attack_converter.py +++ b/pyrit/prompt_converter/insert_punctuation_converter.py @@ -20,11 +20,10 @@ class InsertPunctuationConverter(PromptConverter): default_punctuation_list = [",", ".", "!", "?", ":", ";", "-"] - def __init__(self, max_iterations: int = 10, word_swap_ratio: float = 0.2, between_words: bool = True) -> None: + def __init__(self, word_swap_ratio: float = 0.2, between_words: bool = True) -> None: """ - Initialize the converter with optional max iterations and word swap ratio. + Initialize the converter with optional and word swap ratio. Args: - max_iterations (int): Number of prompts to generate. Defaults to 10. word_swap_ratio (float): Percentage of words to perturb. Defaults to 0.2. between_words (bool): If True, insert punctuation only between words. If False, insert punctuation within words. Defaults to True. @@ -33,11 +32,6 @@ def __init__(self, max_iterations: int = 10, word_swap_ratio: float = 0.2, betwe if not 0 < word_swap_ratio <= 1: raise ValueError("word_swap_ratio must be between 0 to 1, as (0, 1].") - # Max iterations should be at least 1 - if max_iterations < 1: - raise ValueError("max_iterations must be greater than 0.") - - self._max_iterations = max_iterations self._word_swap_ratio = word_swap_ratio self._between_words = between_words @@ -71,8 +65,6 @@ async def convert_async( # If not specified, defaults to default_punctuation_list if punctuation_list is None: punctuation_list = self.default_punctuation_list - elif not punctuation_list: - raise ValueError("punctuation_list cannot be empty") elif not self._is_valid_punctuation(punctuation_list): raise ValueError( f"Invalid punctuations: {punctuation_list}." diff --git a/tests/converter/test_insert_punctuation_converter.py b/tests/converter/test_insert_punctuation_converter.py index e13e73c4a3..bd62ed3ff3 100644 --- a/tests/converter/test_insert_punctuation_converter.py +++ b/tests/converter/test_insert_punctuation_converter.py @@ -3,31 +3,29 @@ import re import pytest -from pyrit.prompt_converter.insert_punctuation_attack_converter import InsertPunctuationConverter +from pyrit.prompt_converter.insert_punctuation_converter import InsertPunctuationConverter # Test for correctness # Long prompt, short prompt, weird spacing and punctuation, non-wordy prompt, short and empty prompt @pytest.mark.parametrize( - "input_prompt,between_words,punctuation_list,max_iterations,word_swap_ratio,expected_punctuation_count", + "input_prompt,between_words,punctuation_list,word_swap_ratio,expected_punctuation_count", [ - ("Despite the rain, we decided to go hiking; it was a refreshing experience.", True, [",", "!", "]"], 3, 1, 16), - ("Quick!", False, [",", "~", "]"], 6, 1, 2), - (" Hello, world! ", True, [",", "[", "]"], 5, 0.3, 3), - ("....", True, [",", "[", ">"], 2, 0.2, 5), - ("Numbers are also words, 1234 not intuitive, not symbols $@.", True, [",", "[", "]"], 7, 0.6, 10), - ("", True, [",", "$", "]"], 2, 0.9, 1), - ("a b", False, [",", "^", "]"], 3, 1, 2), - ("I can't wait!!!", False, [",", "/", "]"], 9, 0.4, 6), + ("Despite the rain, we decided to go hiking; it was a refreshing experience.", True, [",", "!", "]"], 1, 16), + ("Quick!", False, [",", "~", "]"], 1, 2), + (" Hello, world! ", True, [",", "[", "]"], 0.3, 3), + ("....", True, [",", "[", ">"], 0.2, 5), + ("Numbers are also words, 1234 not intuitive, not symbols $@.", True, [",", "[", "]"], 0.6, 10), + ("", True, [",", "$", "]"], 0.9, 1), + ("a b", False, [",", "^", "]"], 1, 2), + ("I can't wait!!!", False, [",", "/", "]"], 0.4, 6), ], ) @pytest.mark.asyncio async def test_word_swap_ratio( - input_prompt, between_words, punctuation_list, max_iterations, word_swap_ratio, expected_punctuation_count + input_prompt, between_words, punctuation_list, word_swap_ratio, expected_punctuation_count ): - converter = InsertPunctuationConverter( - max_iterations=max_iterations, word_swap_ratio=word_swap_ratio, between_words=between_words - ) + converter = InsertPunctuationConverter(word_swap_ratio=word_swap_ratio, between_words=between_words) result = await converter.convert_async(prompt=input_prompt, punctuation_list=punctuation_list) modified_prompt = result.output_text assert ( @@ -37,13 +35,13 @@ async def test_word_swap_ratio( ) -# Test default max_iterations =10, and swap ratio = 0.2 +# Test default swap ratio = 0.2 @pytest.mark.parametrize( "input_prompt, expected_punctuation_count", [("count 1 2 3 4 5 6 7 8 9 and 10.", 3), ("Aha!", 2)], ) @pytest.mark.asyncio -async def test_default_iteration_swap(input_prompt, expected_punctuation_count): +async def test_default_swap(input_prompt, expected_punctuation_count): converter = InsertPunctuationConverter() result = await converter.convert_async(prompt=input_prompt) modified_prompt = result.output_text From dd7a7dd83c573be7b97b4657e004a22639eb7ec2 Mon Sep 17 00:00:00 2001 From: Jiayue Date: Tue, 25 Feb 2025 22:09:26 +1100 Subject: [PATCH 10/10] pre-commit hook --- pyrit/prompt_converter/insert_punctuation_converter.py | 4 ++-- tests/converter/test_insert_punctuation_converter.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pyrit/prompt_converter/insert_punctuation_converter.py b/pyrit/prompt_converter/insert_punctuation_converter.py index 7c79cd5d4d..da1fcc23a0 100644 --- a/pyrit/prompt_converter/insert_punctuation_converter.py +++ b/pyrit/prompt_converter/insert_punctuation_converter.py @@ -2,12 +2,12 @@ # Licensed under the MIT license. import random -import string import re +import string from typing import List, Optional from pyrit.models import PromptDataType -from pyrit.prompt_converter import PromptConverter, ConverterResult +from pyrit.prompt_converter import ConverterResult, PromptConverter class InsertPunctuationConverter(PromptConverter): diff --git a/tests/converter/test_insert_punctuation_converter.py b/tests/converter/test_insert_punctuation_converter.py index bd62ed3ff3..66b178a547 100644 --- a/tests/converter/test_insert_punctuation_converter.py +++ b/tests/converter/test_insert_punctuation_converter.py @@ -2,8 +2,12 @@ # Licensed under the MIT license. import re + import pytest -from pyrit.prompt_converter.insert_punctuation_converter import InsertPunctuationConverter + +from pyrit.prompt_converter.insert_punctuation_converter import ( + InsertPunctuationConverter, +) # Test for correctness