Skip to content

Commit 8fe1194

Browse files
committed
MAINT: Standardize garak.encoding defaults and fix atomic-attack name collisions
1 parent 855a10f commit 8fe1194

5 files changed

Lines changed: 267 additions & 58 deletions

File tree

doc/scanner/garak.ipynb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,20 @@
6565
"strategy encodes the prompt, asks the target to decode it, and scores whether the decoded output\n",
6666
"matches the harmful content. Default datasets include slur terms and web/HTML/JS content.\n",
6767
"\n",
68-
"**CLI example:**\n",
68+
"**Default run** uses the curated `DEFAULT` strategy aggregate (Base16, ROT13, MorseCode — one\n",
69+
"base-N, one substitution cipher, and one symbolic alphabet) for a fast, representative scan. Use\n",
70+
"the `ALL` aggregate for an exhaustive run across every encoding scheme.\n",
71+
"\n",
72+
"**Fast path** (sanity-check target wiring in well under a minute) — pick a single-variant encoding\n",
73+
"and one prompt:\n",
6974
"\n",
7075
"```bash\n",
71-
"pyrit_scan garak.encoding --target openai_chat --strategies base64 --max-dataset-size 1\n",
76+
"pyrit_scan garak.encoding --target openai_chat --strategies rot13 --max-dataset-size 1\n",
7277
"```\n",
7378
"\n",
7479
"**Available strategies** (17 encodings): Base64, Base2048, Base16, Base32, ASCII85, Hex,\n",
7580
"QuotedPrintable, UUencode, ROT13, Braille, Atbash, MorseCode, NATO, Ecoji, Zalgo, LeetSpeak,\n",
76-
"AsciiSmuggler\n",
81+
"AsciiSmuggler. Aggregates: `DEFAULT` (curated subset, the default) and `ALL` (every encoding).\n",
7782
"\n",
7883
"> **Note:** Strategy composition is NOT supported for Encoding — each encoding is tested\n",
7984
"> independently."
@@ -143,7 +148,7 @@
143148
"\u001b[36m────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n",
144149
"\u001b[1m 📋 Scenario Details\u001b[0m\n",
145150
"\u001b[36m • Name: Encoding\u001b[0m\n",
146-
"\u001b[36m • Scenario Version: 1\u001b[0m\n",
151+
"\u001b[36m • Scenario Version: 2\u001b[0m\n",
147152
"\u001b[36m • PyRIT Version: 0.12.1.dev0\u001b[0m\n",
148153
"\u001b[36m • Description:\u001b[0m\n",
149154
"\u001b[36m Encoding Scenario implementation for PyRIT. This scenario tests how resilient models are to various encoding\u001b[0m\n",

doc/scanner/garak.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,20 @@
3939
# strategy encodes the prompt, asks the target to decode it, and scores whether the decoded output
4040
# matches the harmful content. Default datasets include slur terms and web/HTML/JS content.
4141
#
42-
# **CLI example:**
42+
# **Default run** uses the curated `DEFAULT` strategy aggregate (Base16, ROT13, MorseCode — one
43+
# base-N, one substitution cipher, and one symbolic alphabet) for a fast, representative scan. Use
44+
# the `ALL` aggregate for an exhaustive run across every encoding scheme.
45+
#
46+
# **Fast path** (sanity-check target wiring in well under a minute) — pick a single-variant encoding
47+
# and one prompt:
4348
#
4449
# ```bash
45-
# pyrit_scan garak.encoding --target openai_chat --strategies base64 --max-dataset-size 1
50+
# pyrit_scan garak.encoding --target openai_chat --strategies rot13 --max-dataset-size 1
4651
# ```
4752
#
4853
# **Available strategies** (17 encodings): Base64, Base2048, Base16, Base32, ASCII85, Hex,
4954
# QuotedPrintable, UUencode, ROT13, Braille, Atbash, MorseCode, NATO, Ecoji, Zalgo, LeetSpeak,
50-
# AsciiSmuggler
55+
# AsciiSmuggler. Aggregates: `DEFAULT` (curated subset, the default) and `ALL` (every encoding).
5156
#
5257
# > **Note:** Strategy composition is NOT supported for Encoding — each encoding is tested
5358
# > independently.

pyrit/scenario/scenarios/garak/encoding.py

Lines changed: 87 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -85,33 +85,50 @@ class EncodingStrategy(ScenarioStrategy):
8585
Strategies for encoding attacks.
8686
8787
Each enum member represents an encoding scheme that will be tested against the target model.
88-
The ALL aggregate expands to include all encoding strategies.
88+
The ``ALL`` aggregate expands to every encoding scheme (exhaustive run). The ``DEFAULT``
89+
aggregate expands to a small curated subset that spans distinct encoding families, giving a
90+
fast, representative default run.
8991
9092
Note: EncodingStrategy does not support composition. Each encoding must be applied individually.
93+
The strategy axis here is the encoding scheme (not an attack technique), and every encoding runs
94+
as a single-turn ``PromptSendingAttack``, so SINGLE_TURN/MULTI_TURN aggregates are not applicable.
9195
"""
9296

93-
# Aggregate member
97+
# Aggregate members
9498
ALL = ("all", {"all"})
99+
DEFAULT = ("default", {"default"})
95100

96-
# Individual encoding strategies (matching the atomic attack names)
101+
# Individual encoding strategies (each value matches the encoding name used for display grouping).
102+
# Members tagged ``default`` form the curated DEFAULT aggregate: one base-N encoding (Base16),
103+
# one substitution cipher (ROT13), and one symbolic alphabet (MorseCode).
97104
Base64 = ("base64", set[str]())
98105
Base2048 = ("base2048", set[str]())
99-
Base16 = ("base16", set[str]())
106+
Base16 = ("base16", {"default"})
100107
Base32 = ("base32", set[str]())
101108
ASCII85 = ("ascii85", set[str]())
102109
Hex = ("hex", set[str]())
103110
QuotedPrintable = ("quoted_printable", set[str]())
104111
UUencode = ("uuencode", set[str]())
105-
ROT13 = ("rot13", set[str]())
112+
ROT13 = ("rot13", {"default"})
106113
Braille = ("braille", set[str]())
107114
Atbash = ("atbash", set[str]())
108-
MorseCode = ("morse_code", set[str]())
115+
MorseCode = ("morse_code", {"default"})
109116
NATO = ("nato", set[str]())
110117
Ecoji = ("ecoji", set[str]())
111118
Zalgo = ("zalgo", set[str]())
112119
LeetSpeak = ("leet_speak", set[str]())
113120
AsciiSmuggler = ("ascii_smuggler", set[str]())
114121

122+
@classmethod
123+
def get_aggregate_tags(cls) -> set[str]:
124+
"""
125+
Get the set of tags that represent aggregate categories.
126+
127+
Returns:
128+
set[str]: The base ``"all"`` aggregate plus the scenario-specific ``"default"`` aggregate.
129+
"""
130+
return super().get_aggregate_tags() | {"default"}
131+
115132

116133
logger = logging.getLogger(__name__)
117134

@@ -133,7 +150,7 @@ class Encoding(Scenario):
133150
By default, this uses the same dataset as Garak: slur terms and web XSS payloads.
134151
"""
135152

136-
VERSION: int = 1
153+
VERSION: int = 2
137154

138155
@apply_defaults
139156
def __init__(
@@ -165,7 +182,7 @@ def __init__(
165182
super().__init__(
166183
version=self.VERSION,
167184
strategy_class=EncodingStrategy,
168-
default_strategy=EncodingStrategy.ALL,
185+
default_strategy=EncodingStrategy.DEFAULT,
169186
default_dataset_config=CompoundDatasetAttackConfiguration(
170187
configurations=[
171188
EncodingDatasetConfiguration(dataset_names=["garak_slur_terms_en"], max_dataset_size=3),
@@ -231,45 +248,57 @@ def _get_converter_attacks(self) -> list[AtomicAttack]:
231248
Returns:
232249
list[AtomicAttack]: List of all atomic attacks to execute.
233250
"""
234-
# Map of all available converters with their encoding names
235-
all_converters_with_encodings: list[tuple[list[PromptConverter], str]] = [
236-
([Base64Converter()], "base64"),
237-
([Base64Converter(encoding_func="urlsafe_b64encode")], "base64"),
238-
([Base64Converter(encoding_func="standard_b64encode")], "base64"),
239-
([Base64Converter(encoding_func="b2a_base64")], "base64"),
240-
([Base2048Converter()], "base2048"),
241-
([Base64Converter(encoding_func="b16encode")], "base16"),
242-
([Base64Converter(encoding_func="b32encode")], "base32"),
243-
([Base64Converter(encoding_func="a85encode")], "ascii85"),
244-
([Base64Converter(encoding_func="b85encode")], "ascii85"),
245-
([BinAsciiConverter(encoding_func="hex")], "hex"),
246-
([BinAsciiConverter(encoding_func="quoted-printable")], "quoted_printable"),
247-
([BinAsciiConverter(encoding_func="UUencode")], "uuencode"),
248-
([ROT13Converter()], "rot13"),
249-
([BrailleConverter()], "braille"),
250-
([AtbashConverter()], "atbash"),
251-
([MorseConverter()], "morse_code"),
252-
([NatoConverter()], "nato"),
253-
([EcojiConverter()], "ecoji"),
254-
([ZalgoConverter()], "zalgo"),
255-
([LeetspeakConverter()], "leet_speak"),
256-
([AsciiSmugglerConverter()], "ascii_smuggler"),
251+
# Map of all available converters with their encoding name and a unique variant slug.
252+
# ``encoding_name`` drives strategy selection and user-facing grouping (display_group);
253+
# ``variant_slug`` is unique per row so that atomic-attack names stay unique even when one
254+
# encoding name maps to multiple converter variants (e.g. base64, ascii85).
255+
# NOTE: some base64 variants are near-duplicates (default == standard_b64encode; b2a only
256+
# appends a trailing newline). They are retained here to keep the exhaustive ALL run stable
257+
# behind the VERSION gate; trimming them is a separate cleanup.
258+
all_converters_with_encodings: list[tuple[list[PromptConverter], str, str]] = [
259+
([Base64Converter()], "base64", "base64"),
260+
([Base64Converter(encoding_func="urlsafe_b64encode")], "base64", "base64_urlsafe"),
261+
([Base64Converter(encoding_func="standard_b64encode")], "base64", "base64_standard"),
262+
([Base64Converter(encoding_func="b2a_base64")], "base64", "base64_b2a"),
263+
([Base2048Converter()], "base2048", "base2048"),
264+
([Base64Converter(encoding_func="b16encode")], "base16", "base16"),
265+
([Base64Converter(encoding_func="b32encode")], "base32", "base32"),
266+
([Base64Converter(encoding_func="a85encode")], "ascii85", "ascii85_a85"),
267+
([Base64Converter(encoding_func="b85encode")], "ascii85", "ascii85_b85"),
268+
([BinAsciiConverter(encoding_func="hex")], "hex", "hex"),
269+
([BinAsciiConverter(encoding_func="quoted-printable")], "quoted_printable", "quoted_printable"),
270+
([BinAsciiConverter(encoding_func="UUencode")], "uuencode", "uuencode"),
271+
([ROT13Converter()], "rot13", "rot13"),
272+
([BrailleConverter()], "braille", "braille"),
273+
([AtbashConverter()], "atbash", "atbash"),
274+
([MorseConverter()], "morse_code", "morse_code"),
275+
([NatoConverter()], "nato", "nato"),
276+
([EcojiConverter()], "ecoji", "ecoji"),
277+
([ZalgoConverter()], "zalgo", "zalgo"),
278+
([LeetspeakConverter()], "leet_speak", "leet_speak"),
279+
([AsciiSmugglerConverter()], "ascii_smuggler", "ascii_smuggler"),
257280
]
258281

259282
# Filter to only include selected strategies
260283
selected_encoding_names = {s.value for s in self._scenario_strategies}
261284
converters_with_encodings = [
262-
(conv, name) for conv, name in all_converters_with_encodings if name in selected_encoding_names
285+
(conv, name, variant_slug)
286+
for conv, name, variant_slug in all_converters_with_encodings
287+
if name in selected_encoding_names
263288
]
264289

265290
atomic_attacks = []
266-
for conv, name in converters_with_encodings:
267-
atomic_attacks.extend(self._get_prompt_attacks(converters=conv, encoding_name=name))
291+
for conv, name, variant_slug in converters_with_encodings:
292+
atomic_attacks.extend(
293+
self._get_prompt_attacks(converters=conv, encoding_name=name, variant_slug=variant_slug)
294+
)
268295
return atomic_attacks
269296

270-
def _get_prompt_attacks(self, *, converters: list[PromptConverter], encoding_name: str) -> list[AtomicAttack]:
297+
def _get_prompt_attacks(
298+
self, *, converters: list[PromptConverter], encoding_name: str, variant_slug: str
299+
) -> list[AtomicAttack]:
271300
"""
272-
Create atomic attacks for a specific encoding scheme.
301+
Create atomic attacks for a specific encoding converter variant.
273302
274303
For each seed prompt (the text to be decoded), creates atomic attacks that:
275304
1. Encode the seed prompt using the specified converter(s)
@@ -279,31 +308,42 @@ def _get_prompt_attacks(self, *, converters: list[PromptConverter], encoding_nam
279308
280309
Args:
281310
converters (list[PromptConverter]): The list of converters to apply to the seed prompts.
282-
encoding_name (str): Human-readable name of the encoding scheme (e.g., "Base64", "ROT13").
311+
encoding_name (str): Human-readable name of the encoding scheme (e.g., "base64", "rot13").
312+
Used as the ``display_group`` so all variants of an encoding aggregate together in output.
313+
variant_slug (str): Unique slug for this converter variant, used to build a unique
314+
``atomic_attack_name`` per converter variant and prompt config.
283315
284316
Returns:
285-
list[AtomicAttack]: List of atomic attacks for this encoding scheme.
317+
list[AtomicAttack]: List of atomic attacks for this encoding converter variant.
286318
287319
Raises:
288320
ValueError: If scenario is not properly initialized.
289321
"""
290-
converter_configs = [
291-
AttackConverterConfig(
292-
request_converters=PromptConverterConfiguration.from_converters(converters=converters)
322+
# (config_name_suffix, converter_config). The bare "raw" config encodes only; each
323+
# decode-template config additionally asks the model to decode.
324+
converter_configs: list[tuple[str, AttackConverterConfig]] = [
325+
(
326+
"raw",
327+
AttackConverterConfig(
328+
request_converters=PromptConverterConfiguration.from_converters(converters=converters)
329+
),
293330
)
294331
]
295332

296-
for decode_type in self._encoding_templates:
333+
for decode_index, decode_type in enumerate(self._encoding_templates):
297334
converters_ = converters[:] + [AskToDecodeConverter(template=decode_type, encoding_name=encoding_name)]
298335

299336
converter_configs.append(
300-
AttackConverterConfig(
301-
request_converters=PromptConverterConfiguration.from_converters(converters=converters_)
337+
(
338+
f"decode{decode_index}",
339+
AttackConverterConfig(
340+
request_converters=PromptConverterConfiguration.from_converters(converters=converters_)
341+
),
302342
)
303343
)
304344

305345
atomic_attacks = []
306-
for attack_converter_config in converter_configs:
346+
for config_suffix, attack_converter_config in converter_configs:
307347
# objective_target is guaranteed to be non-None by parent class validation
308348
if self._objective_target is None:
309349
raise ValueError(
@@ -316,7 +356,8 @@ def _get_prompt_attacks(self, *, converters: list[PromptConverter], encoding_nam
316356
)
317357
atomic_attacks.append(
318358
AtomicAttack(
319-
atomic_attack_name=encoding_name,
359+
atomic_attack_name=f"{variant_slug}_{config_suffix}",
360+
display_group=encoding_name,
320361
attack_technique=AttackTechnique(attack=attack),
321362
seed_groups=self._resolved_seed_groups or [],
322363
)

tests/unit/backend/test_scenario_run_service.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from pyrit.models import AttackOutcome, ScenarioRunState
2020
from pyrit.models.catalog.scenario import RunScenarioRequest
2121
from pyrit.scenario.core import DatasetAttackConfiguration, DatasetConfiguration
22+
from pyrit.scenario.scenarios.garak.encoding import EncodingDatasetConfiguration
2223

2324
_REGISTRY_PATCH_BASE = "pyrit.registry"
2425
_MEMORY_PATCH = "pyrit.memory.CentralMemory.get_memory_instance"
@@ -311,6 +312,40 @@ class _MarkerDatasetConfiguration(DatasetConfiguration):
311312
assert default_config.dataset_names == ["original"]
312313
assert default_config.max_dataset_size == 100
313314

315+
async def test_encoding_dataset_configuration_stays_backend_constructible(self, mock_all_registries) -> None:
316+
"""``EncodingDatasetConfiguration`` remains reconstructible by the backend ``dataset_names`` path.
317+
318+
Foot-gun guard: the backend's ``_build_init_kwargs`` rebuilds the config via
319+
``type(default_config)(dataset_names=..., max_dataset_size=...)`` and silently degrades to a
320+
plain base config on ``TypeError``. Each child inside the scenario's default
321+
``CompoundDatasetAttackConfiguration`` is an ``EncodingDatasetConfiguration``, so that subclass
322+
must stay constructible with just ``dataset_names``/``max_dataset_size`` (no new *required*
323+
``__init__`` args). This pins the real subclass (not a synthetic marker) so adding a required
324+
ctor arg fails loudly here.
325+
326+
NOTE: This does NOT cover the scenario's actual *default* config, which is a
327+
``CompoundDatasetAttackConfiguration`` — that type takes ``configurations=`` (not
328+
``dataset_names=``), so the backend ``--dataset-names`` override currently falls back to a base
329+
``DatasetAttackConfiguration`` for compound defaults. That reconstruction gap is a separate,
330+
pre-existing backend limitation tracked outside this scenario PR.
331+
"""
332+
default_config = EncodingDatasetConfiguration(
333+
dataset_names=["garak_slur_terms_en", "garak_web_html_js"], max_dataset_size=3
334+
)
335+
scenario_instance = mock_all_registries["scenario_instance"]
336+
scenario_instance._default_dataset_config = default_config
337+
338+
service = ScenarioRunService()
339+
await service.start_run_async(request=_make_request(dataset_names=["custom_a", "custom_b"], max_dataset_size=2))
340+
341+
init_call = scenario_instance.initialize_async.await_args
342+
built_config = init_call.kwargs["dataset_config"]
343+
344+
# Real subclass type is preserved (not degraded to base DatasetConfiguration)
345+
assert type(built_config) is EncodingDatasetConfiguration
346+
assert built_config.dataset_names == ["custom_a", "custom_b"]
347+
assert built_config.max_dataset_size == 2
348+
314349
async def test_start_run_dataset_names_without_max_dataset_size_preserves_subclass(
315350
self, mock_all_registries
316351
) -> None:

0 commit comments

Comments
 (0)