Skip to content

Commit 5505aa2

Browse files
behnam-oBehnam Ousat
andauthored
FEAT Add dataset info to backend and expose --list-datasets in pyrit_scan (microsoft#2106)
Co-authored-by: Behnam Ousat <behnamousat@microsoft.com>
1 parent d1aafe1 commit 5505aa2

14 files changed

Lines changed: 307 additions & 2 deletions

File tree

pyrit/backend/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
attacks,
2626
auth,
2727
converters,
28+
datasets,
2829
health,
2930
initializers,
3031
labels,
@@ -120,6 +121,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
120121
app.include_router(attacks.router, prefix="/api", tags=["attacks"])
121122
app.include_router(targets.router, prefix="/api", tags=["targets"])
122123
app.include_router(converters.router, prefix="/api", tags=["converters"])
124+
app.include_router(datasets.router, prefix="/api", tags=["datasets"])
123125
app.include_router(scenarios.router, prefix="/api", tags=["scenarios"])
124126
app.include_router(initializers.router, prefix="/api", tags=["initializers"])
125127
app.include_router(labels.router, prefix="/api", tags=["labels"])

pyrit/backend/models/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@
4848
CreateConverterResponse,
4949
PreviewStep,
5050
)
51+
from pyrit.backend.models.datasets import (
52+
DatasetInfo,
53+
DatasetListResponse,
54+
)
5155
from pyrit.backend.models.initializers import (
5256
ListRegisteredInitializersResponse,
5357
RegisterInitializerRequest,
@@ -101,6 +105,9 @@
101105
"CreateConverterRequest",
102106
"CreateConverterResponse",
103107
"PreviewStep",
108+
# Datasets
109+
"DatasetInfo",
110+
"DatasetListResponse",
104111
# Scenarios
105112
"ListRegisteredScenariosResponse",
106113
"ScenarioRunListResponse",

pyrit/backend/models/datasets.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""
5+
Dataset models for the PyRIT API.
6+
7+
Datasets are seed prompt/objective collections provided by
8+
``SeedDatasetProvider`` subclasses. These models describe the wire format for
9+
listing available datasets.
10+
"""
11+
12+
from pydantic import BaseModel, Field
13+
14+
15+
class DatasetInfo(BaseModel):
16+
"""Metadata about a single available dataset."""
17+
18+
name: str = Field(..., description="Dataset name (e.g., 'harmbench')")
19+
20+
21+
class DatasetListResponse(BaseModel):
22+
"""Response for listing available datasets."""
23+
24+
items: list[DatasetInfo] = Field(..., description="List of available datasets")

pyrit/backend/routes/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,23 @@
55
API route handlers.
66
"""
77

8-
from pyrit.backend.routes import attacks, converters, health, initializers, labels, media, scenarios, targets, version
8+
from pyrit.backend.routes import (
9+
attacks,
10+
converters,
11+
datasets,
12+
health,
13+
initializers,
14+
labels,
15+
media,
16+
scenarios,
17+
targets,
18+
version,
19+
)
920

1021
__all__ = [
1122
"attacks",
1223
"converters",
24+
"datasets",
1325
"health",
1426
"initializers",
1527
"labels",

pyrit/backend/routes/datasets.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""
5+
Dataset API routes.
6+
7+
Provides an endpoint for listing available seed datasets. Datasets are
8+
discovered from registered ``SeedDatasetProvider`` subclasses.
9+
"""
10+
11+
from fastapi import APIRouter
12+
13+
from pyrit.backend.models.common import ProblemDetail
14+
from pyrit.backend.models.datasets import (
15+
DatasetListResponse,
16+
)
17+
from pyrit.backend.services.dataset_service import get_dataset_service
18+
19+
router = APIRouter(prefix="/datasets", tags=["datasets"])
20+
21+
22+
@router.get(
23+
"",
24+
response_model=DatasetListResponse,
25+
responses={
26+
500: {"model": ProblemDetail, "description": "Internal server error"},
27+
},
28+
)
29+
async def list_datasets() -> DatasetListResponse: # pyrit-async-suffix-exempt
30+
"""
31+
List all available datasets.
32+
33+
Returns:
34+
DatasetListResponse: Available datasets.
35+
"""
36+
service = get_dataset_service()
37+
return await service.list_datasets_async()

pyrit/backend/services/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
ConverterService,
1616
get_converter_service,
1717
)
18+
from pyrit.backend.services.dataset_service import (
19+
DatasetService,
20+
get_dataset_service,
21+
)
1822
from pyrit.backend.services.initializer_service import (
1923
InitializerService,
2024
get_initializer_service,
@@ -37,6 +41,8 @@
3741
"get_attack_service",
3842
"ConverterService",
3943
"get_converter_service",
44+
"DatasetService",
45+
"get_dataset_service",
4046
"InitializerService",
4147
"get_initializer_service",
4248
"ScenarioService",
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""
5+
Dataset service for listing seed datasets.
6+
7+
Wraps ``SeedDatasetProvider`` discovery and memory to list available datasets.
8+
"""
9+
10+
import logging
11+
from functools import lru_cache
12+
13+
from pyrit.backend.models.datasets import (
14+
DatasetInfo,
15+
DatasetListResponse,
16+
)
17+
from pyrit.datasets import SeedDatasetProvider
18+
from pyrit.memory import CentralMemory
19+
20+
logger = logging.getLogger(__name__)
21+
22+
23+
class DatasetService:
24+
"""Service for listing seed datasets."""
25+
26+
def __init__(self) -> None:
27+
"""Initialize the dataset service."""
28+
self._memory = CentralMemory.get_memory_instance()
29+
30+
async def list_datasets_async(self) -> DatasetListResponse:
31+
"""
32+
List all available datasets.
33+
34+
Combines datasets discoverable via registered providers with those
35+
already loaded into memory, since both are available for use.
36+
37+
Returns:
38+
DatasetListResponse: Available datasets.
39+
"""
40+
provider_names = await SeedDatasetProvider.get_all_dataset_names_async()
41+
memory_names = self._memory.get_seed_dataset_names()
42+
available = sorted(set(provider_names) | set(memory_names))
43+
items = [DatasetInfo(name=name) for name in available]
44+
return DatasetListResponse(items=items)
45+
46+
47+
@lru_cache(maxsize=1)
48+
def get_dataset_service() -> DatasetService:
49+
"""
50+
Get the global dataset service instance.
51+
52+
Returns:
53+
The singleton DatasetService instance.
54+
"""
55+
return DatasetService()

pyrit/cli/_output.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from __future__ import annotations
1414

1515
import sys
16-
from typing import TYPE_CHECKING
16+
from typing import TYPE_CHECKING, Any
1717

1818
if TYPE_CHECKING:
1919
from pyrit.models import ScenarioResult
@@ -193,6 +193,31 @@ def print_target_list(*, items: list[TargetInstance]) -> None:
193193
print(f"\nTotal targets: {len(items)}")
194194

195195

196+
# ---------------------------------------------------------------------------
197+
# Dataset listing
198+
# ---------------------------------------------------------------------------
199+
200+
201+
def print_dataset_list(*, items: list[dict[str, Any]]) -> None:
202+
"""
203+
Print a formatted list of available datasets.
204+
205+
Args:
206+
items: List of dataset dicts from ``GET /api/datasets``.
207+
"""
208+
if not items:
209+
print("No datasets found.")
210+
return
211+
212+
print("\nAvailable Datasets:")
213+
print("=" * 80)
214+
for ds in items:
215+
name = ds.get("name", "unknown")
216+
print(f" {name}")
217+
print("=" * 80)
218+
print(f"\nTotal datasets: {len(items)}")
219+
220+
196221
# ---------------------------------------------------------------------------
197222
# Scenario run progress & summary
198223
# ---------------------------------------------------------------------------

pyrit/cli/api_client.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,19 @@ async def list_targets_async(self, *, limit: int = 200) -> list[TargetInstance]:
196196
payload = await self._get_json_async(path="/api/targets", params={"limit": limit})
197197
return [TargetInstance.model_validate(item) for item in payload.get("items", [])]
198198

199+
# ------------------------------------------------------------------
200+
# Datasets
201+
# ------------------------------------------------------------------
202+
203+
async def list_datasets_async(self) -> dict[str, Any]:
204+
"""
205+
List all available datasets.
206+
207+
Returns:
208+
dict: ``DatasetListResponse`` payload.
209+
"""
210+
return await self._get_json_async(path="/api/datasets")
211+
199212
# ------------------------------------------------------------------
200213
# Scenario runs
201214
# ------------------------------------------------------------------

pyrit/cli/pyrit_scan.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ def _print_cli_exception(*, exc: BaseException) -> None:
9191
pyrit_scan --list-initializers
9292
pyrit_scan --list-targets
9393
94+
# List available datasets
95+
pyrit_scan --list-datasets
96+
9497
# Run single-turn cyber attacks against a target
9598
pyrit_scan airt.cyber --target openai_chat --strategies single_turn
9699
@@ -188,6 +191,11 @@ def _build_base_parser(*, add_help: bool = True) -> ArgumentParser:
188191
action="store_true",
189192
help="List all available targets and exit",
190193
)
194+
discovery_group.add_argument(
195+
"--list-datasets",
196+
action="store_true",
197+
help="List all available datasets and exit",
198+
)
191199
discovery_group.add_argument(
192200
"--add-initializer",
193201
type=str,
@@ -451,6 +459,7 @@ def _is_command_specified(*, parsed_args: Namespace) -> bool:
451459
parsed_args.list_scenarios
452460
or parsed_args.list_initializers
453461
or parsed_args.list_targets
462+
or parsed_args.list_datasets
454463
or parsed_args.add_initializer
455464
or parsed_args.scenario_name
456465
)
@@ -514,6 +523,10 @@ async def _handle_list_commands_async(*, client: Any, parsed_args: Namespace) ->
514523
targets = await client.list_targets_async()
515524
_output.print_target_list(items=targets)
516525
return 0
526+
if parsed_args.list_datasets:
527+
resp = await client.list_datasets_async()
528+
_output.print_dataset_list(items=resp.get("items", []))
529+
return 0
517530
return None
518531

519532

0 commit comments

Comments
 (0)