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
8 changes: 4 additions & 4 deletions pyrit/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,10 @@ Stop these test-owned servers after the run.
### Request Limits

The backend reads at most 100 MiB of a request body; larger bodies receive **413**, and
API requests with URLs over 8 KiB receive **414**. Identifiers, names, labels, filters,
cursors, configuration files, and initializer scripts also have length or item limits,
listed in the OpenAPI schema; values over a limit receive **422**. Prompt content (message
pieces, system prompts, and converter preview input) and free-form values (prompt
API requests with URLs over 8 KiB receive **414**. Identifiers, names, titles, descriptions,
labels, filters, cursors, configuration files, and initializer scripts also have length or
item limits, listed in the OpenAPI schema; values over a limit receive **422**. Prompt content
(message pieces, system prompts, and converter preview input) and free-form values (prompt
metadata, scenario and initializer arguments, and target and converter parameter values)
are limited only by the body size, so long prompts and base64 media keep working.

Expand Down
28 changes: 24 additions & 4 deletions pyrit/backend/models/attacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from datetime import UTC, datetime
from typing import Annotated, Any, Literal, cast

from pydantic import BaseModel, Field, computed_field, field_serializer, model_validator
from pydantic import AfterValidator, BaseModel, Field, computed_field, field_serializer, model_validator

from pyrit.backend.models._media import build_filename, infer_mime_type
from pyrit.backend.models.common import (
Expand Down Expand Up @@ -493,6 +493,26 @@ def _normalize_legacy_attribution_labels(cls, data: Any) -> Any:
return normalized


def _limit_target_binding(binding: TargetBinding) -> TargetBinding:
"""
Apply the identifier limit to a binding a caller sends.

Saved bindings are read without this limit, so attacks saved before it keep loading.

Returns:
TargetBinding: The unchanged binding.

Raises:
ValueError: If a name or hash is longer than the identifier limit.
"""
if max(len(binding.source_name), len(binding.source_hash), len(binding.effective_hash)) > MAX_IDENTIFIER_LENGTH:
raise ValueError(f"Target binding names and hashes are limited to {MAX_IDENTIFIER_LENGTH} characters.")
return binding


TargetBindingInput = Annotated[TargetBinding, AfterValidator(_limit_target_binding)]


# ============================================================================
# Create Attack
# ============================================================================
Expand All @@ -510,7 +530,7 @@ class CreateAttackRequest(_AttackAttributionInput):
"""

name: TextStr | None = Field(None, description="Attack name/label")
target_binding: TargetBinding | None = None
target_binding: TargetBindingInput | None = None
chat_scorer: RegisteredComponentReference | None = None
target_registry_name: IdentifierStr | None = Field(
None, description="Target registry name, or None for a saved unbound attack"
Expand Down Expand Up @@ -636,7 +656,7 @@ class SaveConversationRequest(_AttackAttributionInput):
expected_objective: str | None = None
objective: str | None = Field(None, description="Omit to keep the destination's objective unchanged")
target_registry_name: IdentifierStr | None = None
target_binding: TargetBinding | None = None
target_binding: TargetBindingInput | None = None
messages: list[MessageRequest] = Field(default_factory=list)
reuse_unsent_conversation: bool = False
expected_source_piece_ids: list[uuid.UUID] | None = None
Expand Down Expand Up @@ -728,7 +748,7 @@ class AddMessageRequest(MessageRequest):
None,
description="Target registry name. Required when send=True so the backend knows which target to use.",
)
target_binding: TargetBinding | None = None
target_binding: TargetBindingInput | None = None
converter_ids: list[IdentifierStr] | None = Field(
None,
max_length=MAX_ITEMS,
Expand Down
11 changes: 8 additions & 3 deletions pyrit/backend/models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,21 @@

from pydantic import AfterValidator, BaseModel, Field

from pyrit.models.request_limits import MAX_IDENTIFIER_LENGTH, MAX_ITEMS, MAX_LABEL_KEY_LENGTH, MAX_LABEL_VALUE_LENGTH
from pyrit.models.request_limits import (
MAX_IDENTIFIER_LENGTH,
MAX_ITEMS,
MAX_LABEL_KEY_LENGTH,
MAX_LABEL_VALUE_LENGTH,
MAX_TEXT_LENGTH,
)

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

# Request limits. Identifier, list, and label limits are shared with ``pyrit.models``. Prompt
# Request limits. Identifier, list, label, and text limits are shared with ``pyrit.models``. Prompt
# content (message pieces, system prompts, preview input) and free-form values (metadata and
# parameter values) are only limited by the request body size, so long prompts and base64 media
# keep working.
MAX_CURSOR_LENGTH = 1_024
MAX_TEXT_LENGTH = 100_000
MAX_FILE_CONTENT_LENGTH = 1_048_576

IdentifierStr = Annotated[str, Field(max_length=MAX_IDENTIFIER_LENGTH)]
Expand Down
21 changes: 19 additions & 2 deletions pyrit/backend/models/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
from pydantic import BaseModel, ConfigDict, Field

from pyrit.backend.models.attacks import ScoreView
from pyrit.backend.models.common import TextStr
from pyrit.models import (
AttackOutcome,
Finding,
FindingCreate,
FindingEvidence,
FindingSeverity,
Operation,
OperationCreate,
OperationUsage,
)
from pyrit.models.harm_category import HarmCategory
Expand Down Expand Up @@ -44,13 +46,19 @@ class OperationDetail(Operation):
usage: OperationUsage


class OperationCreateRequest(OperationCreate):
"""An operation to create."""

description: TextStr = ""


class OperationUpdateRequest(BaseModel):
"""The editable fields of an operation. Omitted fields are unchanged."""

model_config = ConfigDict(extra="forbid")

name: OperationName | None = None
description: str | None = None
description: TextStr | None = None


class FindingListItem(Finding):
Expand All @@ -67,7 +75,16 @@ class FindingListResponse(BaseModel):
next_offset: int | None


class FindingCreateRequest(FindingCreate):
class FindingUpdateRequest(FindingCreate):
"""A finding's editable fields."""

title: TextStr
description: TextStr = ""
severity_other: TextStr | None = None
harm_type_other: TextStr | None = None


class FindingCreateRequest(FindingUpdateRequest):
"""
A finding to record, optionally with one saved attack result as evidence.

Expand Down
3 changes: 2 additions & 1 deletion pyrit/backend/models/scenario_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from pydantic import BaseModel, Field

from pyrit.backend.models.common import IdentifierStr
from pyrit.models.catalog.scenario import ScenarioRunSizeEstimate
from pyrit.models.catalog.scenario_preset import PresetIssue, ScenarioPreset

Expand Down Expand Up @@ -78,4 +79,4 @@ class UpdateScenarioPresetRequest(BaseModel):
"""

preset: ScenarioPreset = Field(..., description="The replacement preset")
expected_version: str = Field(..., description="Version returned when the preset being edited was read")
expected_version: IdentifierStr = Field(..., description="Version returned when the preset being edited was read")
3 changes: 2 additions & 1 deletion pyrit/backend/models/scenario_suites.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from pydantic import BaseModel, Field

from pyrit.backend.models.common import IdentifierStr
from pyrit.models.catalog.scenario_preset import PresetIssue
from pyrit.models.catalog.scenario_suite import ScenarioSuite

Expand Down Expand Up @@ -73,4 +74,4 @@ class UpdateScenarioSuiteRequest(BaseModel):
"""Request body for replacing a scenario suite the caller has read."""

suite: ScenarioSuite = Field(..., description="The replacement suite")
expected_version: str = Field(..., description="Version returned when the suite being edited was read")
expected_version: IdentifierStr = Field(..., description="Version returned when the suite being edited was read")
12 changes: 8 additions & 4 deletions pyrit/backend/routes/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
FindingEvidenceListResponse,
FindingListResponse,
FindingOptionsResponse,
FindingUpdateRequest,
OperationCreateRequest,
OperationDetail,
OperationListResponse,
OperationUpdateRequest,
Expand All @@ -27,7 +29,7 @@
OperationService,
)
from pyrit.memory.memory_interface import OperationInUseError
from pyrit.models import Finding, FindingCreate, Operation, OperationCreate
from pyrit.models import Finding, Operation
from pyrit.models.harm_category import HarmCategory

T = TypeVar("T")
Expand All @@ -36,7 +38,7 @@


@router.post("", response_model=Operation, status_code=status.HTTP_201_CREATED)
async def create_operation_async(request: OperationCreate) -> Operation:
async def create_operation_async(request: OperationCreateRequest) -> Operation:
"""
Create an operation. Names must be unique, ignoring case and surrounding whitespace.

Expand Down Expand Up @@ -151,7 +153,7 @@ async def list_operation_findings_async(
operation_id: UUID,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
offset: Annotated[int, Query(ge=0)] = 0,
title: str | None = None,
title: str | None = Query(None, max_length=1000),
) -> FindingListResponse:
"""
List one page of an operation's findings, by severity and then newest first.
Expand All @@ -165,7 +167,9 @@ async def list_operation_findings_async(


@router.put("/{operation_id}/findings/{finding_id}", response_model=Finding)
async def update_operation_finding_async(*, operation_id: UUID, finding_id: UUID, request: FindingCreate) -> Finding:
async def update_operation_finding_async(
*, operation_id: UUID, finding_id: UUID, request: FindingUpdateRequest
) -> Finding:
"""
Replace the editable fields of a finding in its operation.

Expand Down
10 changes: 5 additions & 5 deletions pyrit/backend/routes/scenario_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status

from pyrit.backend.middleware.auth import current_user_name, require_admin
from pyrit.backend.models.common import ProblemDetail
from pyrit.backend.models.common import IdentifierStr, ProblemDetail
from pyrit.backend.models.scenario_presets import (
ScenarioPresetListResponse,
ScenarioPresetResponse,
Expand Down Expand Up @@ -115,7 +115,7 @@ async def list_scenario_presets( # pyrit-async-suffix-exempt
response_model=ScenarioPresetResponse,
responses={404: {"model": ProblemDetail, "description": "Preset not found"}},
)
async def get_scenario_preset(name: str) -> ScenarioPresetResponse: # pyrit-async-suffix-exempt
async def get_scenario_preset(name: IdentifierStr) -> ScenarioPresetResponse: # pyrit-async-suffix-exempt
"""
Get one preset and the version required to update it.

Expand Down Expand Up @@ -192,7 +192,7 @@ async def create_scenario_preset( # pyrit-async-suffix-exempt
},
)
async def update_scenario_preset( # pyrit-async-suffix-exempt
name: str,
name: IdentifierStr,
body: UpdateScenarioPresetRequest,
) -> ScenarioPresetResponse:
"""
Expand Down Expand Up @@ -247,7 +247,7 @@ async def update_scenario_preset( # pyrit-async-suffix-exempt
dependencies=[Depends(require_admin)],
responses={404: {"model": ProblemDetail, "description": "Preset not found"}},
)
async def delete_scenario_preset(name: str) -> None: # pyrit-async-suffix-exempt
async def delete_scenario_preset(name: IdentifierStr) -> None: # pyrit-async-suffix-exempt
"""
Delete one stored preset.

Expand Down Expand Up @@ -277,7 +277,7 @@ async def delete_scenario_preset(name: str) -> None: # pyrit-async-suffix-exemp
},
)
async def resolve_scenario_preset( # pyrit-async-suffix-exempt
name: str,
name: IdentifierStr,
body: ResolveScenarioPresetRequest,
) -> RunScenarioRequest:
"""
Expand Down
10 changes: 5 additions & 5 deletions pyrit/backend/routes/scenario_suites.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status

from pyrit.backend.middleware.auth import current_user_name, require_admin
from pyrit.backend.models.common import ProblemDetail
from pyrit.backend.models.common import IdentifierStr, ProblemDetail
from pyrit.backend.models.scenario_suites import (
ScenarioSuiteListResponse,
ScenarioSuiteResponse,
Expand Down Expand Up @@ -152,7 +152,7 @@ async def cancel_scenario_suite_execution( # pyrit-async-suffix-exempt
},
)
async def launch_scenario_suite( # pyrit-async-suffix-exempt
name: str,
name: IdentifierStr,
body: LaunchScenarioSuiteRequest,
http_request: Request,
) -> ScenarioSuiteExecutionSummary:
Expand Down Expand Up @@ -195,7 +195,7 @@ async def launch_scenario_suite( # pyrit-async-suffix-exempt
response_model=ScenarioSuiteResponse,
responses={404: {"model": ProblemDetail, "description": "Scenario suite not found"}},
)
async def get_scenario_suite(name: str) -> ScenarioSuiteResponse: # pyrit-async-suffix-exempt
async def get_scenario_suite(name: IdentifierStr) -> ScenarioSuiteResponse: # pyrit-async-suffix-exempt
"""
Get one scenario suite and the version required to update it.

Expand Down Expand Up @@ -259,7 +259,7 @@ async def create_scenario_suite( # pyrit-async-suffix-exempt
},
)
async def update_scenario_suite( # pyrit-async-suffix-exempt
name: str,
name: IdentifierStr,
body: UpdateScenarioSuiteRequest,
) -> ScenarioSuiteResponse:
"""
Expand Down Expand Up @@ -302,7 +302,7 @@ async def update_scenario_suite( # pyrit-async-suffix-exempt
dependencies=[Depends(require_admin)],
responses={404: {"model": ProblemDetail, "description": "Scenario suite not found"}},
)
async def delete_scenario_suite(name: str) -> None: # pyrit-async-suffix-exempt
async def delete_scenario_suite(name: IdentifierStr) -> None: # pyrit-async-suffix-exempt
"""Delete one stored scenario suite."""
try:
deleted = await get_scenario_suite_service().delete_suite_async(name=name)
Expand Down
13 changes: 10 additions & 3 deletions pyrit/backend/routes/scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@
from starlette.concurrency import run_in_threadpool

from pyrit.analytics import PairedOutcomeStatistics, compute_paired_outcome_statistics
from pyrit.backend.models.common import MAX_ITEMS, CursorStr, IdentifierStr, LabelFilterStr, ProblemDetail
from pyrit.backend.models.common import (
MAX_IDENTIFIER_LENGTH,
MAX_ITEMS,
CursorStr,
IdentifierStr,
LabelFilterStr,
ProblemDetail,
)
from pyrit.backend.models.datasets import ScenarioDatasetLoadResponse
from pyrit.backend.models.scenarios import (
ListRegisteredScenariosResponse,
Expand Down Expand Up @@ -453,8 +460,8 @@ async def get_scenario_run_results(scenario_result_id: IdentifierStr) -> Scenari
async def get_paired_outcomes_async(
*,
scenario_result_id: IdentifierStr,
baseline_group_id: IdentifierStr = Query(..., description="Saved baseline atomic group ID"),
attack_group_id: IdentifierStr = Query(..., description="Saved attack atomic group ID"),
baseline_group_id: str = Query(..., max_length=MAX_IDENTIFIER_LENGTH, description="Saved baseline atomic group ID"),
attack_group_id: str = Query(..., max_length=MAX_IDENTIFIER_LENGTH, description="Saved attack atomic group ID"),
) -> PairedOutcomeStatistics:
"""
Compare explicitly selected groups using the SDK's effective outcome semantics.
Expand Down
13 changes: 11 additions & 2 deletions pyrit/models/catalog/scenario_preset.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
)
from pyrit.models.identifiers.class_name_utils import validate_registry_name
from pyrit.models.identifiers.component_identifier import ComponentIdentifier, Identifiable
from pyrit.models.request_limits import MAX_IDENTIFIER_LENGTH, MAX_TEXT_LENGTH
from pyrit.models.scenario_dataset import ScenarioDatasetRequest

PRESET_SCHEMA_VERSION = 1
Expand Down Expand Up @@ -58,16 +59,23 @@ class ScenarioPreset(BaseModel, Identifiable):

model_config = ConfigDict(extra="forbid")

name: str = Field(..., description="Unique preset name, used as the storage key and as the reference from scans")
name: str = Field(
...,
max_length=MAX_IDENTIFIER_LENGTH,
description="Unique preset name, used as the storage key and as the reference from scans",
)
# Literal rather than PRESET_SCHEMA_VERSION: a document written before this field existed
# is a version 1 document, and must keep reading back as one after the constant is bumped.
# Defaulting to the constant would relabel those documents as the newest version while they
# still hold the oldest content, so a migration would skip exactly the documents needing it.
schema_version: int = Field(1, ge=1, description="Shape of this document")
scenario_name: _RequestName = Field(..., min_length=1, description="Registered scenario this preset configures")
description: str | None = Field(None, description="Human-readable summary of what this preset tests")
description: str | None = Field(
None, max_length=MAX_TEXT_LENGTH, description="Human-readable summary of what this preset tests"
)
author: str | None = Field(
None,
max_length=MAX_IDENTIFIER_LENGTH,
description="Who created this preset; descriptive only, never used to authorize a read or a write",
)
show_in_scanner: bool = Field(
Expand Down Expand Up @@ -164,6 +172,7 @@ class ResolveScenarioPresetRequest(ScenarioLaunchConfiguration):

expected_version: str | None = Field(
None,
max_length=MAX_IDENTIFIER_LENGTH,
description="Version the caller resolved against; omit to resolve the current definition",
)

Expand Down
Loading
Loading