From ccc46e68fcc763355c9df393d2b9781e1feaafde Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Sun, 21 Jun 2026 13:22:31 -0700 Subject: [PATCH 1/2] FEAT: Adding Garak Remote Datasets Adds remote seed-dataset loaders for the datasets hosted under the garak-llm HuggingFace org so garak techniques are easier to use in PyRIT scenarios. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/code/datasets/1_loading_datasets.ipynb | 140 ++++++++++--- doc/code/datasets/1_loading_datasets.py | 6 + .../datasets/seed_datasets/remote/__init__.py | 190 ++++++------------ .../seed_datasets/remote/_audio_cache.py | 73 +++++++ .../remote/garak_audio_dataset.py | 133 ++++++++++++ .../seed_datasets/remote/garak_dataset.py | 179 +++++++++++++++++ .../garak_package_hallucination_dataset.py | 161 +++++++++++++++ .../remote/garak_system_prompt_dataset.py | 65 ++++++ pyrit/datasets/seed_datasets/seed_metadata.py | 1 + .../unit/datasets/test_garak_audio_dataset.py | 99 +++++++++ ...est_garak_package_hallucination_dataset.py | 133 ++++++++++++ .../test_garak_system_prompt_dataset.py | 101 ++++++++++ 12 files changed, 1122 insertions(+), 159 deletions(-) create mode 100644 pyrit/datasets/seed_datasets/remote/_audio_cache.py create mode 100644 pyrit/datasets/seed_datasets/remote/garak_audio_dataset.py create mode 100644 pyrit/datasets/seed_datasets/remote/garak_dataset.py create mode 100644 pyrit/datasets/seed_datasets/remote/garak_package_hallucination_dataset.py create mode 100644 pyrit/datasets/seed_datasets/remote/garak_system_prompt_dataset.py create mode 100644 tests/unit/datasets/test_garak_audio_dataset.py create mode 100644 tests/unit/datasets/test_garak_package_hallucination_dataset.py create mode 100644 tests/unit/datasets/test_garak_system_prompt_dataset.py diff --git a/doc/code/datasets/1_loading_datasets.ipynb b/doc/code/datasets/1_loading_datasets.ipynb index 0dd5fc8389..fbd60e1581 100644 --- a/doc/code/datasets/1_loading_datasets.ipynb +++ b/doc/code/datasets/1_loading_datasets.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "0", + "id": "972bc331", "metadata": {}, "source": [ "# 1. Loading Built-in Datasets\n", @@ -57,15 +57,36 @@ "Red Team Social Bias [@vantaylor2024socialbias],\n", "and PromptIntel [@roccia2024promptintel].\n", "Some datasets also originate from tools like garak [@derczynski2024garak]\n", - "and AdvBench [@zou2023gcg]." + "and AdvBench [@zou2023gcg].\n", + "The garak family includes per-language package-hallucination registries\n", + "(`garak_pypi_packages`, `garak_npm_packages`, `garak_crates_packages`,\n", + "`garak_rubygems_packages`, `garak_dart_packages`, `garak_perl_packages`,\n", + "`garak_raku_packages`), system-prompt libraries (`garak_drh_system_prompts`,\n", + "`garak_tm_system_prompts`), and an audio jailbreak set\n", + "(`garak_audio_achilles_heel`)." ] }, { "cell_type": "code", - "execution_count": null, - "id": "1", - "metadata": {}, + "execution_count": 1, + "id": "ea993b30", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-21T19:50:19.684329Z", + "iopub.status.busy": "2026-06-21T19:50:19.684046Z", + "iopub.status.idle": "2026-06-21T19:50:24.746156Z", + "shell.execute_reply": "2026-06-21T19:50:24.745195Z" + } + }, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "C:\\Users\\rlundeen\\AppData\\Local\\miniconda3\\Lib\\site-packages\\requests\\__init__.py:113: RequestsDependencyWarning: urllib3 (2.5.0) or chardet (7.4.3)/charset_normalizer (3.3.2) doesn't match a supported version!\n", + " warnings.warn(\n" + ] + }, { "data": { "text/plain": [ @@ -108,7 +129,17 @@ " 'figstep',\n", " 'forbidden_questions',\n", " 'garak_access_shell_commands',\n", + " 'garak_audio_achilles_heel',\n", + " 'garak_crates_packages',\n", + " 'garak_dart_packages',\n", + " 'garak_drh_system_prompts',\n", + " 'garak_npm_packages',\n", + " 'garak_perl_packages',\n", + " 'garak_pypi_packages',\n", + " 'garak_raku_packages',\n", + " 'garak_rubygems_packages',\n", " 'garak_slur_terms_en',\n", + " 'garak_tm_system_prompts',\n", " 'garak_web_html_js',\n", " 'harmbench',\n", " 'harmbench_multimodal',\n", @@ -151,7 +182,7 @@ " 'xstest']" ] }, - "execution_count": null, + "execution_count": 1, "metadata": {}, "output_type": "execute_result" } @@ -166,7 +197,7 @@ }, { "cell_type": "markdown", - "id": "2", + "id": "9008caa4", "metadata": {}, "source": [ "## Loading Specific Datasets\n", @@ -176,10 +207,49 @@ }, { "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, + "execution_count": 2, + "id": "b5d578de", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-21T19:50:24.748414Z", + "iopub.status.busy": "2026-06-21T19:50:24.748044Z", + "iopub.status.idle": "2026-06-21T19:50:26.115123Z", + "shell.execute_reply": "2026-06-21T19:50:26.114227Z" + } + }, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\r", + "Loading datasets - this can take a few minutes: 0%| | 0/95 [00:00 str: + """ + Persist audio bytes under ``seed-prompt-entries`` and return the local path. + + The cached path is constructed deterministically from the serializer's + ``results_path`` plus its ``data_sub_directory`` plus ``filename``. If a file + already exists at that path, the path is returned without rewriting the bytes. + + Args: + filename: On-disk filename for the cached audio, including extension + (e.g. ``"garak_audio_achilles_heel_.wav"``). + audio_bytes: Raw audio file bytes to persist. + log_prefix: Short tag prepended to warning log messages. + + Returns: + str: Local path to the cached audio file. + + Raises: + RuntimeError: If the serializer's underlying memory is not configured. + """ + extension = Path(filename).suffix.lstrip(".") or None + + serializer = data_serializer_factory( + category="seed-prompt-entries", + data_type="audio_path", + extension=extension, + ) + + results_path = serializer._memory.results_path if serializer._memory is not None else None + results_storage_io = serializer._memory.results_storage_io if serializer._memory is not None else None + if not results_path or results_storage_io is None: + raise RuntimeError( + f"[{log_prefix}] Serializer memory is not properly configured: " + "results_path and results_storage_io must be set." + ) + + sub_directory = serializer.data_sub_directory.lstrip("/\\") + serializer.value = str(Path(results_path) / sub_directory / filename) + + try: + if await results_storage_io.path_exists_async(serializer.value): + return serializer.value + except Exception as e: + logger.warning(f"[{log_prefix}] Failed to check if cached audio {filename} exists: {e}") + + await serializer.save_data_async(data=audio_bytes, output_filename=Path(filename).stem) + + return str(serializer.value) diff --git a/pyrit/datasets/seed_datasets/remote/garak_audio_dataset.py b/pyrit/datasets/seed_datasets/remote/garak_audio_dataset.py new file mode 100644 index 0000000000..fd999b2bb2 --- /dev/null +++ b/pyrit/datasets/seed_datasets/remote/garak_audio_dataset.py @@ -0,0 +1,133 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Audio jailbreak dataset from the garak ``garak-llm`` HuggingFace org. + +garak's ``audio`` probe uses the ``audio_achilles_heel`` set of spoken +adversarial prompts. This loader exposes each clip as a ``SeedPrompt`` with +``data_type="audio_path"`` pointing at a locally-cached ``.wav`` file. The +upstream filenames encode a harm category (e.g. ``Malware_Generation_12.wav``), +which is preserved in ``SeedPrompt.harm_categories`` and ``metadata``. + +The audio column is loaded with ``Audio(decode=False)`` so the original WAV +bytes are persisted verbatim, avoiding any audio-decoding dependency. + +Reference: [@derczynski2024garak] +""" + +import logging +import re +from typing import Any, ClassVar + +from datasets import Audio +from typing_extensions import override + +from pyrit.datasets.seed_datasets.remote._audio_cache import cache_audio_bytes_async +from pyrit.datasets.seed_datasets.remote.garak_dataset import _GarakRemoteDataset +from pyrit.models import Modality, PromptDataType, SeedDataset, SeedPrompt, SeedUnion + +logger = logging.getLogger(__name__) + +# Strips a trailing "_" index from a filename stem to recover the +# harm-category prefix (e.g. "Malware_Generation_12" -> "Malware_Generation"). +_INDEX_SUFFIX = re.compile(r"_\d+$") + + +class _GarakAudioAchillesHeelDataset(_GarakRemoteDataset): + """ + garak ``audio_achilles_heel`` spoken-jailbreak dataset. + + Reference: [@derczynski2024garak] + """ + + should_register = True + HF_DATASET_NAME: ClassVar[str] = "garak-llm/audio_achilles_heel" + _DATASET_NAME: ClassVar[str] = "garak_audio_achilles_heel" + DATA_TYPE: ClassVar[PromptDataType] = "audio_path" + + # Metadata + modalities: tuple[Modality, ...] = (Modality.AUDIO,) + size: str = "medium" # ~350 audio clips + tags: frozenset[str] = frozenset({"safety", "multimodal", "jailbreak"}) + + @staticmethod + def _harm_category_from_path(path: str | None) -> str | None: + """ + Derive a harm category from an upstream filename. + + Args: + path: The upstream audio filename (e.g. ``"Malware_Generation_12.wav"``). + + Returns: + str | None: The category prefix (e.g. ``"Malware_Generation"``), or + None if no usable name is present. + """ + if not path: + return None + stem = re.sub(r"\.[^.]+$", "", path) + category = _INDEX_SUFFIX.sub("", stem) + return category or None + + @override + async def fetch_dataset_async(self, *, cache: bool = True) -> SeedDataset: + """ + Fetch the garak audio dataset and return it as a ``SeedDataset``. + + Each clip's raw WAV bytes are cached locally and referenced by an + ``audio_path`` ``SeedPrompt``. + + Args: + cache: Whether to cache the fetched dataset. Defaults to True. + + Returns: + SeedDataset: The audio dataset. + + Raises: + ValueError: If no usable seeds remain after processing. + """ + logger.info(f"Loading garak dataset {self.HF_DATASET_NAME}") + data = await self._fetch_rows_async(cache=cache) + # Disable decoding so the original WAV bytes are persisted verbatim + # (no soundfile/librosa dependency required). + data = data.cast_column("audio", Audio(decode=False)) + + seeds: list[SeedUnion] = [] + for index, item in enumerate(data): + audio = item.get("audio") or {} + audio_bytes = audio.get("bytes") + if not audio_bytes: + continue + + upstream_name = audio.get("path") or f"clip_{index}.wav" + stem = re.sub(r"\.[^.]+$", "", upstream_name) + safe_stem = re.sub(r"[^0-9A-Za-z_-]", "_", stem) + cached_path = await cache_audio_bytes_async( + filename=f"{self._DATASET_NAME}_{safe_stem}.wav", + audio_bytes=audio_bytes, + log_prefix="Garak-Audio", + ) + + category = self._harm_category_from_path(upstream_name) + metadata: dict[str, Any] = {"original_filename": upstream_name} + if category: + metadata["category"] = category + + seeds.append( + SeedPrompt( + value=cached_path, + data_type=self.DATA_TYPE, + dataset_name=self.dataset_name, + harm_categories=[category] if category else [], + source=self._source_url, + authors=list(self.SOURCE_AUTHORS), + groups=list(self.SOURCE_GROUPS), + metadata=metadata, + ) + ) + + if not seeds: + raise ValueError("SeedDataset cannot be empty. Check your filter criteria.") + + logger.info(f"Successfully loaded {len(seeds)} audio seeds from {self.HF_DATASET_NAME}") + return SeedDataset(seeds=seeds, dataset_name=self.dataset_name) diff --git a/pyrit/datasets/seed_datasets/remote/garak_dataset.py b/pyrit/datasets/seed_datasets/remote/garak_dataset.py new file mode 100644 index 0000000000..e9233285cc --- /dev/null +++ b/pyrit/datasets/seed_datasets/remote/garak_dataset.py @@ -0,0 +1,179 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Shared base for the garak (``garak-llm`` HuggingFace org) seed-dataset loaders. + +The garak LLM vulnerability scanner pulls reference data from a family of +datasets hosted under https://huggingface.co/garak-llm. Each of those datasets is +flat: every row contributes a single string (a package name, a system prompt, +an audio clip), so the uniform mapping is **one row -> one ``SeedPrompt`` and one +HuggingFace repo -> one ``SeedDataset``**. A ``SeedPrompt`` is just a value plus +metadata, so a package name is as valid a ``SeedPrompt`` as a chat message. + +Concrete loaders subclass ``_GarakRemoteDataset`` and set a handful of class +attributes (``HF_DATASET_NAME``, ``TEXT_COLUMN``, ``_DATASET_NAME``, optional +``METADATA_COLUMNS``); the base handles HuggingFace fetching, row -> ``SeedPrompt`` +conversion, metadata preservation, and the empty-result guard. + +Reference: [@derczynski2024garak] +""" + +import logging +from abc import ABC +from typing import Any, ClassVar + +from typing_extensions import override + +from pyrit.datasets.seed_datasets.remote.remote_dataset_loader import _RemoteDatasetLoader +from pyrit.models import ChatMessageRole, PromptDataType, SeedDataset, SeedPrompt, SeedUnion + +logger = logging.getLogger(__name__) + + +class _GarakRemoteDataset(_RemoteDatasetLoader, ABC): + """ + Abstract base for garak (``garak-llm``) HuggingFace seed datasets. + + Subclasses set the following class attributes: + + - ``HF_DATASET_NAME``: the ``garak-llm/`` HuggingFace identifier. + - ``TEXT_COLUMN``: the column whose value becomes ``SeedPrompt.value``. + - ``_DATASET_NAME``: the short ``garak_*`` name exposed via ``dataset_name``. + - ``METADATA_COLUMNS`` (optional): extra columns preserved in + ``SeedPrompt.metadata``. Maps the desired metadata key to one or more + candidate source column names (the first present wins), so loaders can + paper over upstream column-name drift (e.g. ``package_first_seen`` vs + ``package first seen``). + - ``ROLE`` (optional): ``SeedPrompt.role`` to assign (e.g. ``"system"``). + + Subclasses may also declare the class-level metadata attributes read by + ``_parse_metadata_async`` (``tags``, ``size``, ``modalities``, + ``harm_categories``). + """ + + should_register = False # abstract base — concrete subclasses register themselves + + # Required per-dataset identifiers. Declared (not defaulted) so a subclass that + # forgets to set them fails fast with AttributeError instead of silently using "". + HF_DATASET_NAME: ClassVar[str] + _DATASET_NAME: ClassVar[str] + + # Optional hooks with sensible family-wide defaults. + TEXT_COLUMN: ClassVar[str] = "text" + # Mapping of output metadata key -> candidate source column names (first match wins). + METADATA_COLUMNS: ClassVar[dict[str, tuple[str, ...]]] = {} + ROLE: ClassVar[ChatMessageRole | None] = None + DATA_TYPE: ClassVar[PromptDataType] = "text" + + # Shared provenance metadata for the garak dataset family. + SOURCE_AUTHORS: ClassVar[list[str]] = ["garak Team", "NVIDIA"] + SOURCE_GROUPS: ClassVar[list[str]] = ["NVIDIA"] + + def __init__(self) -> None: + """Initialize the loader. Subclasses are no-arg for provider discovery.""" + + @property + def _source_url(self) -> str: + """Return the canonical HuggingFace URL for this dataset.""" + return f"https://huggingface.co/datasets/{self.HF_DATASET_NAME}" + + @property + @override + def dataset_name(self) -> str: + """Return the short garak dataset name.""" + return self._DATASET_NAME + + def _extract_metadata(self, item: dict[str, Any]) -> dict[str, Any]: + """ + Build the per-seed metadata dict from a raw row. + + Args: + item: A single raw HuggingFace row. + + Returns: + dict[str, Any]: Metadata keyed per ``METADATA_COLUMNS``, skipping + absent or null source columns. + """ + metadata: dict[str, Any] = {} + for out_key, candidates in self.METADATA_COLUMNS.items(): + for column in candidates: + if column in item and item[column] is not None: + metadata[out_key] = item[column] + break + return metadata + + def _build_seed(self, *, value: str, item: dict[str, Any]) -> SeedPrompt: + """ + Construct a single ``SeedPrompt`` from a row's text value. + + Args: + value: The text that becomes ``SeedPrompt.value``. + item: The raw row, used to extract per-seed metadata. + + Returns: + SeedPrompt: The constructed seed. + """ + return SeedPrompt( + value=value, + data_type=self.DATA_TYPE, + dataset_name=self.dataset_name, + harm_categories=[], + role=self.ROLE, + source=self._source_url, + authors=list(self.SOURCE_AUTHORS), + groups=list(self.SOURCE_GROUPS), + metadata=self._extract_metadata(item), + ) + + async def _fetch_rows_async(self, *, cache: bool) -> Any: + """ + Fetch the raw HuggingFace ``train`` split for this dataset. + + Args: + cache: Whether to cache the fetched dataset. + + Returns: + The iterable HuggingFace dataset of raw rows. + """ + return await self._fetch_from_huggingface_async( + dataset_name=self.HF_DATASET_NAME, + split="train", + cache=cache, + ) + + @override + async def fetch_dataset_async(self, *, cache: bool = True) -> SeedDataset: + """ + Fetch the garak dataset from HuggingFace and return it as a ``SeedDataset``. + + Each row's ``TEXT_COLUMN`` becomes a ``SeedPrompt.value``; rows whose text + column is missing or empty are skipped. + + Args: + cache: Whether to cache the fetched dataset. Defaults to True. + + Returns: + SeedDataset: The garak dataset. + + Raises: + ValueError: If no usable seeds remain after processing. + """ + logger.info(f"Loading garak dataset {self.HF_DATASET_NAME}") + data = await self._fetch_rows_async(cache=cache) + + seeds: list[SeedUnion] = [] + for item in data: + value = item.get(self.TEXT_COLUMN) + if value is None: + continue + value = str(value).strip() + if not value: + continue + seeds.append(self._build_seed(value=value, item=item)) + + if not seeds: + raise ValueError("SeedDataset cannot be empty. Check your filter criteria.") + + logger.info(f"Successfully loaded {len(seeds)} seeds from {self.HF_DATASET_NAME}") + return SeedDataset(seeds=seeds, dataset_name=self.dataset_name) diff --git a/pyrit/datasets/seed_datasets/remote/garak_package_hallucination_dataset.py b/pyrit/datasets/seed_datasets/remote/garak_package_hallucination_dataset.py new file mode 100644 index 0000000000..6c5a6919fe --- /dev/null +++ b/pyrit/datasets/seed_datasets/remote/garak_package_hallucination_dataset.py @@ -0,0 +1,161 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Package-name reference lists from the garak ``garak-llm`` HuggingFace org. + +garak's ``packagehallucination`` detector checks model-generated code for +imports/requires of packages that do not exist in the relevant registry. These +loaders expose the same per-language package registries as ``SeedDataset``s: +every package name is a ``SeedPrompt`` (``data_type="text"``), so the set can be +used both as the hallucination-reference lookup (``{s.value for s in +dataset.seeds}``) and as candidate prompts. The ``package_first_seen`` column, +when present, is preserved in ``SeedPrompt.metadata`` so garak's cutoff-date +filtering remains reproducible. + +Each loader pins the same dated snapshot garak currently defaults to per +language. + +Reference: [@derczynski2024garak] +""" + +from typing import Any, ClassVar + +from pyrit.datasets.seed_datasets.remote.garak_dataset import _GarakRemoteDataset +from pyrit.models import Modality + + +class _GarakPackageHallucinationDataset(_GarakRemoteDataset): + """ + Base for garak per-language package-name registries. + + The text column is always ``text``; subclasses set ``HF_DATASET_NAME``, + ``_DATASET_NAME``, and ``_LANGUAGE``. The ``package_first_seen`` metadata + column name drifts across registries (``package_first_seen`` vs + ``package first seen``), so both spellings are accepted. + """ + + TEXT_COLUMN: ClassVar[str] = "text" + METADATA_COLUMNS: ClassVar[dict[str, tuple[str, ...]]] = { + "package_first_seen": ("package_first_seen", "package first seen"), + } + + # Programming-language label recorded on each seed's metadata. + _LANGUAGE: ClassVar[str] = "" + + # Metadata + modalities: tuple[Modality, ...] = (Modality.TEXT,) + tags: frozenset[str] = frozenset({"cybersecurity"}) + + def _extract_metadata(self, item: dict[str, Any]) -> dict[str, Any]: + """ + Add the programming-language label alongside the package-date metadata. + + Args: + item: A single raw HuggingFace row. + + Returns: + dict: Metadata including ``language`` and (when present) + ``package_first_seen``. + """ + metadata = super()._extract_metadata(item) + metadata["language"] = self._LANGUAGE + return metadata + + +class _GarakPypiDataset(_GarakPackageHallucinationDataset): + """ + garak PyPI (Python) package registry. + + Reference: [@derczynski2024garak] + """ + + should_register = True + HF_DATASET_NAME: ClassVar[str] = "garak-llm/pypi-20241031" + _DATASET_NAME: ClassVar[str] = "garak_pypi_packages" + _LANGUAGE: ClassVar[str] = "python" + size: str = "huge" # ~555k packages + + +class _GarakNpmDataset(_GarakPackageHallucinationDataset): + """ + garak npm (JavaScript) package registry. + + Reference: [@derczynski2024garak] + """ + + should_register = True + HF_DATASET_NAME: ClassVar[str] = "garak-llm/npm-20241031" + _DATASET_NAME: ClassVar[str] = "garak_npm_packages" + _LANGUAGE: ClassVar[str] = "javascript" + size: str = "huge" # ~3.3M packages + + +class _GarakCratesDataset(_GarakPackageHallucinationDataset): + """ + garak crates.io (Rust) package registry. + + Reference: [@derczynski2024garak] + """ + + should_register = True + HF_DATASET_NAME: ClassVar[str] = "garak-llm/crates-20250307" + _DATASET_NAME: ClassVar[str] = "garak_crates_packages" + _LANGUAGE: ClassVar[str] = "rust" + size: str = "huge" # ~156k crates + + +class _GarakRubyGemsDataset(_GarakPackageHallucinationDataset): + """ + garak RubyGems (Ruby) package registry. + + Reference: [@derczynski2024garak] + """ + + should_register = True + HF_DATASET_NAME: ClassVar[str] = "garak-llm/rubygems-20241031" + _DATASET_NAME: ClassVar[str] = "garak_rubygems_packages" + _LANGUAGE: ClassVar[str] = "ruby" + size: str = "huge" # ~181k gems + + +class _GarakDartDataset(_GarakPackageHallucinationDataset): + """ + garak pub.dev (Dart) package registry. + + Reference: [@derczynski2024garak] + """ + + should_register = True + HF_DATASET_NAME: ClassVar[str] = "garak-llm/dart-20250811" + _DATASET_NAME: ClassVar[str] = "garak_dart_packages" + _LANGUAGE: ClassVar[str] = "dart" + size: str = "huge" # ~67k packages + + +class _GarakPerlDataset(_GarakPackageHallucinationDataset): + """ + garak CPAN (Perl) package registry. + + Reference: [@derczynski2024garak] + """ + + should_register = True + HF_DATASET_NAME: ClassVar[str] = "garak-llm/perl-20250811" + _DATASET_NAME: ClassVar[str] = "garak_perl_packages" + _LANGUAGE: ClassVar[str] = "perl" + size: str = "huge" # ~56k modules + + +class _GarakRakuDataset(_GarakPackageHallucinationDataset): + """ + garak Raku package registry. + + Reference: [@derczynski2024garak] + """ + + should_register = True + HF_DATASET_NAME: ClassVar[str] = "garak-llm/raku-20250811" + _DATASET_NAME: ClassVar[str] = "garak_raku_packages" + _LANGUAGE: ClassVar[str] = "raku" + size: str = "large" # ~2.1k modules diff --git a/pyrit/datasets/seed_datasets/remote/garak_system_prompt_dataset.py b/pyrit/datasets/seed_datasets/remote/garak_system_prompt_dataset.py new file mode 100644 index 0000000000..13f7984c85 --- /dev/null +++ b/pyrit/datasets/seed_datasets/remote/garak_system_prompt_dataset.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +System-prompt collections from the garak ``garak-llm`` HuggingFace org. + +garak's ``sysprompt_extraction`` probe uses libraries of real system prompts as +extraction targets. These loaders expose those libraries as ``SeedDataset``s +where each system prompt is a ``SeedPrompt`` with ``role="system"``. Per-row +metadata (agent name, ids, flags) is preserved in ``SeedPrompt.metadata``. + +Reference: [@derczynski2024garak] +""" + +from typing import ClassVar + +from pyrit.datasets.seed_datasets.remote.garak_dataset import _GarakRemoteDataset +from pyrit.models import ChatMessageRole, Modality + + +class _GarakSystemPromptDataset(_GarakRemoteDataset): + """Base for garak system-prompt libraries (seeds use ``role="system"``).""" + + ROLE: ClassVar[ChatMessageRole | None] = "system" + + # Metadata + modalities: tuple[Modality, ...] = (Modality.TEXT,) + tags: frozenset[str] = frozenset({"system_prompt"}) + + +class _GarakDrhSystemPromptDataset(_GarakSystemPromptDataset): + """ + garak processed system-prompt library (credit: danielrosehill/System-Prompt-Library). + + Reference: [@derczynski2024garak] + """ + + should_register = True + HF_DATASET_NAME: ClassVar[str] = "garak-llm/drh-System-Prompt-processed" + _DATASET_NAME: ClassVar[str] = "garak_drh_system_prompts" + TEXT_COLUMN: ClassVar[str] = "systemprompt" + METADATA_COLUMNS: ClassVar[dict[str, tuple[str, ...]]] = { + "agentname": ("agentname",), + "creation_date": ("creation_date",), + "is_agent": ("is-agent",), + "is_single_turn": ("is-single-turn",), + } + size: str = "large" # ~944 system prompts + + +class _GarakTmSystemPromptDataset(_GarakSystemPromptDataset): + """ + garak system-prompt library (credit: teilomillet/system_prompt). + + Reference: [@derczynski2024garak] + """ + + should_register = True + HF_DATASET_NAME: ClassVar[str] = "garak-llm/tm-system_prompt" + _DATASET_NAME: ClassVar[str] = "garak_tm_system_prompts" + TEXT_COLUMN: ClassVar[str] = "prompt" + METADATA_COLUMNS: ClassVar[dict[str, tuple[str, ...]]] = { + "source_id": ("id",), + } + size: str = "small" # ~69 system prompts diff --git a/pyrit/datasets/seed_datasets/seed_metadata.py b/pyrit/datasets/seed_datasets/seed_metadata.py index cdd1149a85..d274c77b42 100644 --- a/pyrit/datasets/seed_datasets/seed_metadata.py +++ b/pyrit/datasets/seed_datasets/seed_metadata.py @@ -68,6 +68,7 @@ "prompt_injection", # direct or indirect prompt-injection payloads "ethics", # moral-judgment / values evaluation (e.g., moral foundations theory) "toxicity", # toxicity / hate-speech / profanity (e.g., RealToxicityPrompts, Perspective API) + "system_prompt", # collections of system prompts used as extraction targets (e.g., garak sysprompt probes) } ) diff --git a/tests/unit/datasets/test_garak_audio_dataset.py b/tests/unit/datasets/test_garak_audio_dataset.py new file mode 100644 index 0000000000..b276d4be91 --- /dev/null +++ b/tests/unit/datasets/test_garak_audio_dataset.py @@ -0,0 +1,99 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from pyrit.datasets.seed_datasets.remote.garak_audio_dataset import _GarakAudioAchillesHeelDataset +from pyrit.models import SeedDataset, SeedPrompt + + +def _make_data(rows): + """Wrap rows in a stand-in for an HF Dataset whose ``cast_column`` is a no-op.""" + data = MagicMock() + data.cast_column.return_value = rows + return data + + +@pytest.fixture +def mock_audio_rows(): + return [ + {"audio": {"bytes": b"RIFFmalware", "path": "Malware_Generation_12.wav"}}, + {"audio": {"bytes": b"RIFFprivacy", "path": "Privacy_Violation_3.wav"}}, + ] + + +async def test_fetch_maps_audio_rows(mock_audio_rows): + loader = _GarakAudioAchillesHeelDataset() + + with ( + patch.object( + loader, + "_fetch_rows_async", + new=AsyncMock(return_value=_make_data(mock_audio_rows)), + ), + patch( + "pyrit.datasets.seed_datasets.remote.garak_audio_dataset.cache_audio_bytes_async", + new=AsyncMock(side_effect=lambda *, filename, audio_bytes, log_prefix: f"/cache/{filename}"), + ), + ): + dataset = await loader.fetch_dataset_async() + + assert isinstance(dataset, SeedDataset) + assert len(dataset.seeds) == 2 + assert all(isinstance(p, SeedPrompt) for p in dataset.seeds) + first = dataset.seeds[0] + assert first.data_type == "audio_path" + assert first.value == "/cache/garak_audio_achilles_heel_Malware_Generation_12.wav" + assert first.dataset_name == "garak_audio_achilles_heel" + assert first.harm_categories == ["Malware_Generation"] + assert first.metadata["original_filename"] == "Malware_Generation_12.wav" + assert first.metadata["category"] == "Malware_Generation" + assert dataset.seeds[1].harm_categories == ["Privacy_Violation"] + + +async def test_rows_without_bytes_skipped(): + loader = _GarakAudioAchillesHeelDataset() + rows = [ + {"audio": {"bytes": b"RIFF", "path": "Malware_Generation_1.wav"}}, + {"audio": {"bytes": None, "path": "x.wav"}}, + {"audio": {}}, + ] + + with ( + patch.object(loader, "_fetch_rows_async", new=AsyncMock(return_value=_make_data(rows))), + patch( + "pyrit.datasets.seed_datasets.remote.garak_audio_dataset.cache_audio_bytes_async", + new=AsyncMock(side_effect=lambda *, filename, audio_bytes, log_prefix: f"/cache/{filename}"), + ), + ): + dataset = await loader.fetch_dataset_async() + + assert len(dataset.seeds) == 1 + + +async def test_empty_after_fetch_raises(): + loader = _GarakAudioAchillesHeelDataset() + + with patch.object(loader, "_fetch_rows_async", new=AsyncMock(return_value=_make_data([]))): + with pytest.raises(ValueError, match="SeedDataset cannot be empty"): + await loader.fetch_dataset_async() + + +@pytest.mark.parametrize( + "path, expected", + [ + ("Malware_Generation_12.wav", "Malware_Generation"), + ("Privacy_Violation_3.wav", "Privacy_Violation"), + ("NoIndexHere.wav", "NoIndexHere"), + (None, None), + ("", None), + ], +) +def test_harm_category_from_path(path, expected): + assert _GarakAudioAchillesHeelDataset._harm_category_from_path(path) == expected + + +def test_dataset_name(): + assert _GarakAudioAchillesHeelDataset().dataset_name == "garak_audio_achilles_heel" diff --git a/tests/unit/datasets/test_garak_package_hallucination_dataset.py b/tests/unit/datasets/test_garak_package_hallucination_dataset.py new file mode 100644 index 0000000000..108cac7e77 --- /dev/null +++ b/tests/unit/datasets/test_garak_package_hallucination_dataset.py @@ -0,0 +1,133 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyrit.datasets.seed_datasets.remote.garak_package_hallucination_dataset import ( + _GarakCratesDataset, + _GarakDartDataset, + _GarakNpmDataset, + _GarakPerlDataset, + _GarakPypiDataset, + _GarakRakuDataset, + _GarakRubyGemsDataset, +) +from pyrit.models import SeedDataset, SeedPrompt + + +@pytest.fixture +def mock_pypi_rows(): + return [ + {"text": "numpy", "package first seen": "2010-01-01"}, + {"text": "requests", "package first seen": "2011-02-02"}, + ] + + +@pytest.fixture +def mock_npm_rows(): + return [ + {"text": "express", "package_first_seen": "2010-05-05"}, + {"text": "lodash", "package_first_seen": "2011-06-06"}, + ] + + +async def test_fetch_dataset_maps_rows(mock_pypi_rows): + loader = _GarakPypiDataset() + + with patch.object( + loader, + "_fetch_from_huggingface_async", + new=AsyncMock(return_value=mock_pypi_rows), + ): + dataset = await loader.fetch_dataset_async() + + assert isinstance(dataset, SeedDataset) + assert len(dataset.seeds) == 2 + assert all(isinstance(p, SeedPrompt) for p in dataset.seeds) + assert dataset.seeds[0].value == "numpy" + assert dataset.seeds[0].data_type == "text" + assert dataset.seeds[0].dataset_name == "garak_pypi_packages" + assert dataset.seeds[0].harm_categories == [] + + +async def test_pypi_space_column_alias(mock_pypi_rows): + """pypi uses the spaced ``package first seen`` column name.""" + loader = _GarakPypiDataset() + + with patch.object( + loader, + "_fetch_from_huggingface_async", + new=AsyncMock(return_value=mock_pypi_rows), + ): + dataset = await loader.fetch_dataset_async() + + assert dataset.seeds[0].metadata["package_first_seen"] == "2010-01-01" + assert dataset.seeds[0].metadata["language"] == "python" + + +async def test_npm_underscore_column_alias(mock_npm_rows): + """npm uses the underscored ``package_first_seen`` column name.""" + loader = _GarakNpmDataset() + + with patch.object( + loader, + "_fetch_from_huggingface_async", + new=AsyncMock(return_value=mock_npm_rows), + ): + dataset = await loader.fetch_dataset_async() + + assert dataset.seeds[0].metadata["package_first_seen"] == "2010-05-05" + assert dataset.seeds[0].metadata["language"] == "javascript" + + +async def test_fetch_uses_train_split(mock_pypi_rows): + loader = _GarakPypiDataset() + + with patch.object( + loader, + "_fetch_from_huggingface_async", + new=AsyncMock(return_value=mock_pypi_rows), + ) as mock_fetch: + await loader.fetch_dataset_async() + + call_kwargs = mock_fetch.call_args.kwargs + assert call_kwargs["split"] == "train" + assert call_kwargs["dataset_name"] == "garak-llm/pypi-20241031" + + +async def test_empty_rows_raises(): + loader = _GarakPypiDataset() + + with patch.object(loader, "_fetch_from_huggingface_async", new=AsyncMock(return_value=[])): + with pytest.raises(ValueError, match="SeedDataset cannot be empty"): + await loader.fetch_dataset_async() + + +async def test_rows_missing_text_are_skipped(): + loader = _GarakDartDataset() + rows = [{"text": "valid"}, {"text": None}, {"text": " "}] + + with patch.object(loader, "_fetch_from_huggingface_async", new=AsyncMock(return_value=rows)): + dataset = await loader.fetch_dataset_async() + + assert [s.value for s in dataset.seeds] == ["valid"] + + +@pytest.mark.parametrize( + "loader_cls, expected_name, expected_language", + [ + (_GarakPypiDataset, "garak_pypi_packages", "python"), + (_GarakNpmDataset, "garak_npm_packages", "javascript"), + (_GarakCratesDataset, "garak_crates_packages", "rust"), + (_GarakRubyGemsDataset, "garak_rubygems_packages", "ruby"), + (_GarakDartDataset, "garak_dart_packages", "dart"), + (_GarakPerlDataset, "garak_perl_packages", "perl"), + (_GarakRakuDataset, "garak_raku_packages", "raku"), + ], +) +def test_dataset_names_and_language(loader_cls, expected_name, expected_language): + loader = loader_cls() + assert loader.dataset_name == expected_name + assert expected_language == loader._LANGUAGE diff --git a/tests/unit/datasets/test_garak_system_prompt_dataset.py b/tests/unit/datasets/test_garak_system_prompt_dataset.py new file mode 100644 index 0000000000..86b6c87b95 --- /dev/null +++ b/tests/unit/datasets/test_garak_system_prompt_dataset.py @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyrit.datasets.seed_datasets.remote.garak_system_prompt_dataset import ( + _GarakDrhSystemPromptDataset, + _GarakTmSystemPromptDataset, +) +from pyrit.models import SeedDataset, SeedPrompt + + +@pytest.fixture +def mock_drh_rows(): + return [ + { + "systemprompt": "You are a helpful assistant.", + "agentname": "Helper", + "creation_date": "2024-01-01", + "is-agent": True, + "is-single-turn": False, + }, + {"systemprompt": "You are a pirate.", "agentname": "Pirate"}, + ] + + +@pytest.fixture +def mock_tm_rows(): + return [ + {"prompt": "Act as a translator.", "id": "tm-1"}, + {"prompt": "Act as a poet.", "id": "tm-2"}, + ] + + +async def test_drh_fetch_maps_rows(mock_drh_rows): + loader = _GarakDrhSystemPromptDataset() + + with patch.object( + loader, + "_fetch_from_huggingface_async", + new=AsyncMock(return_value=mock_drh_rows), + ): + dataset = await loader.fetch_dataset_async() + + assert isinstance(dataset, SeedDataset) + assert len(dataset.seeds) == 2 + assert all(isinstance(p, SeedPrompt) for p in dataset.seeds) + first = dataset.seeds[0] + assert first.value == "You are a helpful assistant." + assert first.role == "system" + assert first.dataset_name == "garak_drh_system_prompts" + assert first.metadata["agentname"] == "Helper" + assert first.metadata["creation_date"] == "2024-01-01" + assert first.metadata["is_agent"] is True + assert first.metadata["is_single_turn"] is False + + +async def test_drh_handles_missing_metadata_columns(mock_drh_rows): + loader = _GarakDrhSystemPromptDataset() + + with patch.object( + loader, + "_fetch_from_huggingface_async", + new=AsyncMock(return_value=mock_drh_rows), + ): + dataset = await loader.fetch_dataset_async() + + assert "creation_date" not in dataset.seeds[1].metadata + assert dataset.seeds[1].metadata["agentname"] == "Pirate" + + +async def test_tm_fetch_maps_rows(mock_tm_rows): + loader = _GarakTmSystemPromptDataset() + + with patch.object( + loader, + "_fetch_from_huggingface_async", + new=AsyncMock(return_value=mock_tm_rows), + ): + dataset = await loader.fetch_dataset_async() + + assert len(dataset.seeds) == 2 + first = dataset.seeds[0] + assert first.value == "Act as a translator." + assert first.role == "system" + assert first.metadata["source_id"] == "tm-1" + + +async def test_empty_rows_raises(): + loader = _GarakTmSystemPromptDataset() + + with patch.object(loader, "_fetch_from_huggingface_async", new=AsyncMock(return_value=[])): + with pytest.raises(ValueError, match="SeedDataset cannot be empty"): + await loader.fetch_dataset_async() + + +def test_dataset_names(): + assert _GarakDrhSystemPromptDataset().dataset_name == "garak_drh_system_prompts" + assert _GarakTmSystemPromptDataset().dataset_name == "garak_tm_system_prompts" From cf0711b6db1900514ba60cb0b99a3325904b86cd Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Sun, 21 Jun 2026 13:43:38 -0700 Subject: [PATCH 2/2] Strip notebook outputs to satisfy pre-commit hooks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/code/datasets/1_loading_datasets.ipynb | 95 ++++------------------ 1 file changed, 18 insertions(+), 77 deletions(-) diff --git a/doc/code/datasets/1_loading_datasets.ipynb b/doc/code/datasets/1_loading_datasets.ipynb index fbd60e1581..f118067276 100644 --- a/doc/code/datasets/1_loading_datasets.ipynb +++ b/doc/code/datasets/1_loading_datasets.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "972bc331", + "id": "0", "metadata": {}, "source": [ "# 1. Loading Built-in Datasets\n", @@ -68,22 +68,15 @@ }, { "cell_type": "code", - "execution_count": 1, - "id": "ea993b30", - "metadata": { - "execution": { - "iopub.execute_input": "2026-06-21T19:50:19.684329Z", - "iopub.status.busy": "2026-06-21T19:50:19.684046Z", - "iopub.status.idle": "2026-06-21T19:50:24.746156Z", - "shell.execute_reply": "2026-06-21T19:50:24.745195Z" - } - }, + "execution_count": null, + "id": "1", + "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "C:\\Users\\rlundeen\\AppData\\Local\\miniconda3\\Lib\\site-packages\\requests\\__init__.py:113: RequestsDependencyWarning: urllib3 (2.5.0) or chardet (7.4.3)/charset_normalizer (3.3.2) doesn't match a supported version!\n", + "./AppData/Local/miniconda3/Lib/site-packages/requests/__init__.py:113: RequestsDependencyWarning: urllib3 (2.5.0) or chardet (7.4.3)/charset_normalizer (3.3.2) doesn't match a supported version!\n", " warnings.warn(\n" ] }, @@ -182,7 +175,7 @@ " 'xstest']" ] }, - "execution_count": 1, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -197,7 +190,7 @@ }, { "cell_type": "markdown", - "id": "9008caa4", + "id": "2", "metadata": {}, "source": [ "## Loading Specific Datasets\n", @@ -207,49 +200,10 @@ }, { "cell_type": "code", - "execution_count": 2, - "id": "b5d578de", - "metadata": { - "execution": { - "iopub.execute_input": "2026-06-21T19:50:24.748414Z", - "iopub.status.busy": "2026-06-21T19:50:24.748044Z", - "iopub.status.idle": "2026-06-21T19:50:26.115123Z", - "shell.execute_reply": "2026-06-21T19:50:26.114227Z" - } - }, + "execution_count": null, + "id": "3", + "metadata": {}, "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "\r", - "Loading datasets - this can take a few minutes: 0%| | 0/95 [00:00