Skip to content

Commit d1ce4ce

Browse files
authored
FEAT: atomic attack identifier (microsoft#1446)
1 parent d0148d4 commit d1ce4ce

51 files changed

Lines changed: 1912 additions & 528 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

doc/api.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,12 +271,17 @@ API Reference
271271
:nosignatures:
272272
:toctree: _autosummary/
273273

274+
AtomicAttackEvaluationIdentifier
275+
build_atomic_attack_identifier
276+
build_seed_identifier
277+
ChildEvalRule
274278
class_name_to_snake_case
275279
ComponentIdentifier
276280
compute_eval_hash
277281
config_hash
278-
EvaluationIdentity
282+
EvaluationIdentifier
279283
Identifiable
284+
ScorerEvaluationIdentifier
280285
snake_case_to_class_name
281286

282287
:py:mod:`pyrit.memory`

doc/code/memory/3_memory_data_types.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ Scores enable automated evaluation of attack success, content harmfulness, and o
151151
- **`conversation_id`**: The conversation that produced this result
152152
- **`objective`**: Natural-language description of the attacker's goal
153153
- **`attack_identifier`**: Information identifying the attack strategy used
154+
- **`atomic_attack_identifier`**: Composite identifier combining the attack strategy with general technique seed identifiers from the dataset
154155
- **`last_response`**: The final `MessagePiece` generated in the attack
155156
- **`last_score`**: The final score assigned to the last response
156157
- **`executed_turns`**: Number of turns executed in the attack

pyrit/analytics/result_analysis.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ def analyze_results(attack_results: list[AttackResult]) -> dict[str, AttackStats
6262
raise TypeError(f"Expected AttackResult, got {type(attack).__name__}: {attack!r}")
6363

6464
outcome = attack.outcome
65-
attack_type = attack.attack_identifier.class_name if attack.attack_identifier else "unknown"
65+
attack_type = (
66+
attack.get_attack_strategy_identifier().class_name if attack.get_attack_strategy_identifier() else "unknown"
67+
)
6668

6769
if outcome == AttackOutcome.SUCCESS:
6870
overall_counts["successes"] += 1

pyrit/backend/mappers/attack_mappers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ def attack_result_to_summary(
204204
created_at = datetime.fromisoformat(created_str) if created_str else datetime.now(timezone.utc)
205205
updated_at = datetime.fromisoformat(updated_str) if updated_str else created_at
206206

207-
aid = ar.attack_identifier
207+
aid = ar.get_attack_strategy_identifier()
208208

209209
# Extract only frontend-relevant fields from ComponentIdentifier
210210
target_id = aid.get_child("objective_target") if aid else None

pyrit/backend/services/attack_service.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from pyrit.backend.services.converter_service import get_converter_service
5151
from pyrit.backend.services.target_service import get_target_service
5252
from pyrit.identifiers import ComponentIdentifier
53+
from pyrit.identifiers.atomic_attack_identifier import build_atomic_attack_identifier
5354
from pyrit.memory import CentralMemory
5455
from pyrit.models import (
5556
AttackOutcome,
@@ -295,10 +296,12 @@ async def create_attack_async(self, *, request: CreateAttackRequest) -> CreateAt
295296
attack_result = AttackResult(
296297
conversation_id=conversation_id,
297298
objective=request.name or "Manual attack via GUI",
298-
attack_identifier=ComponentIdentifier(
299-
class_name=request.name or "ManualAttack",
300-
class_module="pyrit.backend",
301-
children={"objective_target": target_identifier} if target_identifier else {},
299+
atomic_attack_identifier=build_atomic_attack_identifier(
300+
attack_identifier=ComponentIdentifier(
301+
class_name=request.name or "ManualAttack",
302+
class_module="pyrit.backend",
303+
children={"objective_target": target_identifier} if target_identifier else {},
304+
),
302305
),
303306
outcome=AttackOutcome.UNDETERMINED,
304307
metadata={

pyrit/executor/attack/core/attack_executor.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import asyncio
1111
from collections.abc import Iterator, Sequence
12-
from dataclasses import dataclass
12+
from dataclasses import dataclass, field
1313
from typing import (
1414
TYPE_CHECKING,
1515
Any,
@@ -56,6 +56,13 @@ class AttackExecutorResult(Generic[AttackResultT]):
5656

5757
completed_results: list[AttackResultT]
5858
incomplete_objectives: list[tuple[str, BaseException]]
59+
input_indices: list[int] = field(default_factory=list)
60+
"""Maps each completed result to its position in the original input sequence.
61+
62+
``input_indices[i]`` is the index in the original objectives/seed_groups/params
63+
list that produced ``completed_results[i]``. When some inputs fail, this lets
64+
callers correlate results back to the specific input that produced them.
65+
"""
5966

6067
def __iter__(self) -> Iterator[AttackResultT]:
6168
"""
@@ -331,16 +338,19 @@ def _process_execution_results(
331338
"""
332339
completed: list[AttackStrategyResultT] = []
333340
incomplete: list[tuple[str, BaseException]] = []
341+
completed_indices: list[int] = []
334342

335-
for objective, result in zip(objectives, results_or_exceptions, strict=False):
343+
for i, (objective, result) in enumerate(zip(objectives, results_or_exceptions, strict=False)):
336344
if isinstance(result, BaseException):
337345
incomplete.append((objective, result))
338346
else:
339347
completed.append(result)
348+
completed_indices.append(i)
340349

341350
executor_result: AttackExecutorResult[AttackStrategyResultT] = AttackExecutorResult(
342351
completed_results=completed,
343352
incomplete_objectives=incomplete,
353+
input_indices=completed_indices,
344354
)
345355

346356
if not return_partial_on_failure:

pyrit/executor/attack/multi_turn/chunked_request.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
MultiTurnAttackContext,
2121
MultiTurnAttackStrategy,
2222
)
23+
from pyrit.identifiers import build_atomic_attack_identifier
2324
from pyrit.models import (
2425
AttackOutcome,
2526
AttackResult,
@@ -316,7 +317,7 @@ async def _perform_async(self, *, context: ChunkedRequestAttackContext) -> Attac
316317
return AttackResult(
317318
conversation_id=context.session.conversation_id,
318319
objective=context.objective,
319-
attack_identifier=self.get_identifier(),
320+
atomic_attack_identifier=build_atomic_attack_identifier(attack_identifier=self.get_identifier()),
320321
last_response=response.get_piece() if response else None,
321322
last_score=score,
322323
related_conversations=context.related_conversations,

pyrit/executor/attack/multi_turn/crescendo.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
MultiTurnAttackContext,
3131
MultiTurnAttackStrategy,
3232
)
33+
from pyrit.identifiers import build_atomic_attack_identifier
3334
from pyrit.memory.central_memory import CentralMemory
3435
from pyrit.message_normalizer import ConversationContextNormalizer
3536
from pyrit.models import (
@@ -393,7 +394,7 @@ async def _perform_async(self, *, context: CrescendoAttackContext) -> CrescendoA
393394

394395
# Prepare the result
395396
result = CrescendoAttackResult(
396-
attack_identifier=self.get_identifier(),
397+
atomic_attack_identifier=build_atomic_attack_identifier(attack_identifier=self.get_identifier()),
397398
conversation_id=context.session.conversation_id,
398399
objective=context.objective,
399400
outcome=(AttackOutcome.SUCCESS if achieved_objective else AttackOutcome.FAILURE),

pyrit/executor/attack/multi_turn/multi_prompt_sending.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
MultiTurnAttackContext,
2020
MultiTurnAttackStrategy,
2121
)
22+
from pyrit.identifiers import build_atomic_attack_identifier
2223
from pyrit.models import (
2324
AttackOutcome,
2425
AttackResult,
@@ -287,7 +288,7 @@ async def _perform_async(self, *, context: MultiTurnAttackContext[Any]) -> Attac
287288
return AttackResult(
288289
conversation_id=context.session.conversation_id,
289290
objective=context.objective,
290-
attack_identifier=self.get_identifier(),
291+
atomic_attack_identifier=build_atomic_attack_identifier(attack_identifier=self.get_identifier()),
291292
last_response=response.get_piece() if response else None,
292293
last_score=score,
293294
related_conversations=context.related_conversations,

pyrit/executor/attack/multi_turn/red_teaming.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
MultiTurnAttackContext,
2727
MultiTurnAttackStrategy,
2828
)
29+
from pyrit.identifiers import build_atomic_attack_identifier
2930
from pyrit.memory import CentralMemory
3031
from pyrit.models import (
3132
AttackOutcome,
@@ -314,7 +315,7 @@ async def _perform_async(self, *, context: MultiTurnAttackContext[Any]) -> Attac
314315

315316
# Prepare the result
316317
return AttackResult(
317-
attack_identifier=self.get_identifier(),
318+
atomic_attack_identifier=build_atomic_attack_identifier(attack_identifier=self.get_identifier()),
318319
conversation_id=context.session.conversation_id,
319320
objective=context.objective,
320321
outcome=(AttackOutcome.SUCCESS if achieved_objective else AttackOutcome.FAILURE),

0 commit comments

Comments
 (0)