Skip to content

Commit cebe850

Browse files
committed
fix: reject technique enum collisions
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b6d69d3-3f92-4c5b-8991-f481820dfe70
1 parent 6fafce4 commit cebe850

3 files changed

Lines changed: 94 additions & 4 deletions

File tree

doc/code/scenarios/0_scenarios.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,7 @@
5959
# - Each enum member represents an **attack technique** (the *how* of an attack)
6060
# - Each member is defined as `(value, tags)` where value is a string and tags is a set of strings
6161
# - Include an `ALL` aggregate technique that expands to all available techniques
62-
# - The default technique (what runs when the caller selects nothing) is owned by the
63-
# catalog, not the scenario: override the `default()` classmethod to return the default
64-
# member (omit it to fall back to `ALL`)
62+
# - The default technique (what runs when the caller selects nothing) is owned by the catalog, not the scenario: override the `default()` classmethod to return the default member (omit it to fall back to `ALL`)
6563
#
6664
# 2. **Scenario Class**: Extend `Scenario` and pass these to `super().__init__()`:
6765
# - `technique_class`: Your technique enum class

pyrit/registry/components/attack_technique_registry.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,46 @@ def _attack_technique_factory_type() -> type[AttackTechniqueFactory]:
5353
return AttackTechniqueFactory
5454

5555

56+
def _validate_generated_member_collisions(
57+
*,
58+
class_name: str,
59+
factories: list[AttackTechniqueFactory],
60+
aggregate_tags: set[str],
61+
) -> None:
62+
"""
63+
Validate that generated enum member names and values are unambiguous.
64+
65+
Args:
66+
class_name (str): Name of the enum class being generated.
67+
factories (list[AttackTechniqueFactory]): Technique factories that become enum members.
68+
aggregate_tags (set[str]): Catalog tags that become aggregate members.
69+
70+
Raises:
71+
ValueError: If a factory or aggregate would collide with a reserved or generated member.
72+
"""
73+
member_sources = {"ALL": "reserved aggregate 'all'", "DEFAULT": "reserved aggregate 'default'"}
74+
value_sources = {"all": "reserved aggregate 'all'", "default": "reserved aggregate 'default'"}
75+
76+
def _reserve(*, member_name: str, member_value: str, source: str) -> None:
77+
if existing := member_sources.get(member_name):
78+
raise ValueError(
79+
f"Cannot build {class_name}: {source} maps to enum member name {member_name!r}, "
80+
f"already used by {existing}. Rename the tag or factory."
81+
)
82+
if existing := value_sources.get(member_value):
83+
raise ValueError(
84+
f"Cannot build {class_name}: {source} maps to enum value {member_value!r}, "
85+
f"already used by {existing}. Rename the tag or factory."
86+
)
87+
member_sources[member_name] = source
88+
value_sources[member_value] = source
89+
90+
for tag in sorted(aggregate_tags):
91+
_reserve(member_name=tag.upper(), member_value=tag, source=f"aggregate tag {tag!r}")
92+
for factory in factories:
93+
_reserve(member_name=factory.name, member_value=factory.name, source=f"technique factory {factory.name!r}")
94+
95+
5696
@dataclass(frozen=True)
5797
class AttackTechniqueMetadata(RegistryMetadata):
5898
"""
@@ -216,7 +256,8 @@ def build_technique_class_from_factories(
216256
type: A ``ScenarioTechnique`` subclass with the generated members.
217257
218258
Raises:
219-
ValueError: If both ``default_tags`` and ``default_names`` are provided.
259+
ValueError: If both ``default_tags`` and ``default_names`` are provided, or if generated
260+
enum member names or values collide.
220261
"""
221262
from pyrit.scenario import ScenarioTechnique
222263

@@ -245,6 +286,11 @@ def build_technique_class_from_factories(
245286
# technique (name selection wins).
246287
reserved_aggregate_tags = {"all", "default"}
247288
auto_aggregate_tags = pool_tags - reserved_aggregate_tags - pool_technique_names
289+
_validate_generated_member_collisions(
290+
class_name=class_name,
291+
factories=pool,
292+
aggregate_tags=auto_aggregate_tags,
293+
)
248294

249295
all_aggregate_tag_names = {"all"} | auto_aggregate_tags
250296
if default_member_names:

tests/unit/registry/test_attack_technique_registry.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,3 +651,49 @@ def test_both_default_tags_and_names_raises(self):
651651
default_tags={"light"},
652652
default_names={"gamma"},
653653
)
654+
655+
@pytest.mark.parametrize("name", ["ALL", "all", "DEFAULT", "default"])
656+
def test_reserved_factory_name_raises(self, name: str):
657+
"""Factories cannot shadow the synthetic ALL or DEFAULT members."""
658+
factories = [AttackTechniqueFactory(name=name, attack_class=_StubAttack)]
659+
660+
with pytest.raises(ValueError, match="reserved aggregate"):
661+
AttackTechniqueRegistry.build_technique_class_from_factories(
662+
class_name="ReservedFactoryTechnique",
663+
factories=factories,
664+
)
665+
666+
def test_duplicate_factory_name_raises(self):
667+
"""Duplicate factory names fail instead of silently overwriting an enum member."""
668+
factories = [
669+
AttackTechniqueFactory(name="duplicate", attack_class=_StubAttack),
670+
AttackTechniqueFactory(name="duplicate", attack_class=_StubAttack),
671+
]
672+
673+
with pytest.raises(ValueError, match="enum member name 'duplicate'"):
674+
AttackTechniqueRegistry.build_technique_class_from_factories(
675+
class_name="DuplicateFactoryTechnique",
676+
factories=factories,
677+
)
678+
679+
def test_reserved_aggregate_tag_raises(self):
680+
"""An uppercase reserved tag cannot overwrite the synthetic ALL member."""
681+
factories = [AttackTechniqueFactory(name="alpha", attack_class=_StubAttack, technique_tags=["ALL"])]
682+
683+
with pytest.raises(ValueError, match="enum member name 'ALL'"):
684+
AttackTechniqueRegistry.build_technique_class_from_factories(
685+
class_name="ReservedTagTechnique",
686+
factories=factories,
687+
)
688+
689+
def test_case_colliding_aggregate_tags_raise(self):
690+
"""Tags that normalize to the same enum member name fail explicitly."""
691+
factories = [
692+
AttackTechniqueFactory(name="alpha", attack_class=_StubAttack, technique_tags=["foo", "FOO"]),
693+
]
694+
695+
with pytest.raises(ValueError, match="enum member name 'FOO'"):
696+
AttackTechniqueRegistry.build_technique_class_from_factories(
697+
class_name="CollidingTagTechnique",
698+
factories=factories,
699+
)

0 commit comments

Comments
 (0)