Repository navigation
FEAT: Add ScenarioPreset - #2931
hannahwestra25 merged 52 commits into
Conversation
A scenario preset is a named, reusable, target-agnostic answer to *what to test*: a scenario plus its scenario-owned configuration. It deliberately excludes everything environment-specific (target, concurrency, retries, labels), which belongs to a launch. That split is what lets one preset run unchanged against dev, staging, and production. This is the first PR of the composite scan work and adds only the persistence layer. No API routes, no UI, and no built-in presets are registered yet. - `ScenarioPreset` / `ScenarioPresetProvenance` models. Every configurable field is tri-state: `None` means "not set by this preset, use the scenario default", which is distinct from an explicit value that happens to equal that default. Collapsing the two would pin a scenario default at save time and stop it tracking upstream changes. - `ScenarioPresetStorage` for JSON persistence with an optimistic-concurrency check on `version`. The caller states intent through a separate `expected_version` argument rather than through the version on the submitted model, so create and update are never ambiguous and a client-supplied version is never trusted into storage. - `ScenarioPresetRegistry` unioning built-in presets (registered from initializer code, read-only) with user presets from storage. Built-in wins a name collision and the colliding user preset is skipped with a warning, so shipping a new built-in cannot break a running install. - `ScenarioPresetConflictError` (HTTP 409) carrying both versions. - `FileDocumentStorage`, extracted from `CustomInitializerStorage`, holding the shared local-directory and Azure Blob handling for flat named documents. Extracted rather than duplicated because a second copy would mean two implementations of SAS detection, credential lifecycle, and container-URL parsing. `CustomInitializerStorage` now subclasses it with its public API unchanged, so its existing tests cover the refactor. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
122cf40 to
77825c6
Compare
Addresses review feedback on the preset storage contract. - Validate registry names in every single-document operation on FileDocumentStorage, closing a path-traversal hole that let a preset name containing separators read, overwrite, or delete files outside the configured source. The check lives at the base so no document API can omit it, which hardens CustomInitializerStorage as well. - Replace the in-model monotonic version counter with an opaque token derived from the stored bytes, paired with the preset as StoredPreset. The counter lived inside the document it guarded, so a hand-edit rewrote the very value used to detect that edit. Hashing the raw content also makes save_preset refuse to create over a malformed file instead of clobbering it. - Make ScenarioPresetRegistry read user presets through to storage instead of caching them, removing staleness across processes and the non-atomic cache rebind. load_stored_presets() is gone; get_stored_preset() replaces it. - Move ScenarioPresetConflictError out of pyrit.exceptions into the storage module as a ValueError subclass and drop its inert status_code. - Drop the misleading is_builtin guard in save_preset, which claimed to protect built-ins while acting on caller-supplied provenance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Scenario defaults already cover most preset fields, so a built-in preset restating them duplicates shipped data. The only case built-ins uniquely serve is multiplicity, which no shipped preset needs yet, so the machinery is deferred to the PR that introduces the first real built-in preset. - Remove ScenarioPresetProvenance, provenance, and is_builtin. - Delete ScenarioPresetRegistry, which without built-ins was a pass-through to storage, and move its default-directory logic into ScenarioPresetStorage. - Re-adding provenance later is not a migration: older documents lacking the key take the default. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ents Listing no longer returns names that the single-document operations reject. FileDocumentStorage._list_documents now filters enumerated names through validate_registry_name and logs a warning for the ones it skips, so list_scripts() and list_presets() only return names that get/save/delete will accept. This fixes a 500 from the custom initializer list endpoint whenever the script directory held an ordinary file like __init__.py or My-Script.py, and a misleading 400 'Invalid registry name' from the delete endpoint for a file that demonstrably exists. Also: ScenarioPreset now forbids unknown fields so a misspelled key fails loudly instead of being silently dropped; the preset name is no longer duplicated inside the document, since the document name is authoritative; get_preset_version() lets a caller recover a preset name whose document is malformed; and RunScenarioRequest.max_dataset_size no longer describes the removed per-dataset behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
My biggest hesitation is that it is difficult to validate the design without seeing the complete preset workflow working together. I like the overall design, but I would prefer to review the persistence contract alongside the CRUD, launch integration, and UI that consume it. Otherwise, we risk committing to storage and versioning decisions before we can validate them through a real user flow. Could we combine the first three planned PRs into one PR, or keep them as a stacked set that lands together? The individual diffs can remain small, but we would be able to review and merge them as one complete, usable feature. I'm flexible here, but I think it's a good approach. WDYT? |
Listing read and decoded every matching file before checking whether the name was one it could address, so a single undecodable file raised out of list_presets and list_scripts and hid every other stored document. Each backend now validates the name first, reads per document, and skips one it cannot read with a warning. Decoding moves out of storage into the subclasses, which know how to report a document they cannot interpret. That also makes the version token hash the exact bytes on disk. Text mode had been rewriting line endings on Windows, so the token described the in-memory string rather than the stored file, and a raw-byte read for version checks alone would have made every update conflict. Local writes stage the content in a sibling temporary file and move it into place, so a failed write leaves the previous document intact instead of truncating it, and a concurrent reader never sees a partial document. Initializer source is normalized on decode. Callers materialize it back to a file in text mode, which would otherwise translate a stored CRLF into CRCRLF. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Exposes the preset persistence layer over REST so a preset can be created,
read, edited, deleted, and turned into a run request.
Reads are open; mutations require an admin. Unlike custom initializers,
presets are data rather than executable code, so they are not gated behind
an allow_ switch.
POST creates a preset that must not already exist and PUT requires the
version returned when the preset was read, so a concurrent edit is reported
as a 409 instead of being silently overwritten.
References that do not resolve against the live registry are reported as
advisory issues on the response rather than rejected, keeping a preset
authored on one deployment storable on another.
POST /{name}/resolve merges a preset with the launch-owned fields it omits
and returns an ordinary RunScenarioRequest for the existing run endpoint.
Resolving server-side keeps one implementation of the merge, so the
distinction between unset and set-to-the-default cannot drift between
clients, and leaves the launch path itself untouched.
Storage is synchronous file or blob I/O, so every call into it runs on a
worker thread rather than blocking the event loop.
Adds scenario_presets_source so presets can be persisted to a shared Azure
Blob container instead of the default local directory.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the frontend for scenario presets on top of the preset API: - ScenarioPresetLibrary lists stored presets, surfaces validation issues from the backend, disables Launch for presets this deployment cannot run, and supports delete with confirmation. - ScenarioPresetEditor creates and updates presets, pinning the scenario-owned fields only. Launch-time settings (target, concurrency, retries, labels) are deliberately absent. - LaunchPresetDialog collects the launch-time settings, resolves the preset into a run request, and starts the run. Extracts the scenario-owned configuration out of ScenarioDetail into shared modules (scenarioConfigForm, ScenarioTechniqueSelector, ScenarioDatasetFields, scenarioRunLimits) so the launch form and the preset editor build the same config from one implementation. Reached from the scenario catalog; routed under /scanner/presets. The /edit suffix keeps /scanner/presets/new from shadowing a preset literally named "new". Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The modal covers the library card, so the operator previously committed to a run knowing only the preset and scenario name. Restate the pinned techniques, datasets, dataset cap, and baseline choice, rendering omitted fields as scenario defaults to match server-side resolution. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Can we separate persistence from this PR and address it as a shared framework capability? Scenario presets need it, but so do targets and other named registry configurations. I would prefer one design for what gets persisted, how references and credentials are handled, and how updates are versioned, rather than establishing a separate storage path for each component. For this PR, could we keep the |
hmm agreed that targets and other instance-backed configs need a persistence design, and this PR shouldn't try to settle it. But I was treating presets as closer to initializers and the environment/config files, and followed that pattern deliberately: a preset is a configuration, not an object in its own right the way a target or converter is. That split seems to be where the existing persistence boundary already falls. Targets and converters live in The resolving registry references that can go stale when a scenario or technique is renamed is a new concept. but that's more of a side effect of the storage decision |
|
After considering the configuration-versus-runtime-instance distinction, I would revise my earlier suggestion to postpone preset persistence entirely. General recommendation Consolidate the document-storage mechanics, not the runtime objects. Preset documents, initializer scripts, configuration files, and future target construction recipes can share storage operations without sharing their models or loading behavior. The shared layer should own local/Blob I/O, missing-document handling, opaque versions, and conditional updates with explicit concurrency guarantees. Typed adapters should own serialization and validation. Reference resolution, component construction, credentials, and API authorization should remain outside that layer. Environment-file protections and read-only source rules must remain intact when those services adopt shared storage. An instance registry addresses named runtime registration and lookup; it does not itself solve persistence. I would not require presets to implement a component identity contract or introduce a second in-memory copy just to reuse an instance registry. Recommendation for this PR Keep preset persistence rather than make the feature live-instance-only. The existing
The scope should be reliable configuration-document persistence now, with broader component persistence later. It should not require solving target reconstruction before presets can be saved. |
Preset storage had its own sidecar-lock implementation alongside the generic document store's. Consolidating on a single conditional-write path keeps the two from drifting and leaves scenario_preset_storage.py a thin adapter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every scenario-owned preset field is tri-state, where an omitted field keeps tracking the scenario default. The editor has to prefill those fields to render a control for them, so it was writing all of them back on any save and freezing this deployment's defaults into the document. The editor now emits a field only when the operator moved it off the scenario default or the stored document already pinned it. Alongside that: - Aggregate techniques are part of the server's allow-list, so the editor no longer reports them unavailable nor replaces them with the default set on save. - A tag toggle derives from the current selection rather than the rendered checkboxes, so a pinned aggregate survives it. - Stored scenario_params the editor renders no control for are carried through and named in an info bar instead of being dropped. - A baseline pin the forbidden policy hides from the form is preserved rather than silently flipped. - list_presets_async gathers its per-preset lookups, matching scenario_service. - configure_source runs off the event loop, since it may create the default preset directory. - scenario_presets_source is documented in .pyrit_conf_example. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Most of this is ordinary: the preset and run-request models grew fields on both sides, and both sides added a route module. RunScenarioRequest keeps main's request-limit types while retaining this branch's max_dataset_size description, which deliberately matches ScenarioRunSizeEstimateRequest. One conflict git could not see. Upstream replaced the dataset-sizing heuristic that summed the seed groups a deployment happened to have loaded with a limit the scenario declares for itself, and deleted the old code. This branch had already copied that heuristic verbatim into a new shared module, so the delete applied cleanly and the copy survived, which would have reinstated a number that means something different on every install. The shared helper now reads the declared limit, and a scenario that sizes itself by prompt generation reports no default and disables the field, so a preset cannot pin a cap the scenario will never apply. A preset authored through the API that already pins one is still shown and re-emitted, matching how a forbidden baseline is preserved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A preset is launched by copying its fields into RunScenarioRequest, which caps list and identifier sizes. The stored model declared none, so an oversized preset saved cleanly and only failed later at launch, when the operator is furthest from the field that caused it. Reuse the same annotated types so it fails at save. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The selector appends in click order while a scenario lists its defaults in registry order, so unchecking and rechecking a default technique produced a reordered-but-identical list. The structural compare read that as an edit and pinned the whole default set, costing the preset the tracking it exists to keep, and the sticky rule then re-pinned it on every later save. The backend resolves techniques through a set, so the order never carried meaning. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The age check and the unlink were separated, so two writers seeing the same stale lock both removed it: the first went on to take a fresh lock and the second deleted that one, leaving both inside the read-compare-replace with the same expected version. Both would then match and replace, losing the first writer's content while returning it success and a version token describing bytes that were never stored. Breaking a lock now happens under a second exclusive file, with the staleness re-checked while it is held so a lock released and retaken in between is left alone. A breaker older than the stale window can only be an orphan, since holding it spans a stat and an unlink, so it is discarded rather than wedging reclamation shut. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A preset's dataset selection is now a `ScenarioDatasetRequest` -- the same model a scenario run request carries -- replacing the flat `dataset_names` / `max_dataset_size` / `dataset_filters` trio. microsoft#3143 deprecated that shape on the run path in favour of `ScenarioDatasetRequest`, which expresses per-source limits, an `"all"` limit, and a `"default"` sentinel the flat fields cannot represent. Keeping a second, weaker vocabulary in the preset would have meant a lossy translation on every launch and a divergence that widened each time the run path gained a dataset control. Doing this before any preset exists on disk means no migration is owed, so `PRESET_SCHEMA_VERSION` stays at 1. Validation is preserved, not relaxed: the filter-key allow-list, the `ge=1` limit bound, the per-name length cap and the source-list cap all have exact counterparts on `ScenarioDatasetRequest`, which additionally rejects duplicate source names. The frontend change is duplicate-capability cleanup. `ScenarioDatasetFields` existed only on this branch -- a weaker reimplementation of dataset editing (comma-joined names, a single integer cap) added alongside main's `ScenarioDatasetTable`. The preset editor now uses the real table, and the `scenario -> defaults/options` fallback both callers need is extracted to `datasetPresentation.ts` rather than written twice. Removed tests, and why they are not coverage loss: - `describe('defaultMaxDatasetSize')` and `describe('initialPresetConfigState')` in `scenarioPresetForm.test.ts` covered functions this commit deletes. `initialPresetConfigState` had become a pure synonym for `initialScenarioConfigState`; `defaultMaxDatasetSize`'s intent -- showing the scenario's cap -- is now `ScenarioDatasetTable`'s, and is covered by its own suite on main. - The three dataset tests in `ScenarioPresetEditor.test.tsx` are rewritten rather than dropped, driving the real table through its ARIA roles. Validation: pytest tests/unit/{backend,models,registry,scenario} -> 7361 passed, 7 skipped npx jest "ScenarioPresets|Scenarios/(ScenarioDetail|ScenarioDatasetTable|scenarioConfigForm)" -> 209 passed, 210 total; the single failure was a 15s timeout in ScenarioDetail.test.tsx caused by a concurrent pytest run, and that file passes 83/83 when run alone tsc --noEmit -> clean Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…squash-merged main squash-merged microsoft#3057 as 822cbd4, a single-parent commit. This branch already carried PR A's original, unsquashed commits (9f84553 is an ancestor), so git saw two independent lineages producing the same content and raised add/add conflicts across every file microsoft#3057 touched. Resolution, and the evidence for it: - The four add/add files -- scenario_preset.py and the three preset test modules -- are byte-identical between 9f84553 and 822cbd4, so main's side contributed nothing this branch did not already have. Took ours, which is PR A's exact content plus the dataset-request migration. Verified afterwards that all five preset files kept their pre-merge blob hashes. - .pyrit_conf_example, scenario/core/__init__.py, configuration_loader.py and test_scenario_run_service.py conflicted only where main added target_upload_directory and IncompatibleTechniquePolicy. Our side was empty at each hunk, so main's additions were taken. Resolved by hand rather than with --theirs, because this branch had non-conflicting changes elsewhere in all three Python files that --theirs would have discarded. - The merge also silently duplicated the technique_tokens import block in scenario/core/__init__.py -- one copy from each lineage, in different positions. Removed the mis-sorted copy; the file now matches main exactly. Verification: of the 25 files microsoft#3057 touched, only the four carrying the dataset migration differ from origin/main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Merge main through 96ef5af while preserving the preset API, UI, and canonical dataset configuration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> # Conflicts: # frontend/src/components/Scenarios/datasetPresentation.ts
Description
First PR of the composite scan work. Design doc: Composite Scans in PyRIT.
Depends on #2956 — merge that first.
A scenario preset is a named, reusable, target-agnostic answer to what to test: a scenario plus its configuration. It excludes everything environment-specific — target, concurrency, retries, labels — which belongs to a launch, so one preset runs unchanged against dev, staging, and prod.
This PR covers the full vertical slice. It originally shipped the persistence layer alone; the API and UI were folded in so the storage contract is reviewed against something that exercises it.
ScenarioPresetStoredPresetScenarioPresetStorageFileDocumentStorage/api/scenario-presetsPOST /{name}/resolveScenarioPresetServiceScenarioPresetLibraryScenarioPresetEditorLaunchPresetDialogNo built-in presets:
RegisteredScenarioalready ships defaults for five of the seven preset fields, so a built-in would mostly restate them. Deferred to the PR that needs one.Screenshots
Worth reviewing
Nonemeans "use the scenario default", distinct from an explicit equal value. Unknown fields are rejected, not dropped, so a misspelled key fails loudly.ScenarioPresetreuses the annotated types fromRunScenarioRequest, so an oversized preset fails at save rather than at launch.expected_versionis separate:Nonecreates, a token updates, and a client-supplied version never reaches storage.FileDocumentStoragebase, so no document API can address a path outside its source. Listing applies the rule before reading and skips what it can't decode, so one bad file can't hide the rest.os.replacefrom a sibling temp file stops a reader seeing a half-written document; it does nothing to stop a second writer discarding the first's edit. Each backend keeps that comparison and the write inseparable: Blob creates useoverwrite=Falseand updates sendIf-Matchon the ETag just read; local writes hold an OS advisory lock across the whole read-compare-replace. A hand-edit outside the class is caught by the content hash instead.FileDocumentStorageis extracted, not duplicated.CustomInitializerStoragebecomes a small subclass with an unchanged public API, so its existing tests are the regression check. Preset storage's separate sidecar lock was folded into the same base, leavingscenario_preset_storage.pya thin adapter.POST /{name}/resolvemerges the preset with the launch-owned fields into an ordinaryRunScenarioRequest, so unset-vs-set-to-the-default has one implementation.pyrit/scenario/core/_technique_tokens.pybacks both launch resolution and preset validation, so a converter-qualified token can't be valid at launch and unknown in the library.scenarioConfigForm,ScenarioTechniqueSelector, andScenarioDatasetFieldscome out ofScenarioDetail, so the launch form and the preset editor build the same config from one place.allow_switch.Tests and Documentation
CustomInitializerStorage: tri-state round-trips, version conflicts, lost-update races on both backends, advisory-lock acquisition and reclamation, lock files never surfacing as documents, out-of-band edits and deletes, illegal names, unknown fields, malformed-file skipping, and the previous document surviving a failed write.RunScenarioRequestmerge./scanner/presets/newfrom shadowing a preset namednew.Validation:
npm run lintandnpx tsc --noEmitpass.jest src/components/Scenarios src/components/ScenarioPresetsis green across 12 suites,pytest tests/unit/registry tests/unit/modelsis green, and the backend preset and lifecycle tests are green. The branch is merged current withmain; full-suite coverage is left to CI.No docs or notebook changes: the preset surface is reached from the scanner UI rather than a notebook, so JupyText was not run.