Skip to content

FIX: Stop a scan whose launch scripts would replace a saved entry (part 6) - #3986

Open
varunj-msft wants to merge 117 commits into
microsoft:mainfrom
varunj-msft:varunj-msft/13178-Launch-Keeps-Saved-Entries
Open

varunj-msft wants to merge 117 commits into
microsoft:mainfrom
varunj-msft:varunj-msft/13178-Launch-Keeps-Saved-Entries

Conversation

@varunj-msft

@varunj-msft varunj-msft commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Description

Part 6 of saving Registry entries across restarts. Stacked on part 4 (#3909), not on part 5; part 7 (#4025) builds on it. This PR's own changes: varunj-msft/PyRIT@varunj-msft/13178-Restore-Saved-Entries...varunj-msft/13178-Launch-Keeps-Saved-Entries

Design: 4.6 When a saved entry can't be restored. Where every part fits: PR map.

This is one of the changes for saving Registry entries across restarts. It builds on #3909. Until the earlier PRs merge, the diff here also shows their changes.

Problem. Scripts a scan runs at launch (initializers on POST /api/scenarios/runs) register their targets, converters and scorers with replace=True. One that used the name of an entry restored from a saved recipe replaced that entry for everyone using the app. Scripts that skip a name that's already taken (the technique and tools scripts, round-robin grouping, and the scorer script's preferred scorers) left the saved entry in place, so the scan ran with it instead of their own.

Change.

  • Restore marks each entry it rebuilds, in the entry's metadata (SAVED_ENTRY_METADATA_KEY, holding the kind).
  • DefaultInstanceRegistry.register(..., replace=True) refuses to replace a marked entry with SavedEntryReplacementError. It's a RuntimeError, because the converter and scorer startup scripts skip any entry that raises a ValueError.
  • register_from_factories (techniques), the tools script, the target script's round-robin grouping, and the scorer script's two picks by name (the preferred scorer in each category and scale_and_refusal) refuse instead of using a marked entry in place of their own.
  • The launch records refusals while each script runs, so one the script caught itself, or wrapped in an error of its own, still counts, and returns 409 naming the script and the entry. That includes the duplicate-name error for a marked entry, whether it comes from register, from the name check create_named_instance runs first, or from the technique registry's case-insensitive name check. A suite shows the same message for that entry instead of the generic one.

What stays the same. Startup and apply start from empty registries, run the scripts, then restore, so nothing is marked while the scripts run. Launches whose scripts don't touch a saved name, and scripts replacing startup entries, work as before. Creating entries through the API already refuses taken names.

Not covered. A custom script that changes registry contents without going through the registry methods. Picking a scorer by tag or by metrics still considers every registered scorer, saved ones included.

Tests and Documentation

  • tests/unit/registry/test_instance_registry.py: replacing a marked entry is refused and changes nothing; a refusal is recorded even when the caller catches it, including a caught name check against a marked entry.
  • tests/unit/registry/test_attack_technique_registry.py: register_from_factories refuses to skip a saved technique and still skips a startup one; a caught case-insensitive clash with a saved technique is recorded.
  • tests/unit/registry/test_converter_registry.py: a caught name clash with a marked entry through create_named_instance is recorded.
  • tests/unit/registry/test_registry_recipe_restore.py: restore marks the entries it rebuilds, with their kind, and leaves startup entries unmarked.
  • tests/unit/backend/test_scenario_run_service.py: a launch whose script would replace a saved converter returns the conflict and keeps the saved object, for the real converter startup script, a script that raises, a script that catches the error itself, and a script that wraps it in its own error; a script error with no clash passes through unchanged; the tools script stops on a saved technique or converter with its name; a script can still replace a startup entry.
  • tests/unit/setup/test_converter_initializer.py: the converter startup script doesn't swallow the refusal (it would if the error were a ValueError).
  • tests/unit/setup/test_scorer_initializer.py: the scorer script refuses to tag a saved scorer under a preferred name or as scale_and_refusal.
  • tests/unit/setup/test_targets_initializer.py: round-robin grouping refuses to leave a saved target under the group's name.
  • tests/unit/backend/test_scenario_suite_execution_service.py: a suite entry shows the clash message.
  • Removing each guard in turn fails at least one of these tests.
  • pytest -n 4 --dist=loadfile tests/unit/backend tests/unit/setup tests/unit/registry tests/unit/models: 8,071 passed, 9 skipped, 4 xfailed.
  • pre-commit (ruff, ty) on the changed files: passed.

No JupyText changes.

…API input

External construction now rejects a parameter whose type includes Path unless the
calling service passes the name in allowed_path_params after making the value safe.
The converter service allows its upload parameters once they are stored, and the
target service allows the server-set upload folder and the GitHubCopilotTarget
working directory. The scorer-only check is removed, and the "Valid parameters"
list leaves out path parameters callers cannot set.
The target catalog now applies the path rule to required parameters too, so a
target that needs a disallowed path is left out instead of listed without the
field, matching the scorer catalog. allowed_path_params is typed frozenset[str]
so a bare string cannot turn the allow-list into a substring match.
The target catalog still lists a target whose required path parameter is its
server-set upload folder when the server has one, and leaves it out otherwise.
# Conflicts:
#	tests/unit/backend/test_converter_service.py
# Conflicts:
#	pyrit/registry/registry_recipe_restore.py
# Conflicts:
#	pyrit/backend/services/runtime_lifecycle.py
#	tests/unit/backend/test_runtime_lifecycle.py
# Conflicts:
#	pyrit/setup/initializers/tools.py
#	tests/unit/backend/test_scenario_run_service.py
#	tests/unit/backend/test_scenario_suite_execution_service.py
#	tests/unit/registry/test_attack_technique_registry.py
#	tests/unit/registry/test_instance_registry.py
#	tests/unit/registry/test_registry_recipe_restore.py
create_named_instance checks the name before it builds, and the technique
registry checks names case-insensitively before it registers. Both raised the
duplicate-name error without recording the clash, so a launch script that
caught either one could still leave a saved entry in place of its own. The name
check now records the clash itself, as register already did, and the technique
check records it too.
@varunj-msft varunj-msft changed the title FIX: Stop a scan whose launch scripts would replace a saved entry FIX: Stop a scan whose launch scripts would replace a saved entry (part 6) Nov 9, 2026
FileDocumentStorage now raises DocumentStorageUnavailableError when the
folder or container can't be used, and DocumentUnreadableError when one
document exists but can't be read. The message names the location, with
any SAS token hidden, and a short reason. One backend handler maps both to
503 and logs the location and reason, with the traceback at debug level,
so the per-route storage helpers are gone.

A single read, a conditional save and a delete now follow the listing's
rules: a folder, pipe or broken link named like a document is unreadable
rather than missing, and on Blob only a deleted blob counts as missing.
Blob sign-in, permission, network and missing-container errors stop the
listing instead of marking every file unreadable. Each unreadable report
keeps the real file name and the original error, and the listing's
documents are read-only.

Initializer delete removes a script that can't be read or decoded, and
registering one no longer turns a storage write failure into a 400.
Tests patch module-level aliases instead of os.
List recipes escapes the OS text it reports, such as a file name that
isn't UTF-8, and a recipe whose JSON holds an unpaired surrogate escape
is reported as invalid, so the models no longer need a JSON-only
serializer. Recipes may start with a byte-order mark. A document without
schema_version reads as version 1, and each file is checked against the
format version of the model that reads it. Validation summaries shorten
each location part and the whole message, since a location can repeat a
stored key.

Recipes and StoredRecipe are no longer frozen, since their params and
tags never were, and StoredRecipe checks that its recipe type matches its
registry. The listing's recipes are read-only, and it keeps the error
behind each recipe that couldn't be read, for the log.
Listing catches only storage errors, so a bug is raised instead of being
reported as unreadable storage, and a bug while planning is a build
error rather than an invalid recipe. Each failure is logged once, with
the error behind it and URL credentials removed. The report shows the
error's text with each URL's user name, query and fragment removed, any
SAS signature hidden, and the length capped.

A class that moved within PyRIT is restored; one now registered from
outside PyRIT still fails. A technique whose name or tag clashes is a
name clash, and anything else registration refuses is an invalid
recipe. A recipe that sets a server file or folder is refused before it
is built. Class metadata comes from the registry's cache. The class
docstring lists the steps the API's create runs that restore skips.
The runtime becomes ready even when some saved entries fail; the
failures stay in the runtime status. Each failed entry keeps its name in
its registry: looking it up, or taking the name, raises
SavedEntryUnavailableError, which the backend answers with 409, so
nothing else stands in for it. Administrators see the full message and
everyone else a fixed explanation of the reason. A folder that can't be
listed holds every unregistered name of its kind, and each entry it left
unbuilt is reported.

While a saved technique is held, a launch or estimate that picks
techniques by all, default or a tag is refused with 409 for scenarios
whose techniques come from the registry, since the broken technique's
tags may be unknown. Naming techniques still works; naming the held one
fails with its reason. Route catch-alls let the error through instead of
turning it into a 500.
A launch script that registers a name held for a saved entry that
couldn't be restored is refused, and the refusal is recorded even when
the script catches it, so the launch stops instead of running with the
script's entry in the saved one's place. A suite entry that fails on a
held entry, or on a broad technique selection, shows the reason without
the error's details.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants