Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 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
d1bdd82
List presets, suites, and initializers through the checked listing
varunj-msft Nov 5, 2026
f5ed760
Merge the shared document listing
varunj-msft Nov 5, 2026
4b55b17
Merge the shared document listing
varunj-msft Nov 5, 2026
76dbf9c
Report a preset, suite, or initializer folder that can't be read as u…
varunj-msft Nov 5, 2026
f823e4b
Merge the storage error mapping for unreadable folders
varunj-msft Nov 5, 2026
99da684
Merge the storage error mapping for unreadable folders
varunj-msft Nov 5, 2026
7d9ac2f
Log why storage is unavailable, without its path or URL
varunj-msft Nov 7, 2026
7b5bfeb
Merge the storage error logging
varunj-msft Nov 7, 2026
22806d8
Merge the storage error logging
varunj-msft Nov 7, 2026
34011c9
Hide a restore message with a percent escape, which can hide a creden…
varunj-msft Nov 7, 2026
031dc61
Merge origin/main
varunj-msft Nov 8, 2026
7f8ab0d
Merge origin/main
varunj-msft Nov 8, 2026
19205b1
Import the saved recipe models from their package, as main now requires
varunj-msft Nov 8, 2026
0348a0b
Merge origin/main
varunj-msft Nov 8, 2026
bba3afd
Import from public packages, as main now requires
varunj-msft Nov 8, 2026
622bbb8
Report storage failures with one error and answer them with 503
varunj-msft Nov 9, 2026
1cae0b9
Merge the single storage error
varunj-msft Nov 9, 2026
eba7bf2
Escape file names once and simplify the saved recipe models
varunj-msft Nov 9, 2026
9f24124
Merge the recipe model simplification
varunj-msft Nov 9, 2026
2bbfc8f
Report restore bugs as build errors and log each failure once
varunj-msft Nov 9, 2026
6ee84f0
Merge origin/main
varunj-msft Nov 10, 2026
d43a7e8
Merge origin/main
varunj-msft Nov 10, 2026
0664063
Merge origin/main
varunj-msft Nov 10, 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
34 changes: 34 additions & 0 deletions pyrit/backend/middleware/error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from pyrit.backend.middleware.request_size import RequestTooLargeError, request_too_large_response
from pyrit.backend.models import FieldError, ProblemDetail
from pyrit.exceptions.analytics_exception import AnalyticsException
from pyrit.registry.file_document_storage import DocumentStorageUnavailableError, DocumentUnreadableError

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -169,6 +170,39 @@ async def permission_error_handler( # pyrit-async-suffix-exempt
content=problem.model_dump(exclude_none=True),
)

@app.exception_handler(DocumentStorageUnavailableError)
async def storage_unavailable_handler( # pyrit-async-suffix-exempt
request: Request,
exc: DocumentStorageUnavailableError,
) -> JSONResponse:
"""
Handle stored documents that can't be read or written as 503 Service Unavailable.

The log names the storage location, without credentials, and the reason, with the
traceback at debug level. The response never carries a path or URL.

Returns:
JSONResponse: RFC 7807 problem detail response with 503 status.
"""
logger.warning(f"{exc} ({request.method} {request.url.path})")
logger.debug("Storage error details", exc_info=exc)
if isinstance(exc, DocumentUnreadableError):
detail = f"{exc.label} '{exc.name}' can't be read: {exc.reason}"
else:
detail = f"{exc.label} storage is unavailable"
problem = ProblemDetail(
type="/errors/storage-unavailable",
title="Service Unavailable",
status=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=detail,
instance=str(request.url.path),
)

return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content=problem.model_dump(exclude_none=True),
)

@app.exception_handler(NotImplementedError)
async def not_implemented_handler( # pyrit-async-suffix-exempt
request: Request,
Expand Down
23 changes: 1 addition & 22 deletions pyrit/backend/routes/initializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
DELETE /api/initializers/{name} — unregister an initializer
"""

from azure.core.exceptions import AzureError
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status

from pyrit.backend.middleware.auth import require_admin
Expand All @@ -35,19 +34,6 @@
router = APIRouter(prefix="/initializers", tags=["initializers"])


def _custom_storage_unavailable() -> HTTPException:
"""
Create a sanitized response for unavailable custom initializer storage.

Returns:
HTTPException: A service-unavailable response without SDK details.
"""
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Custom initializer storage is temporarily unavailable",
)


def _configured_initializers(request: Request) -> list[ConfiguredInitializerSetting]:
"""
Read the initializer list captured from ``.pyrit_conf`` at backend startup.
Expand Down Expand Up @@ -138,10 +124,7 @@ async def list_custom_initializers(request: Request) -> CustomInitializerListRes
CustomInitializerListResponse: The configured source and stored scripts.
"""
_check_custom_initializers_allowed(request)
try:
return await get_initializer_service().list_custom_initializers_async()
except AzureError as exc:
raise _custom_storage_unavailable() from exc
return await get_initializer_service().list_custom_initializers_async()


@router.get(
Expand Down Expand Up @@ -205,8 +188,6 @@ async def register_initializer( # pyrit-async-suffix-exempt

try:
return await service.register_initializer_async(name=body.name, script_content=body.script_content)
except AzureError as exc:
raise _custom_storage_unavailable() from exc
except ValueError as e:
detail = str(e)
if "already registered" in detail:
Expand Down Expand Up @@ -243,8 +224,6 @@ async def unregister_initializer( # pyrit-async-suffix-exempt

try:
await service.unregister_initializer_async(initializer_name=initializer_name)
except AzureError as exc:
raise _custom_storage_unavailable() from exc
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
Expand Down
29 changes: 1 addition & 28 deletions pyrit/backend/routes/scenario_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@

from typing import Annotated

from azure.core.exceptions import AzureError
from fastapi import APIRouter, Depends, HTTPException, Query, status

from pyrit.backend.middleware.auth import current_user_name, require_admin
Expand All @@ -38,19 +37,6 @@
router = APIRouter(prefix="/scenario-presets", tags=["scenario-presets"])


def _storage_unavailable() -> HTTPException:
"""
Create a sanitized response for unavailable preset storage.

Returns:
HTTPException: A service-unavailable response without SDK details.
"""
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Scenario preset storage is temporarily unavailable",
)


def _not_found(name: str) -> HTTPException:
"""
Create a not-found response for a missing preset.
Expand Down Expand Up @@ -79,8 +65,6 @@ async def _load_preset_or_404_async(name: str) -> ScenarioPresetResponse:
"""
try:
preset = await get_scenario_preset_service().get_preset_async(name=name)
except AzureError as exc:
raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None

Expand All @@ -106,10 +90,7 @@ async def list_scenario_presets( # pyrit-async-suffix-exempt
Returns:
ScenarioPresetListResponse: The configured source and the stored presets.
"""
try:
return await get_scenario_preset_service().list_presets_async(include_estimates=include_estimates)
except AzureError as exc:
raise _storage_unavailable() from exc
return await get_scenario_preset_service().list_presets_async(include_estimates=include_estimates)


@router.get(
Expand Down Expand Up @@ -177,8 +158,6 @@ async def create_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{preset.name}' already exists",
) from exc
except AzureError as exc:
raise _storage_unavailable() from exc


@router.put(
Expand Down Expand Up @@ -239,8 +218,6 @@ async def update_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
) from exc
except AzureError as exc:
raise _storage_unavailable() from exc


@router.delete(
Expand All @@ -261,8 +238,6 @@ async def delete_scenario_preset(name: IdentifierStr) -> None: # pyrit-async-su
"""
try:
deleted = await get_scenario_preset_service().delete_preset_async(name=name)
except AzureError as exc:
raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None

Expand Down Expand Up @@ -312,8 +287,6 @@ async def resolve_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
) from None
except AzureError as exc:
raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None

Expand Down
29 changes: 1 addition & 28 deletions pyrit/backend/routes/scenario_suites.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from typing import Annotated
from uuid import UUID

from azure.core.exceptions import AzureError
from fastapi import APIRouter, Depends, HTTPException, Request, status

from pyrit.backend.middleware.auth import current_user_name, require_admin
Expand All @@ -31,19 +30,6 @@
router = APIRouter(prefix="/scenario-suites", tags=["scenario-suites"])


def _storage_unavailable() -> HTTPException:
"""
Create a sanitized response for unavailable suite storage.

Returns:
HTTPException: A service-unavailable response without SDK details.
"""
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Scenario suite storage is temporarily unavailable",
)


def _not_found(name: str) -> HTTPException:
"""
Create a not-found response for a missing suite.
Expand All @@ -66,8 +52,6 @@ async def _load_suite_or_404_async(name: str) -> ScenarioSuiteResponse:
"""
try:
suite = await get_scenario_suite_service().get_suite_async(name=name)
except AzureError as exc:
raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None

Expand All @@ -84,10 +68,7 @@ async def list_scenario_suites() -> ScenarioSuiteListResponse: # pyrit-async-su
Returns:
ScenarioSuiteListResponse: Suites from both origins with current entry statuses.
"""
try:
return await get_scenario_suite_service().list_suites_async()
except AzureError as exc:
raise _storage_unavailable() from exc
return await get_scenario_suite_service().list_suites_async()


@router.get(
Expand Down Expand Up @@ -181,8 +162,6 @@ async def launch_scenario_suite( # pyrit-async-suffix-exempt
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
except ScenarioSuiteExecutionCapacityError as exc:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(exc)) from None
except AzureError as exc:
raise _storage_unavailable() from exc
except RuntimeError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from None

Expand Down Expand Up @@ -239,8 +218,6 @@ async def create_scenario_suite( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario suite '{suite.name}' already exists",
) from exc
except AzureError as exc:
raise _storage_unavailable() from exc


@router.put(
Expand Down Expand Up @@ -289,8 +266,6 @@ async def update_scenario_suite( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario suite '{name}' changed since it was read; re-read it and retry",
) from exc
except AzureError as exc:
raise _storage_unavailable() from exc


@router.delete(
Expand All @@ -303,8 +278,6 @@ async def delete_scenario_suite(name: IdentifierStr) -> None: # pyrit-async-suf
"""Delete one stored scenario suite."""
try:
deleted = await get_scenario_suite_service().delete_suite_async(name=name)
except AzureError as exc:
raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None

Expand Down
5 changes: 2 additions & 3 deletions pyrit/backend/services/scenario_suite_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
from functools import lru_cache
from typing import Literal

from azure.core.exceptions import AzureError

from pyrit.backend.models import (
ScenarioSuiteEntryStatus,
ScenarioSuiteListResponse,
Expand All @@ -25,6 +23,7 @@
ScenarioSuiteRegistryEntry,
)
from pyrit.registry import ScenarioPresetRegistry, ScenarioSuiteRegistry
from pyrit.registry.file_document_storage import DocumentStorageUnavailableError

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -209,7 +208,7 @@ def _resolve_preset_projection(
)

stored_version = registry.get_preset_version(name=preset_name)
except (AzureError, OSError):
except DocumentStorageUnavailableError:
logger.warning(
"Could not project scenario preset '%s' because preset storage is unavailable.",
preset_name,
Expand Down
33 changes: 30 additions & 3 deletions pyrit/common/azure_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@

"""Lightweight Azure Storage URI helpers."""

import re
from urllib.parse import parse_qs, urlparse

# A scheme followed by "://" and everything up to whitespace, so a quote can't end a URL before its query.
_URL_IN_TEXT = re.compile(r"\b[A-Za-z][A-Za-z0-9+.-]*://\S+")
# The signature of a SAS token, which can also appear in text without a scheme.
_SAS_SIGNATURE = re.compile(r"(?i)\bsig=[^&\s]+")


def is_azure_blob_uri(value: str, *, min_path_segments: int = 1) -> bool:
"""
Expand Down Expand Up @@ -48,9 +54,30 @@ def has_sas_signature(value: str) -> bool:

def redact_url_credentials(value: str) -> str:
"""
Remove query parameters and fragments from a URL for display or logging.
Remove the user name and password, query parameters, and fragment from a URL for display or logging.

Returns:
str: The URL without them.
"""
parsed = urlparse(value)
return parsed._replace(netloc=parsed.netloc.rpartition("@")[2], query="", fragment="").geturl()


def redact_urls(text: str) -> str:
"""
Remove the user name and password, query parameters, and fragment from every URL in a text.

A query or user name can hold a credential, such as a SAS token. A URL that can't be parsed is
hidden entirely, and a SAS signature is hidden wherever it appears.

Returns:
str: The URL without query parameters or a fragment.
str: The text with each URL redacted.
"""
return urlparse(value)._replace(query="", fragment="").geturl()

def redact(match: re.Match[str]) -> str:
try:
return redact_url_credentials(match.group(0))
except ValueError:
return "<URL hidden>"

return _SAS_SIGNATURE.sub("sig=<hidden>", _URL_IN_TEXT.sub(redact, text))
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
Loading
Loading