Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 13 additions & 3 deletions pyrit/backend/mappers/converter_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@
from pyrit.models import ConverterIdentifier


def converter_object_to_instance(converter_id: str, converter_obj: Converter) -> ConverterInstance:
def converter_object_to_instance(
*,
converter_id: str,
converter_obj: Converter,
is_llm_based: bool,
description: str | None,
) -> ConverterInstance:
"""
Build a ConverterInstance DTO from a registry converter object.

Expand All @@ -24,13 +30,17 @@ def converter_object_to_instance(converter_id: str, converter_obj: Converter) ->
on the wire.

Args:
converter_id: The unique converter instance identifier.
converter_obj: The domain Converter object from the registry.
converter_id (str): The unique converter instance identifier.
converter_obj (Converter): The domain Converter object from the registry.
is_llm_based (bool): Whether the converter class requires an LLM target.
description (str | None): The converter class description.

Returns:
ConverterInstance DTO wrapping the converter's identifier.
"""
return ConverterInstance(
converter_id=converter_id,
identifier=ConverterIdentifier.from_component_identifier(converter_obj.get_identifier()),
is_llm_based=is_llm_based,
description=description,
)
4 changes: 4 additions & 0 deletions pyrit/backend/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
ConverterInstanceListResponse,
ConverterPreviewRequest,
ConverterPreviewResponse,
ConverterTypeEntry,
ConverterTypeResponse,
CreateConverterRequest,
CreateConverterResponse,
PreviewStep,
Expand Down Expand Up @@ -97,6 +99,8 @@
"ConverterInstanceListResponse": "pyrit.backend.models.converters",
"ConverterPreviewRequest": "pyrit.backend.models.converters",
"ConverterPreviewResponse": "pyrit.backend.models.converters",
"ConverterTypeEntry": "pyrit.backend.models.converters",
"ConverterTypeResponse": "pyrit.backend.models.converters",
"CreateConverterRequest": "pyrit.backend.models.converters",
"CreateConverterResponse": "pyrit.backend.models.converters",
"PreviewStep": "pyrit.backend.models.converters",
Expand Down
2 changes: 2 additions & 0 deletions pyrit/backend/models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from pydantic import BaseModel, Field

REGISTRY_INSTANCE_NAME_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"


class PaginationInfo(BaseModel):
"""Pagination metadata for list responses."""
Expand Down
39 changes: 33 additions & 6 deletions pyrit/backend/models/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@

from pydantic import BaseModel, Field

from pyrit.backend.models.common import REGISTRY_INSTANCE_NAME_PATTERN
from pyrit.models import ConverterIdentifier, Parameter, PromptDataType

__all__ = [
"ConverterCatalogEntry",
"ConverterCatalogResponse",
"ConverterInstance",
"ConverterInstanceListResponse",
"ConverterTypeEntry",
"ConverterTypeResponse",
"CreateConverterRequest",
"CreateConverterResponse",
"ConverterPreviewRequest",
Expand All @@ -27,11 +30,11 @@


# ============================================================================
# Converter Catalog (Available Types)
# Converter Types
# ============================================================================


class ConverterCatalogEntry(BaseModel):
class ConverterTypeEntry(BaseModel):
"""A converter type available from the backend registry."""

converter_type: str = Field(..., description="Converter class name (e.g., 'Base64Converter')")
Expand All @@ -48,10 +51,17 @@ class ConverterCatalogEntry(BaseModel):
description: str | None = Field(None, description="Short description of the converter from its docstring")


class ConverterCatalogResponse(BaseModel):
class ConverterTypeResponse(BaseModel):
"""Response for listing available converter types from the registry."""

items: list[ConverterCatalogEntry] = Field(..., description="List of available converter types")
items: list[ConverterTypeEntry] = Field(..., description="List of available converter types")


# LEGACY COMPATIBILITY: ``Catalog`` is the pre-registry name for ``Type``. These
# aliases exist only so the un-migrated chat UI keeps working; delete them with the
# /catalog route when that UI switches to the /types API.
ConverterCatalogEntry = ConverterTypeEntry
ConverterCatalogResponse = ConverterTypeResponse


# ============================================================================
Expand All @@ -68,8 +78,10 @@ class ConverterInstance(BaseModel):
for the converter's class, supported data types, and constructor params.
"""

converter_id: str = Field(..., description="Unique converter instance identifier")
converter_id: str = Field(..., description="Converter instance registry name")
identifier: ConverterIdentifier = Field(..., description="The converter's identity/configuration projection")
is_llm_based: bool = Field(False, description="Whether this converter requires an LLM target")
description: str | None = Field(None, description="Short description of the converter type")


class ConverterInstanceListResponse(BaseModel):
Expand All @@ -81,7 +93,17 @@ class ConverterInstanceListResponse(BaseModel):
class CreateConverterRequest(BaseModel):
"""Request to create a new converter instance."""

# LEGACY COMPATIBILITY: The current chat UI does not send a name. Make this
# field required when the chat-migration stack layer sends explicit names.
name: str | None = Field(
None,
min_length=1,
pattern=REGISTRY_INSTANCE_NAME_PATTERN,
description="Unique registry name; omitted only for legacy chat compatibility",
)
type: str = Field(..., description="Converter type (e.g., 'Base64Converter')")
# LEGACY COMPATIBILITY: The former create response echoed this field. Remove
# it after clients use the complete ConverterInstance response.
display_name: str | None = Field(None, description="Human-readable display name")
params: dict[str, Any] = Field(
default_factory=dict,
Expand All @@ -90,7 +112,12 @@ class CreateConverterRequest(BaseModel):


class CreateConverterResponse(BaseModel):
"""Response after creating a converter instance."""
"""
Legacy response model for downstream imports.

POST /converters now returns ``ConverterInstance``. Remove this model when
downstream clients no longer import the former response type.
"""

converter_id: str = Field(..., description="Unique converter instance identifier")
converter_type: str = Field(..., description="Converter class name")
Expand Down
25 changes: 21 additions & 4 deletions pyrit/backend/models/targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from pydantic import BaseModel, Field

from pyrit.backend.models.common import PaginationInfo
from pyrit.backend.models.common import REGISTRY_INSTANCE_NAME_PATTERN, PaginationInfo
from pyrit.models import JSONValue, Parameter
from pyrit.models.catalog.target import TargetInstance

Expand All @@ -21,14 +21,16 @@
"TargetCatalogEntry",
"TargetCatalogResponse",
"TargetListResponse",
"TargetTypeEntry",
"TargetTypeResponse",
]


def _default_auth_modes() -> list[Literal["api_key", "identity"]]:
return ["api_key"]


class TargetCatalogEntry(BaseModel):
class TargetTypeEntry(BaseModel):
"""A target type available from the backend registry."""

target_type: str = Field(..., description="Target class name (e.g., 'OpenAIChatTarget')")
Expand All @@ -43,10 +45,17 @@ class TargetCatalogEntry(BaseModel):
description: str | None = Field(None, description="Short description of the target from its docstring")


class TargetCatalogResponse(BaseModel):
class TargetTypeResponse(BaseModel):
"""Response for listing available target types from the registry."""

items: list[TargetCatalogEntry] = Field(..., description="List of available target types")
items: list[TargetTypeEntry] = Field(..., description="List of available target types")


# LEGACY COMPATIBILITY: ``Catalog`` is the pre-registry name for ``Type``. These
# aliases exist only so the un-migrated configuration UI keeps working; delete them
# with the /catalog route when that UI switches to the /types API.
TargetCatalogEntry = TargetTypeEntry
TargetCatalogResponse = TargetTypeResponse


class TargetListResponse(BaseModel):
Expand All @@ -59,6 +68,14 @@ class TargetListResponse(BaseModel):
class CreateTargetRequest(BaseModel):
"""Request to create a new target instance."""

# LEGACY COMPATIBILITY: The current target configuration UI does not send a
# name. Make this field required after that UI sends explicit registry names.
name: str | None = Field(
None,
min_length=1,
pattern=REGISTRY_INSTANCE_NAME_PATTERN,
description="Unique registry name; omitted only for legacy UI compatibility",
)
type: str = Field(..., description="Target type (e.g., 'OpenAIChatTarget')")
params: dict[str, JSONValue] = Field(default_factory=dict, description="Target constructor parameters")
auth_mode: Literal["api_key", "identity"] = Field(
Expand Down
48 changes: 42 additions & 6 deletions pyrit/backend/routes/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
ConverterInstanceListResponse,
ConverterPreviewRequest,
ConverterPreviewResponse,
ConverterTypeResponse,
CreateConverterRequest,
CreateConverterResponse,
)
from pyrit.backend.services.converter_service import get_converter_service

Expand All @@ -42,38 +42,57 @@ async def list_converters() -> ConverterInstanceListResponse: # pyrit-async-suf
return await service.list_converters_async()


@router.get(
"/types",
response_model=ConverterTypeResponse,
)
async def list_converter_types() -> ConverterTypeResponse: # pyrit-async-suffix-exempt
"""
List converter types projected from ``ConverterRegistry`` metadata.

Returns:
ConverterTypeResponse: Available converter types and build parameters.
"""
service = get_converter_service()
return await service.list_converter_types_async()


@router.get(
"/catalog",
response_model=ConverterCatalogResponse,
)
async def list_converter_catalog() -> ConverterCatalogResponse: # pyrit-async-suffix-exempt
"""
List all available converter types from the backend converter registry.
Return the legacy catalog projection used by the current chat UI.

LEGACY COMPATIBILITY: pre-registry alias for ``/converters/types`` that hides
registry-reference parameters. Deleted with the rest of the ``catalog`` concept
when the chat-migration layer of this stack switches to ``/converters/types``.

Returns:
ConverterCatalogResponse: List of available converter types.
ConverterCatalogResponse: The scalar-only legacy catalog projection.
"""
service = get_converter_service()
return await service.list_converter_catalog_async()


@router.post(
"",
response_model=CreateConverterResponse,
response_model=ConverterInstance,
status_code=status.HTTP_201_CREATED,
responses={
400: {"model": ProblemDetail, "description": "Invalid converter type or parameters"},
},
)
async def create_converter(request: CreateConverterRequest) -> CreateConverterResponse: # pyrit-async-suffix-exempt
async def create_converter(request: CreateConverterRequest) -> ConverterInstance: # pyrit-async-suffix-exempt
"""
Create a new converter instance.

Instantiates a converter with the given type and parameters.
Supports nested converters via converter_id references in params.

Returns:
CreateConverterResponse: The created converter instance details.
ConverterInstance: The created converter instance details.
"""
service = get_converter_service()

Expand Down Expand Up @@ -117,6 +136,23 @@ async def get_converter(converter_id: str) -> ConverterInstance: # pyrit-async-
return converter


@router.delete(
"/{converter_id}",
status_code=status.HTTP_204_NO_CONTENT,
responses={
404: {"model": ProblemDetail, "description": "Converter not found"},
},
)
async def delete_converter(converter_id: str) -> None: # pyrit-async-suffix-exempt
"""Delete a converter instance by registry name."""
service = get_converter_service()
if not await service.delete_converter_async(converter_id=converter_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Converter '{converter_id}' not found",
)


@router.post(
"/preview",
response_model=ConverterPreviewResponse,
Expand Down
38 changes: 33 additions & 5 deletions pyrit/backend/routes/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
so the frontend can reference them by URL instead of requiring inline
base64 data URIs. For Azure deployments, media is served directly from
Azure Blob Storage via signed URLs and this endpoint is not used.

This route is the only place PyRIT hands stored bytes to a browser, so it is the
only place that restricts content. Storage stays unrestricted on purpose: any
file type is a legitimate attack payload (uploading an ``.html`` file so an attack
can push it to a blob target is a valid operation). Files therefore keep their
real name and extension on disk, and this route never renames them -- anything
reading a stored path gets the original file. Only the HTTP *response* is
adjusted: a document type a browser would execute in this origin is returned as an
opaque download instead of a rendered page.
"""

import logging
Expand All @@ -26,6 +35,13 @@
# Only serve files from known media subdirectories under results_path.
_ALLOWED_SUBDIRECTORIES = {"prompt-memory-entries", "seed-prompt-entries"}

# Types a browser executes in this origin. They are still stored and still served,
# but always as an opaque download so stored content cannot script against the UI.
_ACTIVE_DOCUMENT_EXTENSIONS = {".htm", ".html", ".svg", ".xhtml", ".xml"}

# Types the browser is asked to download rather than render inline.
_ATTACHMENT_EXTENSIONS = {".csv", ".md", ".pdf", ".txt"} | _ACTIVE_DOCUMENT_EXTENSIONS

# Only serve known media file types (allowlist approach).
_ALLOWED_EXTENSIONS = {
# Images
Expand All @@ -35,7 +51,6 @@
".gif",
".bmp",
".webp",
".svg",
".ico",
".tiff",
# Audio
Expand All @@ -56,8 +71,7 @@
".md",
".csv",
".pdf",
".html",
}
} | _ACTIVE_DOCUMENT_EXTENSIONS


def _validate_media_path(*, path: str, allowed_root: Path) -> Path:
Expand Down Expand Up @@ -110,6 +124,12 @@ async def serve_media_async(
configured results directory (e.g. ``dbdata/prompt-memory-entries/``)
to prevent path traversal attacks and exfiltration of sensitive files.

The stored file is never modified or renamed. Active document types
(see ``_ACTIVE_DOCUMENT_EXTENSIONS``) are returned as opaque downloads so the
browser does not execute them in this origin; the bytes and the file name are
unchanged, so a caller that needs the real file (e.g. to attach an ``.html``
payload to a target) reads it from its stored path.

Args:
path: Absolute path to the file.

Expand All @@ -134,8 +154,16 @@ async def serve_media_async(
if not validated_path.is_file():
raise HTTPException(status_code=404, detail="File not found.")

mime_type, _ = mimetypes.guess_type(validated_path)
extension = validated_path.suffix.lower()
if extension in _ACTIVE_DOCUMENT_EXTENSIONS:
media_type = "application/octet-stream"
else:
guessed_type, _ = mimetypes.guess_type(validated_path)
media_type = guessed_type or "application/octet-stream"
return FileResponse(
path=validated_path,
media_type=mime_type or "application/octet-stream",
media_type=media_type,
filename=validated_path.name if extension in _ATTACHMENT_EXTENSIONS else None,
content_disposition_type="attachment",
headers={"X-Content-Type-Options": "nosniff"},
)
Loading
Loading