Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
f36db28
FIX: Reject path parameters by default when building components from …
varunj-msft Oct 26, 2026
291c462
Leave out target types that require a path callers may not set
varunj-msft Oct 26, 2026
797831b
Test a target whose required path is the server upload folder
varunj-msft Oct 26, 2026
214a0fa
Merge origin/main
varunj-msft Nov 2, 2026
521ec7e
TEST: Cover saved converter copies in the server path check
varunj-msft Nov 2, 2026
66fd155
FEAT: Report the documents a storage listing cannot use
varunj-msft Nov 2, 2026
2d8c465
Report non-files and missing containers instead of skipping them
varunj-msft Nov 2, 2026
5dec828
Report an entry that can't be checked instead of failing the whole li…
varunj-msft Nov 2, 2026
bf662b7
FEAT: Read saved registry recipes
varunj-msft Nov 2, 2026
e8b0604
Merge the server path check for API input
varunj-msft Nov 2, 2026
9143ea6
FEAT: Rebuild saved registry entries in dependency order
varunj-msft Nov 2, 2026
cfa4fc0
Limit technique descriptions like the create request and export the r…
varunj-msft Nov 2, 2026
33735f1
Merge the recipe model limits and exports
varunj-msft Nov 2, 2026
11d0519
Keep restore failure messages safe and complete, and check technique …
varunj-msft Nov 2, 2026
e45865b
Test that a description at the limit is accepted
varunj-msft Nov 3, 2026
b6927a9
Merge the description limit test
varunj-msft Nov 3, 2026
c94ffbd
Show only the error type when a URL in it may carry credentials
varunj-msft Nov 3, 2026
8663852
Cut failure messages at any URL query or fragment and hide any with u…
varunj-msft Nov 3, 2026
9a419b4
Escape text that isn't UTF-8 in restore reports so they can be sent a…
varunj-msft Nov 3, 2026
8fb8095
Merge the report text escaping
varunj-msft Nov 3, 2026
9a67367
Read error text as plain normalized text and say when part of it is h…
varunj-msft Nov 3, 2026
274b4ce
Escape entry names in restore reports too
varunj-msft Nov 3, 2026
95f9492
Merge the entry name escaping
varunj-msft Nov 3, 2026
a60323a
Show error text with its original characters
varunj-msft Nov 3, 2026
e7558a4
Test that a reference name that isn't valid text can still be reported
varunj-msft Nov 3, 2026
c391b0d
Escape report text only when it is sent as JSON
varunj-msft Nov 3, 2026
0b44db6
Merge the JSON-only report escaping
varunj-msft Nov 3, 2026
f986da5
Check each character on its own so long error messages stay fast
varunj-msft Nov 3, 2026
c86bfe6
Check the exact message for every form of each mark and bound the wor…
varunj-msft Nov 3, 2026
a455494
Pass the look-alike check's arguments by keyword
varunj-msft Nov 3, 2026
ce8b684
Test that a technique waits for a saved converter in either list and …
varunj-msft Nov 3, 2026
507567b
Check the opened file before reading it and skip links deleted since …
varunj-msft Nov 3, 2026
9d37ba3
Merge the opened-file check
varunj-msft Nov 3, 2026
ff2c836
Merge the opened-file check
varunj-msft Nov 3, 2026
3afdcaa
Merge origin/main
varunj-msft Nov 3, 2026
de2f4e3
Let restore set the path parameters a target lets callers set, as the…
varunj-msft Nov 3, 2026
a45ac23
Close a file that isn't regular before reporting it and report entrie…
varunj-msft Nov 3, 2026
60477e8
Merge the file handle fix
varunj-msft Nov 3, 2026
28f2262
Merge the file handle fix
varunj-msft Nov 3, 2026
ad21f0c
Give only targets the path parameters their class lets callers set
varunj-msft Nov 3, 2026
db245dc
Test a file replaced just before it's opened and stop a blocked read …
varunj-msft Nov 4, 2026
5d3d2d0
Merge the opened-file tests
varunj-msft Nov 4, 2026
07a617f
Merge the opened-file tests
varunj-msft Nov 4, 2026
80754a2
Read saved recipes separately from building them
varunj-msft Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions pyrit/models/catalog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@

if TYPE_CHECKING:
from pyrit.models.catalog.initializer import RegisteredInitializer
from pyrit.models.catalog.registry_recipe import (
ComponentRecipe,
IgnoredRecipeFile,
RegistryRecipeKind,
RegistryRestoreReport,
RestoreFailure,
RestoreFailureReason,
SavedEntryRef,
StoredRecipe,
TechniqueRecipe,
)
from pyrit.models.catalog.scenario import (
AttackErrorSummary,
AttackRetrySummary,
Expand Down Expand Up @@ -60,9 +71,16 @@
_LAZY_EXPORTS: dict[str, str] = {
"AttackErrorSummary": "pyrit.models.catalog.scenario",
"AttackRetrySummary": "pyrit.models.catalog.scenario",
"ComponentRecipe": "pyrit.models.catalog.registry_recipe",
"IgnoredRecipeFile": "pyrit.models.catalog.registry_recipe",
"PresetEntry": "pyrit.models.catalog.scenario_preset",
"PresetIssue": "pyrit.models.catalog.scenario_preset",
"RegisteredInitializer": "pyrit.models.catalog.initializer",
"RegistryRecipeKind": "pyrit.models.catalog.registry_recipe",
"RegistryRestoreReport": "pyrit.models.catalog.registry_recipe",
"RestoreFailure": "pyrit.models.catalog.registry_recipe",
"RestoreFailureReason": "pyrit.models.catalog.registry_recipe",
"SavedEntryRef": "pyrit.models.catalog.registry_recipe",
"ScorerInstance": "pyrit.models.catalog.scorer",
"RegisteredScenario": "pyrit.models.catalog.scenario",
"RunScenarioRequest": "pyrit.models.catalog.scenario",
Expand All @@ -87,8 +105,10 @@
"ScenarioRunSummary": "pyrit.models.catalog.scenario",
"ScenarioTechniqueSummary": "pyrit.models.catalog.scenario",
"StoredPreset": "pyrit.models.catalog.scenario_preset",
"StoredRecipe": "pyrit.models.catalog.registry_recipe",
"StoredScenarioSuite": "pyrit.models.catalog.scenario_suite",
"TargetInstance": "pyrit.models.catalog.target",
"TechniqueRecipe": "pyrit.models.catalog.registry_recipe",
}

__all__ = list(_LAZY_EXPORTS)
Expand Down
167 changes: 167 additions & 0 deletions pyrit/models/catalog/registry_recipe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

"""Saved registry entries: the recipe kept for each one and what restoring them found."""

from __future__ import annotations

from enum import Enum
from typing import Annotated, ClassVar

from pydantic import BaseModel, ConfigDict, Field, JsonValue, PlainSerializer, field_validator

from pyrit.models.request_limits import MAX_IDENTIFIER_LENGTH, MAX_ITEMS, MAX_TEXT_LENGTH

_Identifier = Annotated[str, Field(max_length=MAX_IDENTIFIER_LENGTH)]
_TechniqueTag = Annotated[str, Field(pattern=r"^[A-Za-z][A-Za-z0-9_]{0,63}$")]


def _escape_invalid_text(value: str) -> str:
"""
Escape characters that can't be written as UTF-8, such as bytes from a file name that isn't UTF-8.

Returns:
str: The text, safe to send in a JSON response.
"""
return value.encode("utf-8", "backslashreplace").decode("utf-8")


# 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")]


class RegistryRecipeKind(str, Enum):
"""The registries whose entries can be saved, named after their storage folders."""

TARGETS = "targets"
CONVERTERS = "converters"
SCORERS = "scorers"
TECHNIQUES = "techniques"


class _RegistryRecipe(BaseModel):
"""Fields every saved recipe shares. The entry's name is its document name, so it isn't repeated."""

CURRENT_SCHEMA_VERSION: ClassVar[int] = 1

model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False, frozen=True)

schema_version: int = Field(
CURRENT_SCHEMA_VERSION, ge=1, le=CURRENT_SCHEMA_VERSION, description="Shape of this document"
)


class ComponentRecipe(_RegistryRecipe):
"""How to rebuild one saved target, converter, or scorer."""

class_module: _Identifier = Field(
..., min_length=1, description="Module that defined the class when the entry was saved"
)
class_name: _Identifier = Field(..., min_length=1, description="Registered class name")
params: dict[_Identifier, JsonValue] = Field(
default_factory=dict,
max_length=MAX_ITEMS,
description="Constructor parameters as the API accepted them; other entries are named, never embedded",
)


class TechniqueRecipe(_RegistryRecipe):
"""
How to rebuild one saved technique: the fields of its create request, without the name.

The fields and their limits mirror the technique API's create request, which this library
can't import.
"""

type: str = Field(..., min_length=1, max_length=256, description="Registered attack type")
params: dict[_Identifier, JsonValue] = Field(
default_factory=dict, max_length=MAX_ITEMS, description="Attack constructor parameters"
)
description: str | None = Field(default=None, max_length=MAX_TEXT_LENGTH)
tags: list[_TechniqueTag] = Field(default_factory=list, max_length=MAX_ITEMS)
request_converters: list[_Identifier] | None = Field(default=None, max_length=MAX_ITEMS)
response_converters: list[_Identifier] | None = Field(default=None, max_length=MAX_ITEMS)
adversarial_chat: _Identifier | None = None
adversarial_system_prompt: str | None = None
adversarial_seed_prompt: str | None = None
adversarial_prompt_template: str | None = None

@field_validator("tags")
@classmethod
def _validate_tags(cls, value: list[str]) -> list[str]:
folded = {tag.casefold() for tag in value}
if len(folded) != len(value):
raise ValueError("Tags must be unique, including letter case")
if {"all", "default"} & folded:
raise ValueError("all and default are reserved selectors")
return value


class SavedEntryRef(BaseModel):
"""Points at one saved entry."""

model_config = ConfigDict(frozen=True)

kind: RegistryRecipeKind
name: _DisplayText


class StoredRecipe(BaseModel):
"""A saved recipe and the version of the document it was read from."""

model_config = ConfigDict(frozen=True)

ref: SavedEntryRef
recipe: ComponentRecipe | TechniqueRecipe
version: str = Field(..., description="Opaque version of the document this recipe was read from")


class RestoreFailureReason(str, Enum):
"""Why a saved entry couldn't be restored."""

UNREADABLE_STORAGE = "unreadable_storage"
UNREADABLE_RECIPE = "unreadable_recipe"
INVALID_RECIPE = "invalid_recipe"
NEWER_SCHEMA_VERSION = "newer_schema_version"
UNKNOWN_CLASS = "unknown_class"
MODULE_MISMATCH = "module_mismatch"
UNSUPPORTED_CLASS = "unsupported_class"
UNSUPPORTED_CREDENTIAL = "unsupported_credential"
NAME_CLASH = "name_clash"
MISSING_DEPENDENCY = "missing_dependency"
FAILED_DEPENDENCY = "failed_dependency"
DEPENDENCY_LOOP = "dependency_loop"
BUILD_ERROR = "build_error"


class RestoreFailure(BaseModel):
"""One saved entry, or one whole storage folder, that couldn't be restored."""

model_config = ConfigDict(frozen=True)

kind: RegistryRecipeKind
name: _DisplayText | None = Field(None, description="Entry name; None when the whole folder couldn't be read")
reason: RestoreFailureReason
message: _DisplayText = Field(..., description="Why, in words safe to show users")
version: str | None = Field(None, description="Version of the stored document, when it could be read")
dependency: SavedEntryRef | None = Field(None, description="The entry this one needed that is missing or failed")


class IgnoredRecipeFile(BaseModel):
"""A stored file whose name can't be an entry name, so it was never read."""

model_config = ConfigDict(frozen=True)

kind: RegistryRecipeKind
file_name: _DisplayText
message: _DisplayText


class RegistryRestoreReport(BaseModel):
"""What a restore rebuilt, what it couldn't, and which files it skipped."""

model_config = ConfigDict(frozen=True)

restored: list[SavedEntryRef] = Field(default_factory=list)
failures: list[RestoreFailure] = Field(default_factory=list)
ignored: list[IgnoredRecipeFile] = Field(default_factory=list)
1 change: 1 addition & 0 deletions pyrit/models/request_limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
MAX_IDENTIFIER_LENGTH = 256
MAX_LABEL_KEY_LENGTH = 128
MAX_LABEL_VALUE_LENGTH = 1_024
MAX_TEXT_LENGTH = 100_000
Loading
Loading