Skip to content

Commit 96077b2

Browse files
u7k4rs6richlundeen
andauthored
FIX: record the attack identity on ERROR attack results (#3097)
Co-authored-by: Richard Lundeen <rlundeen@microsoft.com> Copilot-Session: fcbdea79-a119-453f-a809-3fc2d2cec113
1 parent 65205e5 commit 96077b2

2 files changed

Lines changed: 165 additions & 3 deletions

File tree

‎pyrit/executor/attack/core/attack_strategy.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
)
3131
from pyrit.memory.central_memory import CentralMemory
3232
from pyrit.models import (
33+
AtomicAttackIdentifier,
3334
AttackIdentifier,
3435
AttackOutcome,
3536
AttackResult,
@@ -51,6 +52,7 @@
5152
from pyrit.prompt_target.common.target_requirements import TargetRequirements
5253

5354
if TYPE_CHECKING:
55+
from collections.abc import Callable
5456
from types import TracebackType
5557

5658
from pyrit.executor.attack.component.prepended_conversation_config import (
@@ -185,6 +187,9 @@ class AttackContext(StrategyContext, ABC, Generic[AttackParamsT]):
185187
_memory_labels_override: dict[str, str] | None = None
186188
_error_result_persistence_error: Exception | None = field(default=None, init=False, repr=False)
187189
_error_result_metadata: dict[str, Any] = field(default_factory=dict, init=False, repr=False, compare=False)
190+
_error_result_identifier_builder: Callable[[], AtomicAttackIdentifier] | None = field(
191+
default=None, init=False, repr=False, compare=False
192+
)
188193
_persisted_attack_result_id: str | None = field(default=None, init=False, repr=False, compare=False)
189194
_persist_attack_result: bool = field(default=True, init=False, repr=False, compare=False)
190195
_objective_target_conversation_lifecycle: _ObjectiveTargetConversationLifecycle | None = field(
@@ -540,6 +545,25 @@ def _log_attack_outcome(self, result: AttackResult) -> None:
540545

541546
self._logger.info(message)
542547

548+
def _build_error_result_identifier(self, *, context: AttackStrategyContextT) -> AtomicAttackIdentifier | None:
549+
"""
550+
Build the attack identity for an error result, if the strategy provided one.
551+
552+
Args:
553+
context (AttackStrategyContextT): The failed attack's context.
554+
555+
Returns:
556+
AtomicAttackIdentifier | None: The identity, or None if it cannot be built.
557+
Build failures are logged, and the error result is saved without an identity.
558+
"""
559+
if context._error_result_identifier_builder is None:
560+
return None
561+
try:
562+
return context._error_result_identifier_builder()
563+
except Exception:
564+
self._logger.warning("Could not build the attack identifier for the error result.", exc_info=True)
565+
return None
566+
543567
async def _on_error_async(
544568
self, event_data: StrategyEventData[AttackStrategyContextT, AttackStrategyResultT]
545569
) -> None:
@@ -577,6 +601,7 @@ async def _on_error_async(
577601
attack_result_id=context.attack_result_id or str(uuid.uuid4()),
578602
outcome=AttackOutcome.ERROR,
579603
outcome_reason=f"Exception: {type(error).__name__}: {str(error)}",
604+
atomic_attack_identifier=self._build_error_result_identifier(context=context),
580605
labels=context.memory_labels,
581606
related_conversations=context.related_conversations,
582607
error_message=str(error),
@@ -919,6 +944,9 @@ async def execute_with_context_async(self, *, context: AttackStrategyContextT) -
919944
context._error_result_metadata.clear()
920945
context._persisted_attack_result_id = None
921946
self._validate_scoring_expectation(context=context)
947+
context._error_result_identifier_builder = lambda: AtomicAttackIdentifier.build(
948+
attack_identifier=self.get_identifier()
949+
)
922950
context._attack_result_id = str(uuid.uuid4())
923951
context._result_role = self.RESULT_ROLE
924952
lifecycle = _ObjectiveTargetConversationLifecycle(
@@ -941,6 +969,7 @@ async def execute_with_context_async(self, *, context: AttackStrategyContextT) -
941969
raise
942970
finally:
943971
context._objective_target_conversation_lifecycle = None
972+
context._error_result_identifier_builder = None
944973

945974
if context._persist_attack_result:
946975
try:

‎tests/unit/executor/attack/core/test_attack_result_correlation.py‎

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
import asyncio
55
import json
6+
import logging
67
from typing import Any
8+
from unittest.mock import AsyncMock, patch
79

810
import pytest
911
from unit.mocks import MockPromptTarget
@@ -21,8 +23,11 @@
2123
SingleTurnAttackContext,
2224
)
2325
from pyrit.executor.attack.compound import SequenceCompletionPolicy, SequentialAttack, SequentialChildAttack
26+
from pyrit.executor.attack.core.attack_strategy import _DefaultAttackStrategyEventHandler
27+
from pyrit.executor.core import Strategy
2428
from pyrit.memory import SQLiteMemory
2529
from pyrit.models import (
30+
AtomicAttackIdentifier,
2631
AttackOutcome,
2732
AttackResult,
2833
AttackResultMetadata,
@@ -107,6 +112,22 @@ async def _perform_async(self, *, context: SingleTurnAttackContext[Any]) -> Atta
107112
return result
108113

109114

115+
class _FailingStrategy(Strategy[SingleTurnAttackContext[AttackParameters], AttackResult]):
116+
"""A generic strategy with an attack context but no component identifier."""
117+
118+
def _validate_context(self, *, context: SingleTurnAttackContext[AttackParameters]) -> None:
119+
pass
120+
121+
async def _setup_async(self, *, context: SingleTurnAttackContext[AttackParameters]) -> None:
122+
pass
123+
124+
async def _perform_async(self, *, context: SingleTurnAttackContext[AttackParameters]) -> AttackResult:
125+
raise ValueError("strategy failed")
126+
127+
async def _teardown_async(self, *, context: SingleTurnAttackContext[AttackParameters]) -> None:
128+
pass
129+
130+
110131
async def _owned_ids_async(memory: SQLiteMemory, attack_result_id: str) -> set[str]:
111132
conversations = await memory.get_attack_result_conversations_async(attack_result_id=attack_result_id)
112133
return {conversation.conversation_id for conversation in conversations}
@@ -276,11 +297,10 @@ async def test_standalone_sequential_results_record_their_roles_without_a_parent
276297
) -> None:
277298
target = _RecordingTarget(fail=fail)
278299
seed_group = AttackSeedGroup(seeds=[SeedObjective(value="objective")])
300+
child = PromptSendingAttack(objective_target=target)
279301
sequential = SequentialAttack(
280302
objective_target=target,
281-
child_attacks=[
282-
SequentialChildAttack(strategy=PromptSendingAttack(objective_target=target), seed_group=seed_group)
283-
],
303+
child_attacks=[SequentialChildAttack(strategy=child, seed_group=seed_group)],
284304
)
285305

286306
if fail:
@@ -299,6 +319,14 @@ async def test_standalone_sequential_results_record_their_roles_without_a_parent
299319
AttackResultRole.ORCHESTRATION,
300320
AttackResultRole.TARGET_FACING,
301321
}
322+
if fail:
323+
for result in stored:
324+
assert result.atomic_attack_identifier is not None
325+
assert result.atomic_attack_identifier.eval_hash is not None
326+
expected = sequential if result.attribution_data["result_role"] == "orchestration" else child
327+
technique = result.atomic_attack_identifier.get_child("attack_technique")
328+
assert technique is not None
329+
assert technique.get_child("attack") == expected.get_identifier()
302330

303331

304332
async def test_history_from_an_earlier_execution_is_copied_into_a_new_conversation_async(
@@ -378,3 +406,108 @@ async def test_child_attack_conversations_link_to_the_child_async(sqlite_instanc
378406
for child in children:
379407
assert await _owned_ids_async(sqlite_instance, child.attack_result_id) == {child.conversation_id}
380408
assert await _owned_ids_async(sqlite_instance, result.attack_result_id) == set()
409+
410+
411+
async def test_error_result_records_the_attack_identity_async(sqlite_instance: SQLiteMemory) -> None:
412+
attack = PromptSendingAttack(objective_target=_RecordingTarget(fail=True))
413+
context = SingleTurnAttackContext(params=AttackParameters(objective="objective"))
414+
415+
with pytest.raises(RuntimeError):
416+
await attack.execute_with_context_async(context=context)
417+
418+
[stored] = await sqlite_instance.get_attack_results_async(attack_classes=["PromptSendingAttack"])
419+
assert stored.outcome == AttackOutcome.ERROR
420+
assert stored.atomic_attack_identifier is not None
421+
technique = stored.atomic_attack_identifier.get_child("attack_technique")
422+
assert technique is not None
423+
assert technique.get_child("attack") == attack.get_identifier()
424+
assert stored.atomic_attack_identifier.eval_hash is not None
425+
assert context._error_result_identifier_builder is None
426+
427+
428+
@pytest.mark.parametrize("builder_stage", ["get_identifier", "build"])
429+
async def test_error_identifier_failure_preserves_original_error_and_result_async(
430+
sqlite_instance: SQLiteMemory, caplog: pytest.LogCaptureFixture, builder_stage: str
431+
) -> None:
432+
attack = PromptSendingAttack(objective_target=_RecordingTarget())
433+
context = SingleTurnAttackContext(params=AttackParameters(objective="objective"))
434+
original_error = ValueError("attack failed")
435+
builder_error = RuntimeError("identifier failed")
436+
builder_owner = attack if builder_stage == "get_identifier" else AtomicAttackIdentifier
437+
438+
with (
439+
patch.object(attack, "_perform_async", new_callable=AsyncMock, side_effect=original_error),
440+
patch.object(builder_owner, builder_stage, side_effect=builder_error) as builder,
441+
caplog.at_level(logging.WARNING),
442+
pytest.raises(RuntimeError, match="attack failed") as raised,
443+
):
444+
await attack.execute_with_context_async(context=context)
445+
446+
assert raised.value.__cause__ is original_error
447+
builder.assert_called_once()
448+
[stored] = await sqlite_instance.get_attack_results_async(attack_result_ids=[context.attack_result_id])
449+
assert stored.outcome is AttackOutcome.ERROR
450+
assert stored.error_type == "ValueError"
451+
assert stored.error_message == "attack failed"
452+
assert stored.atomic_attack_identifier is None
453+
assert context._error_result_persistence_error is None
454+
assert context._persisted_attack_result_id == stored.attack_result_id
455+
assert context._error_result_identifier_builder is None
456+
[record] = [record for record in caplog.records if "Could not build the attack identifier" in record.message]
457+
assert record.levelno == logging.WARNING
458+
assert record.exc_info is not None
459+
assert record.exc_info[1] is builder_error
460+
461+
462+
async def test_generic_strategy_error_result_does_not_require_an_identifier_async(
463+
sqlite_instance: SQLiteMemory,
464+
) -> None:
465+
strategy = _FailingStrategy(
466+
context_type=SingleTurnAttackContext,
467+
event_handler=_DefaultAttackStrategyEventHandler(),
468+
)
469+
context = SingleTurnAttackContext(params=AttackParameters(objective="objective"))
470+
471+
with pytest.raises(RuntimeError, match="strategy failed") as raised:
472+
await strategy.execute_with_context_async(context=context)
473+
474+
assert isinstance(raised.value.__cause__, ValueError)
475+
[stored] = await sqlite_instance.get_attack_results_async(objective="objective")
476+
assert stored.outcome is AttackOutcome.ERROR
477+
assert stored.error_message == "strategy failed"
478+
assert stored.atomic_attack_identifier is None
479+
assert context._persisted_attack_result_id == stored.attack_result_id
480+
481+
482+
@pytest.mark.parametrize("first_fails", [False, True])
483+
async def test_reused_context_records_the_current_attack_identity_async(
484+
sqlite_instance: SQLiteMemory, first_fails: bool
485+
) -> None:
486+
first = PromptSendingAttack(objective_target=_RecordingTarget(fail=first_fails))
487+
second = PromptSendingAttack(
488+
objective_target=_RecordingTarget(fail=True),
489+
attack_converter_config=AttackConverterConfig(
490+
request_converters=ConverterConfiguration.from_converters(converters=[_RecordingConverter()])
491+
),
492+
)
493+
context = SingleTurnAttackContext(params=AttackParameters(objective="objective"))
494+
495+
if first_fails:
496+
with pytest.raises(RuntimeError, match="target unavailable"):
497+
await first.execute_with_context_async(context=context)
498+
else:
499+
await first.execute_with_context_async(context=context)
500+
first_id = context.attack_result_id
501+
assert context._error_result_identifier_builder is None
502+
503+
with pytest.raises(RuntimeError, match="target unavailable"):
504+
await second.execute_with_context_async(context=context)
505+
506+
assert context.attack_result_id != first_id
507+
assert context._error_result_identifier_builder is None
508+
for result_id, attack in ((first_id, first), (context.attack_result_id, second)):
509+
[stored] = await sqlite_instance.get_attack_results_async(attack_result_ids=[result_id])
510+
assert stored.atomic_attack_identifier is not None
511+
assert stored.atomic_attack_identifier == AtomicAttackIdentifier.build(
512+
attack_identifier=attack.get_identifier()
513+
)

0 commit comments

Comments
 (0)