Skip to content

Commit e84f18f

Browse files
biefanromanlutz
andauthored
FIX Prevent duplicate attacks when scenario resume history cannot be read (#2857)
Co-authored-by: Roman Lutz <romanlutz13@gmail.com>
1 parent 2497e2d commit e84f18f

2 files changed

Lines changed: 111 additions & 65 deletions

File tree

‎pyrit/scenario/core/scenario.py‎

Lines changed: 30 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,12 +1310,11 @@ def _validate_stored_scenario(
13101310
f"(ID: {self._scenario_result_id}, state: {stored_result.scenario_run_state})"
13111311
)
13121312

1313-
def _get_completed_objective_hashes_for_attack(self, *, atomic_attack: AtomicAttack) -> set[str]:
1313+
def _get_completed_objective_hashes_by_attack(self) -> dict[tuple[str, str | None], set[str]]:
13141314
"""
1315-
Return the set of ``objective_sha256`` values already completed (non-error)
1316-
for a specific atomic attack inside this scenario.
1315+
Index completed objective hashes for every atomic attack in this scenario.
13171316
1318-
Queries ``AttackResultEntry`` rows directly by ``attribution_parent_id`` —
1317+
Read the persisted attack results once by ``attribution_parent_id`` —
13191318
which is stamped at write-time by the attack persistence path — so
13201319
results from an interrupted run are visible even though the
13211320
``ScenarioResult.attack_results`` aggregate may not yet reflect them.
@@ -1329,40 +1328,29 @@ def _get_completed_objective_hashes_for_attack(self, *, atomic_attack: AtomicAtt
13291328
``parent_eval_hash`` was introduced (or by callers that don't supply
13301329
one) match name-only as a backward-compatible fallback.
13311330
1332-
Args:
1333-
atomic_attack (AtomicAttack): The live atomic attack whose
1334-
``atomic_attack_name`` and technique identifier scope the query.
1335-
13361331
Returns:
1337-
set[str]: ``objective_sha256`` hex strings for completed-without-error rows.
1332+
dict[tuple[str, str | None], set[str]]: Completed objective hashes keyed by
1333+
collection name and technique eval hash. A missing eval hash retains
1334+
the legacy name-only matching behavior.
1335+
1336+
Raises:
1337+
Exception: If persisted progress cannot be read. Treating a failed read as
1338+
empty progress would re-execute already-completed objectives.
13381339
"""
13391340
if not self._scenario_result_id:
1340-
return set()
1341-
1342-
atomic_attack_name = atomic_attack.atomic_attack_name
1343-
expected_eval_hash = atomic_attack.technique_eval_hash
1344-
1345-
completed_hashes: set[str] = set()
1346-
try:
1347-
rows = self._memory.get_attack_results(scenario_result_id=self._scenario_result_id)
1348-
for row in rows:
1349-
if row.outcome == AttackOutcome.ERROR:
1350-
continue
1351-
if row.attribution_data is None:
1352-
continue
1353-
if row.attribution_data.get("parent_collection") != atomic_attack_name:
1354-
continue
1355-
row_eval_hash = row.attribution_data.get("parent_eval_hash")
1356-
if row_eval_hash is not None and row_eval_hash != expected_eval_hash:
1357-
continue
1358-
if row.objective:
1359-
completed_hashes.add(to_sha256(row.objective))
1360-
except Exception as e:
1361-
logger.warning(
1362-
f"Failed to retrieve completed objective hashes for atomic attack '{atomic_attack_name}': {str(e)}"
1363-
)
1341+
return {}
13641342

1365-
return completed_hashes
1343+
rows = self._memory.get_attack_results(scenario_result_id=self._scenario_result_id)
1344+
completed_by_attack: dict[tuple[str, str | None], set[str]] = {}
1345+
for row in rows:
1346+
if row.outcome == AttackOutcome.ERROR or not row.attribution_data or not row.objective:
1347+
continue
1348+
name = row.attribution_data.get("parent_collection")
1349+
eval_hash = row.attribution_data.get("parent_eval_hash")
1350+
if not isinstance(name, str) or (eval_hash is not None and not isinstance(eval_hash, str)):
1351+
continue
1352+
completed_by_attack.setdefault((name, eval_hash), set()).add(to_sha256(row.objective))
1353+
return completed_by_attack
13661354

13671355
async def _get_remaining_atomic_attacks_async(self) -> list[AtomicAttack]:
13681356
"""
@@ -1372,7 +1360,8 @@ async def _get_remaining_atomic_attacks_async(self) -> list[AtomicAttack]:
13721360
atomic attack enforces uniqueness of objective hashes at construction
13731361
time, and the executor stamps ``attribution_parent_id`` +
13741362
``attribution_data["parent_collection"]`` on the row so a content-hash
1375-
join is sufficient.
1363+
join is sufficient. Each call reads a fresh snapshot before filtering any
1364+
seed groups; a read failure propagates to the scenario's retry policy.
13761365
13771366
Returns:
13781367
list[AtomicAttack]: List of atomic attacks with uncompleted objectives.
@@ -1382,9 +1371,14 @@ async def _get_remaining_atomic_attacks_async(self) -> list[AtomicAttack]:
13821371
return self._atomic_attacks
13831372

13841373
remaining_attacks: list[AtomicAttack] = []
1374+
# Read and index one snapshot before changing any attack's remaining work.
1375+
completed_by_attack = self._get_completed_objective_hashes_by_attack()
13851376

13861377
for atomic_attack in self._atomic_attacks:
1387-
completed_hashes = self._get_completed_objective_hashes_for_attack(atomic_attack=atomic_attack)
1378+
name = atomic_attack.atomic_attack_name
1379+
completed_hashes = completed_by_attack.get((name, atomic_attack.technique_eval_hash), set()) | (
1380+
completed_by_attack.get((name, None), set())
1381+
)
13881382

13891383
if completed_hashes:
13901384
original_count = len(atomic_attack.seed_groups)

‎tests/unit/scenario/core/test_scenario_retry.py‎

Lines changed: 81 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
AttackSeedGroup,
1919
ComponentIdentifier,
2020
Message,
21+
ScenarioRunState,
2122
SeedObjective,
2223
config_hash,
2324
)
@@ -484,6 +485,70 @@ async def mock_run_with_logged_failure(*args, **kwargs):
484485
class TestScenarioResumption:
485486
"""Tests for Scenario resumption after partial failure."""
486487

488+
@pytest.mark.parametrize("persistent_failure", [False, True], ids=["transient-read", "persistent-read"])
489+
async def test_resume_read_failure_never_reexecutes_completed_objectives(
490+
self, mock_objective_target, persistent_failure
491+
):
492+
completed = create_mock_atomic_attack("completed", ["objective1"])
493+
pending = create_mock_atomic_attack("pending", ["objective2"])
494+
completed.run_async = create_mock_run_async([create_attack_result(1)], atomic_attack=completed)
495+
pending.run_async = create_mock_run_async([create_attack_result(2)], atomic_attack=pending)
496+
scenario = ConcreteScenario(
497+
name="Resume Read Failure", version=1, atomic_attacks_to_return=[completed, pending]
498+
)
499+
scenario.set_params_from_args(args={"objective_target": mock_objective_target, "max_retries": 1})
500+
await scenario.initialize_async()
501+
completed.set_scenario_result_id(scenario._scenario_result_id)
502+
save_attack_results_to_memory([create_attack_result(1)], atomic_attack=completed)
503+
504+
memory = CentralMemory.get_memory_instance()
505+
original_get_results = memory.get_attack_results
506+
reads = 0
507+
508+
def read_results(**kwargs):
509+
nonlocal reads
510+
reads += 1
511+
if persistent_failure or reads == 1:
512+
raise RuntimeError("resume storage unavailable")
513+
return original_get_results(**kwargs)
514+
515+
with patch.object(memory, "get_attack_results", side_effect=read_results):
516+
if persistent_failure:
517+
with pytest.raises(RuntimeError, match="resume storage unavailable"):
518+
await scenario.run_async()
519+
else:
520+
await scenario.run_async()
521+
522+
completed.run_async.assert_not_called()
523+
assert pending.run_async.call_count == (0 if persistent_failure else 1)
524+
assert reads == 2
525+
[stored] = memory.get_scenario_results(scenario_result_ids=[scenario._scenario_result_id])
526+
assert stored.number_tries == 2
527+
assert stored.scenario_run_state == (
528+
ScenarioRunState.FAILED if persistent_failure else ScenarioRunState.COMPLETED
529+
)
530+
assert len(stored.attack_results["completed"]) == 1
531+
if persistent_failure:
532+
assert completed.objectives == ["objective1"]
533+
assert pending.objectives == ["objective2"]
534+
assert stored.error_message == "resume storage unavailable"
535+
536+
async def test_resume_loads_one_result_snapshot_for_all_atomic_attacks(self, mock_objective_target):
537+
attacks = [create_mock_atomic_attack(f"attack_{i}", [f"objective{i}"]) for i in range(10)]
538+
scenario = ConcreteScenario(name="Resume Snapshot", version=1, atomic_attacks_to_return=attacks)
539+
scenario.set_params_from_args(args={"objective_target": mock_objective_target})
540+
await scenario.initialize_async()
541+
for i, attack in enumerate(attacks[:5]):
542+
attack.set_scenario_result_id(scenario._scenario_result_id)
543+
save_attack_results_to_memory([create_attack_result(i)], atomic_attack=attack)
544+
545+
memory = CentralMemory.get_memory_instance()
546+
with patch.object(memory, "get_attack_results", wraps=memory.get_attack_results) as read_results:
547+
remaining = await scenario._get_remaining_atomic_attacks_async()
548+
549+
assert remaining == attacks[5:]
550+
read_results.assert_called_once_with(scenario_result_id=scenario._scenario_result_id)
551+
487552
async def test_parameter_build_partial_result_persists_linkage_before_retry(
488553
self,
489554
mock_objective_target: MagicMock,
@@ -858,8 +923,8 @@ async def noop_run(*args, **kwargs):
858923

859924

860925
@pytest.mark.usefixtures("patch_central_database")
861-
class TestGetCompletedObjectiveHashesForAttack:
862-
"""Direct tests for ``Scenario._get_completed_objective_hashes_for_attack``
926+
class TestGetCompletedObjectiveHashesByAttack:
927+
"""Direct tests for ``Scenario._get_completed_objective_hashes_by_attack``
863928
— the filter that excludes already-completed objectives on resume.
864929
865930
Covers the row-filtering branches: outcome=ERROR rows, rows without
@@ -873,12 +938,6 @@ def _make_scenario(self, scenario_result_id="scn-1"):
873938
scenario._memory = MagicMock()
874939
return scenario
875940

876-
def _make_atomic(self, name, eval_hash="hash-A"):
877-
atomic = MagicMock(spec=AtomicAttack)
878-
atomic.atomic_attack_name = name
879-
type(atomic).technique_eval_hash = PropertyMock(return_value=eval_hash)
880-
return atomic
881-
882941
def _row(self, *, objective, outcome=AttackOutcome.SUCCESS, attribution_data=None):
883942
row = MagicMock()
884943
row.outcome = outcome
@@ -889,10 +948,8 @@ def _row(self, *, objective, outcome=AttackOutcome.SUCCESS, attribution_data=Non
889948
def test_returns_empty_when_scenario_result_id_unset(self):
890949
scenario = ConcreteScenario(name="S", version=1, atomic_attacks_to_return=[])
891950
scenario._scenario_result_id = None
892-
result = scenario._get_completed_objective_hashes_for_attack(
893-
atomic_attack=self._make_atomic("a"),
894-
)
895-
assert result == set()
951+
result = scenario._get_completed_objective_hashes_by_attack()
952+
assert result == {}
896953

897954
def test_skips_error_rows(self):
898955
from pyrit.common.utils import to_sha256
@@ -910,10 +967,8 @@ def test_skips_error_rows(self):
910967
attribution_data={"parent_collection": "a", "parent_eval_hash": "hash-A"},
911968
),
912969
]
913-
result = scenario._get_completed_objective_hashes_for_attack(
914-
atomic_attack=self._make_atomic("a"),
915-
)
916-
assert result == {to_sha256("ok")}
970+
result = scenario._get_completed_objective_hashes_by_attack()
971+
assert result == {("a", "hash-A"): {to_sha256("ok")}}
917972

918973
def test_skips_rows_without_attribution_data(self):
919974
from pyrit.common.utils import to_sha256
@@ -926,12 +981,10 @@ def test_skips_rows_without_attribution_data(self):
926981
attribution_data={"parent_collection": "a", "parent_eval_hash": "hash-A"},
927982
),
928983
]
929-
result = scenario._get_completed_objective_hashes_for_attack(
930-
atomic_attack=self._make_atomic("a"),
931-
)
932-
assert result == {to_sha256("new")}
984+
result = scenario._get_completed_objective_hashes_by_attack()
985+
assert result == {("a", "hash-A"): {to_sha256("new")}}
933986

934-
def test_skips_rows_with_mismatched_eval_hash(self):
987+
def test_indexes_same_name_techniques_separately(self):
935988
"""Two atomic attacks with the same name but different techniques
936989
must not cross-pollinate completed hashes. This is the core Option-B
937990
guarantee."""
@@ -948,10 +1001,11 @@ def test_skips_rows_with_mismatched_eval_hash(self):
9481001
attribution_data={"parent_collection": "encoding", "parent_eval_hash": "hash-hex"},
9491002
),
9501003
]
951-
result = scenario._get_completed_objective_hashes_for_attack(
952-
atomic_attack=self._make_atomic("encoding", eval_hash="hash-base64"),
953-
)
954-
assert result == {to_sha256("mine")}
1004+
result = scenario._get_completed_objective_hashes_by_attack()
1005+
assert result == {
1006+
("encoding", "hash-base64"): {to_sha256("mine")},
1007+
("encoding", "hash-hex"): {to_sha256("theirs")},
1008+
}
9551009

9561010
def test_backward_compat_matches_name_only_when_eval_hash_missing(self):
9571011
"""Rows persisted before ``parent_eval_hash`` shipped match name-only
@@ -965,10 +1019,8 @@ def test_backward_compat_matches_name_only_when_eval_hash_missing(self):
9651019
attribution_data={"parent_collection": "a"}, # no parent_eval_hash
9661020
),
9671021
]
968-
result = scenario._get_completed_objective_hashes_for_attack(
969-
atomic_attack=self._make_atomic("a", eval_hash="hash-A"),
970-
)
971-
assert result == {to_sha256("old")}
1022+
result = scenario._get_completed_objective_hashes_by_attack()
1023+
assert result == {("a", None): {to_sha256("old")}}
9721024

9731025

9741026
@pytest.mark.usefixtures("patch_central_database")

0 commit comments

Comments
 (0)