-
Notifications
You must be signed in to change notification settings - Fork 848
FIX: warn when selected attack techniques have no registered factory #2466
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d82db4c
1b3a481
abead01
a1600c2
0fa4881
6b67551
b653ef8
1b30e56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,34 @@ | |
| from pyrit.scenario.core.atomic_attack import AtomicAttack | ||
| from pyrit.scenario.core.attack_technique import AttackTechnique | ||
|
|
||
|
|
||
| class TechniqueResolutionError(ValueError): | ||
| """ | ||
| Raised when a scenario selects techniques but none of them resolve to a factory. | ||
|
|
||
| Subclasses ``ValueError`` so existing ``except ValueError`` handlers keep working, | ||
| mirroring ``DatasetConstraintError``. Partial misses (some techniques resolve) | ||
| only warn, so a run still proceeds with the techniques that do exist. | ||
| """ | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class TechniqueResolution: | ||
| """ | ||
| Outcome of resolving a scenario's selected techniques to factories. | ||
|
|
||
| Attributes: | ||
| resolved (dict[str, AttackTechniqueFactory]): Factories keyed by technique | ||
| name, ordered by the selection. | ||
| skipped (list[str]): Selected techniques with no registered factory, in | ||
| selection order. This is the authoritative record for surfacing skips — | ||
| display groups are presentation data and cannot be used to derive it. | ||
| """ | ||
|
|
||
| resolved: dict[str, AttackTechniqueFactory] | ||
| skipped: list[str] | ||
|
|
||
|
|
||
| if TYPE_CHECKING: | ||
| from collections.abc import Callable, Mapping, Sequence | ||
|
|
||
|
|
@@ -136,7 +164,7 @@ def resolve_technique_factories( | |
| *, | ||
| context: ScenarioContext, | ||
| extra_factories: dict[str, AttackTechniqueFactory] | None = None, | ||
| ) -> dict[str, AttackTechniqueFactory]: | ||
| ) -> TechniqueResolution: | ||
| """ | ||
| Resolve a run's selected techniques to their registered ``AttackTechniqueFactory`` instances. | ||
|
|
||
|
|
@@ -152,19 +180,48 @@ def resolve_technique_factories( | |
| name. | ||
|
|
||
| Returns: | ||
| dict[str, AttackTechniqueFactory]: Mapping of technique name to factory, ordered by | ||
| the selected techniques. | ||
| TechniqueResolution: ``resolved`` maps technique name to factory ordered by the | ||
| selection; ``skipped`` lists selected techniques with no registered factory in | ||
| selection order (also emitted as a warning so callers proceed knowingly). | ||
|
|
||
| Raises: | ||
| TechniqueResolutionError: If the selection is nonempty but no technique resolves, | ||
| since running only the baseline would silently defeat the selection. | ||
| """ | ||
| from pyrit.registry.components.attack_technique_registry import AttackTechniqueRegistry | ||
|
|
||
| all_factories = dict(AttackTechniqueRegistry.get_registry_singleton().get_factories_or_raise()) | ||
| if extra_factories: | ||
| all_factories.update(extra_factories) | ||
| return { | ||
| technique.value: all_factories[technique.value] | ||
| for technique in context.scenario_techniques | ||
| if technique.value in all_factories | ||
| } | ||
|
|
||
| resolved: dict[str, AttackTechniqueFactory] = {} | ||
| missing: list[str] = [] | ||
| seen_missing: set[str] = set() | ||
| for technique in context.scenario_techniques: | ||
| if technique.value in all_factories: | ||
| resolved[technique.value] = all_factories[technique.value] | ||
| elif technique.value not in seen_missing: | ||
| missing.append(technique.value) | ||
| seen_missing.add(technique.value) | ||
|
|
||
| if missing: | ||
|
romanlutz marked this conversation as resolved.
|
||
| logger.warning( | ||
|
romanlutz marked this conversation as resolved.
|
||
| "Skipping %d selected attack technique(s) with no registered factory: %s. " | ||
| "Register the technique(s) (or pass them via extra_factories) to include them in the run.", | ||
| len(missing), | ||
| ", ".join(missing), | ||
| ) | ||
|
|
||
| if context.scenario_techniques and not resolved: | ||
| # A nonempty selection that resolves to nothing would otherwise run the | ||
| # baseline only while reporting success — a silently empty evaluation. | ||
| raise TechniqueResolutionError( | ||
| f"All {len(context.scenario_techniques)} selected attack technique(s) have no registered " | ||
| f"factory: {', '.join(missing)}. Register the technique(s) (or pass them via " | ||
| "extra_factories) so the run has at least one technique to execute." | ||
| ) | ||
|
|
||
| return TechniqueResolution(resolved=resolved, skipped=missing) | ||
|
|
||
|
|
||
| def build_matrix_atomic_attacks( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function now returns |
||
|
|
@@ -174,7 +231,7 @@ def build_matrix_atomic_attacks( | |
| display_group_fn: Callable[[MatrixCombo], str] | None = None, | ||
| technique_converters: dict[str, list[Converter]] | None = None, | ||
| extra_factories: dict[str, AttackTechniqueFactory] | None = None, | ||
| ) -> list[AtomicAttack]: | ||
| ) -> tuple[list[AtomicAttack], list[str]]: | ||
| """ | ||
| Build a matrix-shaped scenario's atomic attacks from its resolved context in one call. | ||
|
|
||
|
|
@@ -205,21 +262,25 @@ def build_matrix_atomic_attacks( | |
| can offer techniques without registering them globally. | ||
|
|
||
| Returns: | ||
| list[AtomicAttack]: The generated atomic attacks, baseline first when | ||
| ``context.include_baseline`` is set. | ||
| tuple[list[AtomicAttack], list[str]]: The generated atomic attacks (baseline | ||
| first when ``context.include_baseline`` is set) and the names of selected | ||
| techniques that had no registered factory, so callers can persist the | ||
| authoritative skip record before creating the ``ScenarioResult``. | ||
| """ | ||
| builder = MatrixAtomicAttackBuilder( | ||
| objective_target=context.objective_target, | ||
| objective_scorer=objective_scorer, | ||
| memory_labels=context.memory_labels, | ||
| ) | ||
| return builder.build( | ||
| technique_factories=resolve_technique_factories(context=context, extra_factories=extra_factories), | ||
| resolution = resolve_technique_factories(context=context, extra_factories=extra_factories) | ||
| attacks = builder.build( | ||
| technique_factories=resolution.resolved, | ||
| dataset_groups=context.seed_groups_by_dataset, | ||
| display_group_fn=display_group_fn, | ||
| technique_converters=technique_converters, | ||
| include_baseline=context.include_baseline, | ||
| ) | ||
| return attacks, resolution.skipped | ||
|
|
||
|
|
||
| class MatrixAtomicAttackBuilder: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.