Repository navigation
FEAT: Read saved registry recipes (part 2) - #3892
varunj-msft wants to merge 29 commits into
Conversation
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues require changes to the registry recipe models or storage reader.
No open issues 🎉
What changed in this PR
Adds the models and storage reader needed to preserve registry entries across restarts. This second stacked PR builds on #3891’s document-listing support; entry rebuilding and startup restoration remain for later PRs.
Changes:
- Defines versioned component and technique recipes, plus restore-report types with JSON-safe text.
- Reads recipes from per-registry local or Azure Blob folders, preserving SAS queries and reporting invalid, unreadable, newer-format, and ignored files.
- Adds unit coverage for parsing, versions, validation, storage failures, and report serialization without changing existing callers.
| File | Description |
|---|---|
tests/unit/registry/test_registry_recipe_storage.py |
Tests recipe loading, validation, versions, Blob paths, and report serialization. |
tests/unit/registry/test_file_document_storage.py |
Tests issue-aware local and Blob listings and safe error messages. |
pyrit/registry/registry_recipe_storage.py |
Adds per-registry storage, recipe parsing, and failure reporting. |
pyrit/registry/file_document_storage.py |
Adds issue-aware listings while retaining legacy listing behavior. |
pyrit/models/request_limits.py |
Adds the shared technique-description text limit. |
pyrit/models/catalog/registry_recipe.py |
Defines recipe schemas, references, and restore-report models. |
pyrit/models/catalog/__init__.py |
Exposes the new models through lazy exports. |
Review effort: Balanced
…s that can't be checked
…navailable storage
Richard Lundeen (richlundeen)
left a comment
There was a problem hiding this comment.
Requesting changes. My review on #3908 has the main comment about the direction for the restore work; this PR stays as it is apart from the inline comments below. Inline comments marked Blocking need to be addressed before merge; Non-blocking ones are judgment calls.
|
|
||
| model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False, frozen=True) | ||
|
|
||
| schema_version: int = Field( |
There was a problem hiding this comment.
Blocking: default schema_version to 1, not to the current version.
When CURRENT_SCHEMA_VERSION moves to 2, a file without the field would read as version 2 while holding version 1 content, so a migration would skip exactly the files that need it. scenario_preset.py (lines 66-71) explains this and uses Field(1, ge=1), and scenario_suite.py does the same.
There was a problem hiding this comment.
Pin the default with a test, as the preset and suite models do
The preset and suite models keep this rule from coming back with tests: test_schema_version_default_does_not_track_the_current_schema_version in test_scenario_preset.py and test_schema_version_default_stays_pinned_to_first_shape in test_scenario_suite.py. No recipe test does. test_reads_technique_recipes_as_technique_recipes reads a document without schema_version, but it compares the result with TechniqueRecipe.model_validate(document), which uses the same default as the code under test. With the fix, add a test that the schema_version default is 1 for ComponentRecipe and TechniqueRecipe. Then a later change cannot tie the default to the current version again without a test failure.
| if not isinstance(payload, dict): | ||
| return self._failure(name=name, version=version, reason=invalid, message="Not a JSON object.") | ||
| schema_version = payload.get("schema_version") | ||
| current = ComponentRecipe.CURRENT_SCHEMA_VERSION |
There was a problem hiding this comment.
Blocking (small): this compares technique files against ComponentRecipe's version. Choose the model first (line 149) and use model.CURRENT_SCHEMA_VERSION.
There was a problem hiding this comment.
Use one recipe format version instead of a per-model ClassVar
Choosing the model first fixes this lookup, but the ClassVar still cannot change per model. The schema_version default and le bound are set once, when _RegistryRecipe is defined. If TechniqueRecipe sets CURRENT_SCHEMA_VERSION = 2, its schema_version field keeps default 1 and Le(le=1). Then, with the suggested lookup, this check passes a version-2 technique file, and model_validate reports it as invalid_recipe ("Input should be less than or equal to 1"). No layer of this stack (#3908, #3909, #3965) overrides the ClassVar. Use one module constant, as PRESET_SCHEMA_VERSION does in scenario_preset.py: declare schema_version: int = Field(1, ge=1, le=RECIPE_SCHEMA_VERSION) and compare with that constant here. This also fixes the default in the schema_version thread on line 49 of registry_recipe.py.
| version = self._compute_version(content) | ||
| invalid = RestoreFailureReason.INVALID_RECIPE | ||
| try: | ||
| payload = json.loads(content.decode("utf-8")) |
There was a problem hiding this comment.
Blocking (small): accept a UTF-8 byte-order mark.
Until saving lands, recipes are written by hand, and Windows tools often add a BOM; such a file is reported as "Not valid UTF-8 JSON." Decode with utf-8-sig.
| return RestoreFailure(kind=self._kind, name=name, reason=reason, message=message, version=version) | ||
|
|
||
| @staticmethod | ||
| def _describe_validation_error(error: ValidationError) -> str: |
There was a problem hiding this comment.
Blocking (small): cap what this repeats.
pydantic's loc includes stored key names verbatim, such as an unknown top-level key or an over-long params key, with no length limit, although the docstring says stored values aren't echoed. Truncate each location part, cap the whole message, and adjust the docstring.
There was a problem hiding this comment.
Also escape control characters, and apply the cap in _failure
Two additions to this cap. Key names keep control characters: an unknown key "\n\x1b[31mFAKE" goes into RestoreFailure.message unchanged, so a log or terminal that shows the message gets a new line and a color code. Escape non-printable characters in each location part as well as truncating it. The newer-version message on line 145 also repeats stored content: a 4,000-digit schema_version gives a 4,065-character message. Apply the cap in _failure, which builds every failure in this file, not only in _describe_validation_error, and add a test that pins the cap with an over-long key.
|
|
||
|
|
||
| # The model keeps the text it was given, so a name still matches the entry it names; only JSON output is escaped. | ||
| _DisplayText = Annotated[str, PlainSerializer(_escape_invalid_text, return_type=str, when_used="json")] |
There was a problem hiding this comment.
Blocking (simplification): escape once at the edge instead of in the model.
Three commits went into making report text that isn't valid UTF-8 serializable. The only sources are file names from the OS and unpaired surrogate escapes such as \udcff inside a recipe. Escape file names once in list_recipes (line 120 of registry_recipe_storage.py), and reject a recipe whose text has unpaired surrogates as invalid (one check after json.loads). Then _DisplayText and its JSON-only serializer can go.
There was a problem hiding this comment.
Check the whole payload, and escape the ignored-file message too
Two details for this change. Model validation rejects unpaired surrogates only in strings with length or pattern limits, such as class names, tags, converter names, params keys, and description. It keeps them in params values at any depth and in adversarial_system_prompt, adversarial_seed_prompt, and adversarial_prompt_template, which have no limits. At this head, a technique file with "adversarial_system_prompt": "p\udcff", or a params value "v\udcff", is returned as a valid StoredRecipe, and its model_dump_json() raises PydanticSerializationError. So the check after json.loads must walk the whole payload. Also, the ignored file's message repeats the raw name, because validate_registry_name builds "Invalid registry name 'bad\udcff' ...". Escape message as well as file_name in list_recipes; otherwise that message cannot be encoded after _DisplayText is removed.
| ) | ||
|
|
||
|
|
||
| class TechniqueRecipe(_RegistryRecipe): |
There was a problem hiding this comment.
Non-blocking: shared model or copy.
TechniqueRecipe copies the API's CreateTechniqueRequest field for field, and #3908 adds three tests to keep them in step. A shared base model would remove the copy and the tests; the copy does force a deliberate decision about the stored format whenever the API changes. Either is defensible.
There was a problem hiding this comment.
The import direction does not force the copy
One fact for this choice: the TechniqueRecipe docstring says the fields are copied because this library can't import the API's create request. A shared base does not need that import. Put the base in pyrit.models and let CreateTechniqueRequest extend it; pyrit/backend/models/techniques.py already imports MAX_ITEMS and TechniqueOverride from pyrit.models modules, and doc/code/framework.md (Models) asks authors to consider moving overlapping classes to pyrit.models. If you keep the copy, change the docstring to say that it is a deliberate copy of the stored format that the parity tests in #3908 keep in step.
|
|
||
| CURRENT_SCHEMA_VERSION: ClassVar[int] = 1 | ||
|
|
||
| model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False, frozen=True) |
There was a problem hiding this comment.
Non-blocking: frozen=True doesn't make params or tags immutable, and hashing an instance raises because of them. Fine unless someone caches or hashes these; otherwise consider dropping frozen so it doesn't promise more than it does.
| name: _DisplayText | ||
|
|
||
|
|
||
| class StoredRecipe(BaseModel): |
There was a problem hiding this comment.
Non-blocking: StoredRecipe accepts a technique ref with a ComponentRecipe, and the other way round. A small model validator would catch that misuse early.
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.
| return self._failure( | ||
| name=name, version=version, reason=invalid, message=self._describe_validation_error(error) | ||
| ) | ||
| except RecursionError: |
There was a problem hiding this comment.
[P3] Report deep params nesting as too deep, and remove the RecursionError branch that cannot run
model_validate does not raise RecursionError for deep JSON. pydantic-core stops at about 100 nested levels and raises a ValidationError of type recursion_loop, so this handler never runs. A target file whose params nest 100 levels is reported as "Not a valid saved recipe: params.x.list.0.list.0...list.0: Recursion error - cyclic reference detected" (772 characters). A JSON file cannot hold a cycle, and the same problem at greater depth gets "Nested too deeply to read." from the json.loads branch. Map a ValidationError that has a recursion_loop error to "Nested too deeply to read." and remove this handler. test_reports_a_document_nested_too_deeply nests only a top-level list, so it reaches only the json.loads branch; add a case that nests about 150 levels in params and checks the message.
Description
This is one of the changes for saving Registry entries across restarts. It builds on #3891. Until #3891 merges, the diff here also shows its changes.
Problem. There's no file format or storage for a saved target, converter, scorer or technique.
Change.
pyrit/models/catalog/registry_recipe.py: the recipe models, exported frompyrit.models.catalog.schema_version,class_module,class_nameandparams.credentialsis reported, not silently read.pyrit/models/request_limits.py: addsMAX_TEXT_LENGTHfor the technique description. FIX: Apply the request limits to request fields added since the last limits PR #3595 adds the same line, so the two merge cleanly; until it lands,backend/models/common.pykeeps its own copy of the same value.pyrit/registry/registry_recipe_storage.py:RegistryRecipeStorage, aFileDocumentStoragewith one folder per registry (targets/,converters/,scorers/,techniques/) under one source.list_recipes()uses the listing from the previous PR. It returns the parsed recipes with their versions (the SHA-256 of the bytes), a failure for each unreadable, invalid or newer-format file, and the ignored files.\udcffescape inside a recipe, so the report can always be sent as JSON. Valid text is unchanged.What stays the same. Nothing calls this yet; #3908 (part 3) rebuilds entries from these recipes. No API, config or behavior change.
Tests and Documentation
tests/unit/registry/test_registry_recipe_storage.py: recipes and their versions, field limits and unknown fields, failures for unreadable, invalid and newer-format files, ignored names, Blob folders that keep a SAS token in the query, and report text that isn't UTF-8 (escaped only when sent as JSON; models keep the text they were given; valid text unchanged).pytest tests/unit/registry tests/unit/models -n 4: 3,459 passedpre-commit run --from-ref origin/main --to-ref HEAD: passed