{row.value}
+{key}
+ {value}
+{finding.description}
} + key.
+async function findConfirmation(key: string): Promise {
+ const dialog = await screen.findByRole('dialog')
+ expect(within(dialog).getByRole('heading')).toHaveTextContent(`Remove ${key}?`)
+ return dialog
+}
+
+async function confirmRemoval(user: UserEvent, key: string): Promise {
+ await user.click(await screen.findByRole('button', { name: removeButtonName(key) }))
+ const dialog = await findConfirmation(key)
+ await user.click(within(dialog).getByRole('button', { name: 'Remove' }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+}
+
+describe('RetiredFieldValues', () => {
+ const defaultProps = {
+ values: RETIRED_VALUES,
+ removed: new Set(),
+ onRemove: jest.fn(),
+ onUndo: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('renders nothing without retired values', () => {
+ render( )
+ expect(screen.queryByText('Retired fields · read-only')).not.toBeInTheDocument()
+ expect(screen.queryByRole('group')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('lists each retired value with its raw key and a Remove button', () => {
+ render( )
+ const group = screen.getByRole('group', { name: 'Retired fields · read-only' })
+ const items = within(group).getAllByRole('listitem')
+ expect(items).toHaveLength(2)
+ const [description, ticket] = items
+ expect(within(description).getByText('description')).toBeInTheDocument()
+ expect(within(description).getByText(/The assistant agreed/)).toHaveTextContent(
+ 'The assistant agreed to play a character and then\nexplained the restricted steps.',
+ { normalizeWhitespace: false },
+ )
+ expect(within(description).getByRole('button', { name: removeButtonName('description') })).toBeEnabled()
+ expect(within(ticket).getByText('contoso_ticket')).toBeInTheDocument()
+ expect(within(ticket).getByText('CONTOSO-42')).toBeInTheDocument()
+ expect(within(ticket).getByRole('button', { name: removeButtonName('contoso_ticket') })).toBeEnabled()
+ expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument()
+ })
+
+ it('asks before removing and keeps the value on Keep it', async () => {
+ const user = userEvent.setup()
+ render( )
+ await user.click(screen.getByRole('button', { name: removeButtonName('description') }))
+ const dialog = await findConfirmation('description')
+ expect(dialog).toHaveTextContent(
+ 'This deletes its text from the finding when you save. Copy anything you need first. '
+ + 'Cancel on the edit dialog still brings it back.',
+ )
+ expect(within(dialog).getByRole('button', { name: 'Remove' })).toBeInTheDocument()
+ await user.click(within(dialog).getByRole('button', { name: 'Keep it' }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ expect(await screen.findByRole('button', { name: removeButtonName('description') })).toBeInTheDocument()
+ expect(defaultProps.onRemove).not.toHaveBeenCalled()
+ })
+
+ it('reports a confirmed removal and offers Undo', async () => {
+ const user = userEvent.setup()
+ const onRemove = jest.fn()
+ const onUndo = jest.fn()
+ render( )
+ await confirmRemoval(user, 'description')
+ expect(onRemove).toHaveBeenCalledTimes(1)
+ expect(onRemove).toHaveBeenCalledWith('description')
+ expect(await screen.findByRole('button', { name: removeButtonName('contoso_ticket') })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: removeButtonName('description') })).not.toBeInTheDocument()
+ expect(screen.getByText('1 retired value is removed when you save.')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Undo' }))
+ expect(onUndo).toHaveBeenCalledTimes(1)
+ expect(screen.getByRole('button', { name: removeButtonName('description') })).toBeInTheDocument()
+ expect(screen.queryByText(/removed when you save/)).not.toBeInTheDocument()
+ })
+
+ it('counts several removals', async () => {
+ const user = userEvent.setup()
+ const onRemove = jest.fn()
+ render( )
+ await confirmRemoval(user, 'description')
+ await confirmRemoval(user, 'contoso_ticket')
+ expect(onRemove).toHaveBeenNthCalledWith(1, 'description')
+ expect(onRemove).toHaveBeenNthCalledWith(2, 'contoso_ticket')
+ expect(await screen.findByRole('button', { name: 'Undo' })).toBeInTheDocument()
+ expect(screen.getByText('2 retired values are removed when you save.')).toBeInTheDocument()
+ expect(screen.queryByRole('group', { name: 'Retired fields · read-only' })).not.toBeInTheDocument()
+ })
+
+ it('moves focus to Undo after a removal is confirmed', async () => {
+ const user = userEvent.setup()
+ render( )
+ await confirmRemoval(user, 'description')
+ expect(await screen.findByRole('button', { name: 'Undo' })).toHaveFocus()
+ })
+
+ // jsdom fires no focusout when the dialog removes the focused button, so Tabster only restores focus here after
+ // keyboard use. The e2e spec covers a mouse click on Keep it.
+ it('returns focus to the Remove button on Keep it', async () => {
+ const user = userEvent.setup()
+ render( )
+ const removeButton = screen.getByRole('button', { name: removeButtonName('contoso_ticket') })
+ await user.click(removeButton)
+ const dialog = await findConfirmation('contoso_ticket')
+ expect(within(dialog).getByRole('button', { name: 'Keep it' })).toHaveFocus()
+ await user.keyboard('{Enter}')
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ await waitFor(() => expect(removeButton).toHaveFocus())
+ })
+
+ it('returns focus to the Remove button on Escape', async () => {
+ const user = userEvent.setup()
+ render( )
+ const removeButton = screen.getByRole('button', { name: removeButtonName('contoso_ticket') })
+ await user.click(removeButton)
+ await findConfirmation('contoso_ticket')
+ await user.keyboard('{Escape}')
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ await waitFor(() => expect(removeButton).toHaveFocus())
+ })
+
+ it('moves focus to the first Remove button after Undo', async () => {
+ const user = userEvent.setup()
+ render( )
+ await confirmRemoval(user, 'contoso_ticket')
+ await user.click(await screen.findByRole('button', { name: 'Undo' }))
+ expect(screen.getByRole('button', { name: removeButtonName('description') })).toHaveFocus()
+ })
+
+ it('disables every Remove button and Undo while disabled', () => {
+ const values = [...RETIRED_VALUES, { key: 'contoso_owner', value: 'Contoso red team' }]
+ render(
+
+ )
+ const removeButtons = screen.getAllByRole('button', { name: /^Remove retired value / })
+ expect(removeButtons).toHaveLength(2)
+ for (const button of removeButtons) expect(button).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled()
+ })
+})
diff --git a/frontend/src/components/Operations/RetiredFieldValues.tsx b/frontend/src/components/Operations/RetiredFieldValues.tsx
new file mode 100644
index 0000000000..2eab86b4d7
--- /dev/null
+++ b/frontend/src/components/Operations/RetiredFieldValues.tsx
@@ -0,0 +1,107 @@
+import { type RefObject, useEffect, useId, useRef, useState, type JSX } from 'react'
+import {
+ Button, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Link, Text,
+ useRestoreFocusTarget,
+} from '@fluentui/react-components'
+
+import { useRetiredFieldValuesStyles } from './RetiredFieldValues.styles'
+
+interface RetiredFieldValuesProps {
+ /** Retired values from the stored finding, in stored order. */
+ values: Array<{ key: string; value: string }>
+ /** Keys marked for removal in this edit. */
+ removed: ReadonlySet
+ disabled?: boolean
+ onRemove: (key: string) => void
+ /** Restores every marked key. */
+ onUndo: () => void
+}
+
+function removalNote(count: number): string {
+ return count === 1
+ ? '1 retired value is removed when you save.'
+ : `${count} retired values are removed when you save.`
+}
+
+/**
+ * Lists stored values whose keys the active finding fields no longer define. They are read-only, but each
+ * can be marked for removal on save, after a confirmation, and Undo restores every marked value.
+ */
+export default function RetiredFieldValues({
+ values, removed, disabled = false, onRemove, onUndo,
+}: RetiredFieldValuesProps): JSX.Element | null {
+ const styles = useRetiredFieldValuesStyles()
+ const headingId = useId()
+ // The key stays set while the dialog closes, so its title doesn't lose the key during the exit animation.
+ const [confirming, setConfirming] = useState({ key: '', open: false })
+ // The confirmation dialog opens from state rather than a DialogTrigger. Mark each Remove button so Tabster
+ // can return focus to it when the dialog is cancelled.
+ const restoreFocusTarget = useRestoreFocusTarget()
+ const undoRef = useRef(null)
+ const firstRemoveRef = useRef(null)
+ const focusAfterChange = useRef | null>(null)
+
+ // Confirming a removal unmounts the row whose button opened the dialog, and Undo unmounts itself, so focus
+ // moves to the control that replaces them once the parent has applied the change.
+ useEffect(() => {
+ focusAfterChange.current?.current?.focus()
+ focusAfterChange.current = null
+ }, [removed])
+
+ if (values.length === 0) return null
+ const kept = values.filter(({ key }) => !removed.has(key))
+ const closeConfirmation = (): void => {
+ setConfirming((current) => ({ ...current, open: false }))
+ }
+
+ return (
+
+ {kept.length > 0 &&
+ Retired fields · read-only
+
+ {kept.map(({ key, value }, index) => (
+ -
+
+
{key}
+ {value}
+
+ { setConfirming({ key, open: true }) }}>Remove
+
+ ))}
+
+ }
+ {removed.size > 0 &&
+ {removalNote(removed.size)}
+ {
+ focusAfterChange.current = firstRemoveRef
+ onUndo()
+ }}>Undo
+ }
+
+
+ )
+}
diff --git a/frontend/src/hooks/useFindingFields.test.tsx b/frontend/src/hooks/useFindingFields.test.tsx
new file mode 100644
index 0000000000..d693707d45
--- /dev/null
+++ b/frontend/src/hooks/useFindingFields.test.tsx
@@ -0,0 +1,127 @@
+import { StrictMode } from 'react'
+
+import { act, renderHook, waitFor } from '@testing-library/react'
+
+import { operationsApi } from '@/services/api'
+import { deferred } from '@/test-utils/deferred'
+import { CONTOSO_FINDING_FIELDS, DEFAULT_FINDING_FIELDS_FIXTURE } from '@/test-utils/findingFieldsFixtures'
+import type { FindingFields } from '@/types'
+
+import { useFindingFields } from './useFindingFields'
+import { useRuntime } from './useRuntime'
+
+jest.mock('@/services/api', () => ({ operationsApi: { getFindingFields: jest.fn() } }))
+jest.mock('./useRuntime', () => ({ useRuntime: jest.fn() }))
+const mockGetFindingFields = jest.mocked(operationsApi.getFindingFields)
+const mockRuntime = jest.mocked(useRuntime)
+
+describe('useFindingFields', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockGetFindingFields.mockReset()
+ mockRuntime.mockReturnValue({ ready: true, state: 'ready', generation: 'one' })
+ })
+
+ it('loads the finding fields once', async () => {
+ mockGetFindingFields.mockResolvedValue(CONTOSO_FINDING_FIELDS)
+
+ const { result, rerender } = renderHook(() => useFindingFields())
+ expect(result.current.fields).toBeNull()
+
+ await waitFor(() => expect(result.current.fields).toBe(CONTOSO_FINDING_FIELDS))
+ rerender()
+ expect(result.current.error).toBe('')
+ expect(mockGetFindingFields).toHaveBeenCalledTimes(1)
+ })
+
+ it('loads the fields again when the runtime generation changes, keeping the last ones meanwhile', async () => {
+ const reloaded = deferred()
+ mockGetFindingFields.mockResolvedValueOnce(DEFAULT_FINDING_FIELDS_FIXTURE).mockReturnValueOnce(reloaded.promise)
+
+ const { result, rerender } = renderHook(() => useFindingFields())
+ await waitFor(() => expect(result.current.fields).toBe(DEFAULT_FINDING_FIELDS_FIXTURE))
+
+ mockRuntime.mockReturnValue({ ready: true, state: 'ready', generation: 'two' })
+ rerender()
+ expect(mockGetFindingFields).toHaveBeenCalledTimes(2)
+ expect(result.current.fields).toBe(DEFAULT_FINDING_FIELDS_FIXTURE)
+
+ await act(async () => { reloaded.resolve(CONTOSO_FINDING_FIELDS) })
+ expect(result.current.fields).toBe(CONTOSO_FINDING_FIELDS)
+ expect(result.current.error).toBe('')
+ })
+
+ it('changes orderRevision only when a reload brings a different severity order', async () => {
+ mockGetFindingFields
+ .mockResolvedValueOnce(DEFAULT_FINDING_FIELDS_FIXTURE)
+ .mockResolvedValueOnce({ ...DEFAULT_FINDING_FIELDS_FIXTURE })
+ .mockResolvedValueOnce(CONTOSO_FINDING_FIELDS)
+
+ const { result, rerender } = renderHook(() => useFindingFields())
+ await waitFor(() => expect(result.current.fields).toBe(DEFAULT_FINDING_FIELDS_FIXTURE))
+ const loaded = result.current.orderRevision
+
+ mockRuntime.mockReturnValue({ ready: true, state: 'ready', generation: 'two' })
+ rerender()
+ await waitFor(() => expect(result.current.fields).not.toBe(DEFAULT_FINDING_FIELDS_FIXTURE))
+ expect(result.current.orderRevision).toBe(loaded)
+
+ mockRuntime.mockReturnValue({ ready: true, state: 'ready', generation: 'three' })
+ rerender()
+ await waitFor(() => expect(result.current.fields).toBe(CONTOSO_FINDING_FIELDS))
+ expect(result.current.orderRevision).not.toBe(loaded)
+ })
+
+ it('reports a load failure and retries', async () => {
+ mockGetFindingFields
+ .mockRejectedValueOnce(new Error('The Contoso backend is unavailable.'))
+ .mockResolvedValueOnce(DEFAULT_FINDING_FIELDS_FIXTURE)
+
+ const { result } = renderHook(() => useFindingFields())
+ await waitFor(() => expect(result.current.error).toBe('The Contoso backend is unavailable.'))
+ expect(result.current.fields).toBeNull()
+
+ act(() => { result.current.retry() })
+ expect(result.current.error).toBe('')
+
+ await waitFor(() => expect(result.current.fields).toBe(DEFAULT_FINDING_FIELDS_FIXTURE))
+ expect(result.current.error).toBe('')
+ expect(mockGetFindingFields).toHaveBeenCalledTimes(2)
+ })
+
+ it('ignores a response after unmount', async () => {
+ const unmounted = deferred()
+ const mounted = deferred()
+ mockGetFindingFields.mockReturnValueOnce(unmounted.promise).mockReturnValueOnce(mounted.promise)
+
+ // StrictMode unmounts and remounts once, so the first request belongs to an unmounted effect.
+ const { result } = renderHook(() => useFindingFields(), { wrapper: StrictMode })
+ await act(async () => { mounted.resolve(CONTOSO_FINDING_FIELDS) })
+ await act(async () => { unmounted.resolve(DEFAULT_FINDING_FIELDS_FIXTURE) })
+
+ expect(mockGetFindingFields).toHaveBeenCalledTimes(2)
+ expect(result.current.fields).toBe(CONTOSO_FINDING_FIELDS)
+ })
+
+ it('loads only while enabled, again each time it is enabled, and clears an earlier failure', async () => {
+ mockGetFindingFields
+ .mockRejectedValueOnce(new Error('The Contoso backend is unavailable.'))
+ .mockResolvedValueOnce(CONTOSO_FINDING_FIELDS)
+
+ const { result, rerender } = renderHook(({ enabled }: { enabled: boolean }) => useFindingFields(enabled), {
+ initialProps: { enabled: false },
+ })
+ expect(mockGetFindingFields).not.toHaveBeenCalled()
+
+ rerender({ enabled: true })
+ await waitFor(() => expect(result.current.error).toBe('The Contoso backend is unavailable.'))
+ rerender({ enabled: false })
+ mockRuntime.mockReturnValue({ ready: true, state: 'ready', generation: 'two' })
+ rerender({ enabled: false })
+ expect(mockGetFindingFields).toHaveBeenCalledTimes(1)
+ rerender({ enabled: true })
+ await waitFor(() => expect(result.current.fields).toBe(CONTOSO_FINDING_FIELDS))
+ expect(result.current.error).toBe('')
+ expect(mockGetFindingFields).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/frontend/src/hooks/useFindingFields.ts b/frontend/src/hooks/useFindingFields.ts
new file mode 100644
index 0000000000..aff249334a
--- /dev/null
+++ b/frontend/src/hooks/useFindingFields.ts
@@ -0,0 +1,61 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+import { operationsApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import type { FindingFields } from '@/types'
+
+import { useRuntime } from './useRuntime'
+
+interface FindingFieldsState {
+ /** Null until the fields first load. A reload keeps the last fields until the new ones arrive. */
+ readonly fields: FindingFields | null
+ /** The last load failure, or an empty string. */
+ readonly error: string
+ readonly retry: () => void
+ /** Changes when a reload brings a different severity ranking, so lists can load their pages again. */
+ readonly orderRevision: number
+}
+
+/**
+ * Loads the active finding fields, and again when the runtime generation changes, because a live apply can
+ * change them.
+ * @param enabled - Loads only while true, and again each time it turns true, so a closed dialog fetches nothing.
+ */
+export function useFindingFields(enabled = true): FindingFieldsState {
+ const { generation } = useRuntime()
+ const [fields, setFields] = useState(null)
+ const [error, setError] = useState('')
+ const [attempt, setAttempt] = useState(0)
+ const [orderRevision, setOrderRevision] = useState(0)
+ const loadedOrder = useRef(null)
+
+ useEffect(() => {
+ if (!enabled) return
+ let ignore = false
+ operationsApi.getFindingFields()
+ .then((loaded: FindingFields) => {
+ if (!ignore) {
+ const order = JSON.stringify(loaded.severity.options.map(({ key }) => key))
+ if (loadedOrder.current !== null && loadedOrder.current !== order) {
+ setOrderRevision((count: number) => count + 1)
+ }
+ loadedOrder.current = order
+ setFields(loaded)
+ setError('')
+ }
+ })
+ .catch((cause: unknown) => {
+ if (!ignore) setError(toApiError(cause).detail)
+ })
+ return () => {
+ ignore = true
+ }
+ }, [attempt, enabled, generation])
+
+ const retry = useCallback(() => {
+ setError('')
+ setAttempt((count: number) => count + 1)
+ }, [])
+
+ return { fields, error, retry, orderRevision }
+}
diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts
index 9fba7bf2fa..5ce5791a33 100644
--- a/frontend/src/services/api.test.ts
+++ b/frontend/src/services/api.test.ts
@@ -42,16 +42,16 @@ import {
import { compatibility, COMPATIBILITY_HEADER } from "./compatibility";
import { toApiError } from "./errors";
import { makeAnalyticsFacets, makeAnalyticsReport, makeAnalyticsResults } from "@/test-utils/analyticsFixtures";
+import { DEFAULT_FINDING_FIELDS_FIXTURE } from "@/test-utils/findingFieldsFixtures";
import type { FindingExportRequest } from "@/types";
import { DEFAULT_ANALYTICS_VIEW } from "@/utils/attackAnalytics";
describe("api service", () => {
describe("operationsApi", () => {
- it("reads canonical harm categories without copying the taxonomy into the frontend", async () => {
- const options = { harm_types: ["Malware", "Other"] };
- (apiClient.get as jest.Mock).mockResolvedValueOnce({ data: options });
- expect(await operationsApi.getFindingOptions()).toEqual(options);
- expect(apiClient.get).toHaveBeenCalledWith("/operations/finding-options");
+ it("gets finding fields", async () => {
+ (apiClient.get as jest.Mock).mockResolvedValueOnce({ data: DEFAULT_FINDING_FIELDS_FIXTURE });
+ expect(await operationsApi.getFindingFields()).toEqual(DEFAULT_FINDING_FIELDS_FIXTURE);
+ expect(apiClient.get).toHaveBeenCalledWith("/operations/finding-fields");
});
it("encodes evidence scope, searches literally, and preserves duplicate feedback", async () => {
const page = { items: [], has_more: false, next_offset: null };
@@ -73,7 +73,8 @@ describe("api service", () => {
expect(apiClient.delete).toHaveBeenCalledWith("/operations/a%2Fb/findings/c%2Fd/evidence/e%2Ff");
});
it("nests finding requests under an encoded operation ID", async () => {
- const request = { title: "Assessment", description: "", severity: "low" as const };
+ const request = { title: "Assessment", severity: "low", field_values: { description: "Notes" } };
+ const update = { ...request, remove_retired_keys: ["contoso_ticket"] };
(apiClient.post as jest.Mock).mockResolvedValueOnce({ data: { name: "Case" } });
await operationsApi.create({ name: " Case / α% " });
expect(apiClient.post).toHaveBeenCalledWith("/operations", { name: " Case / α% " });
@@ -86,8 +87,8 @@ describe("api service", () => {
params: { limit: 20, offset: 0 },
});
(apiClient.put as jest.Mock).mockResolvedValueOnce({ data: request });
- expect(await operationsApi.updateFinding("a/b", "c/d", request)).toEqual(request);
- expect(apiClient.put).toHaveBeenCalledWith("/operations/a%2Fb/findings/c%2Fd", request);
+ expect(await operationsApi.updateFinding("a/b", "c/d", update)).toEqual(request);
+ expect(apiClient.put).toHaveBeenCalledWith("/operations/a%2Fb/findings/c%2Fd", update);
(apiClient.delete as jest.Mock).mockResolvedValueOnce({ data: undefined });
expect(await operationsApi.deleteFinding("a/b", "c/d")).toBeUndefined();
expect(apiClient.delete).toHaveBeenCalledWith("/operations/a%2Fb/findings/c%2Fd");
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 2a0090b558..9fdfc0f1ae 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -83,10 +83,10 @@ import type {
UpdateAttackRequest,
SaveConversationRequest,
Finding,
- FindingCreate,
FindingCreateRequest,
+ FindingFields,
FindingListResponse,
- FindingOptionsResponse,
+ FindingUpdateRequest,
FindingEvidenceCreateRequest,
FindingEvidenceAttachResponse,
FindingEvidenceListResponse,
@@ -109,8 +109,8 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
const operationPath = (operationId: string): string => `/operations/${encodeURIComponent(operationId)}`
export const operationsApi = {
- getFindingOptions: async (): Promise => {
- return (await apiClient.get('/operations/finding-options')).data
+ getFindingFields: async (): Promise => {
+ return (await apiClient.get('/operations/finding-fields')).data
},
list: async (): Promise => {
return (await apiClient.get('/operations')).data
@@ -135,7 +135,7 @@ export const operationsApi = {
createFinding: async (operationId: string, request: FindingCreateRequest): Promise => {
return (await apiClient.post(`${operationPath(operationId)}/findings`, request)).data
},
- updateFinding: async (operationId: string, findingId: string, request: FindingCreate): Promise => {
+ updateFinding: async (operationId: string, findingId: string, request: FindingUpdateRequest): Promise => {
return (await apiClient.put(`${operationPath(operationId)}/findings/${encodeURIComponent(findingId)}`, request)).data
},
deleteFinding: async (operationId: string, findingId: string): Promise => {
diff --git a/frontend/src/test-utils/findingDialog.ts b/frontend/src/test-utils/findingDialog.ts
new file mode 100644
index 0000000000..e0c0175f65
--- /dev/null
+++ b/frontend/src/test-utils/findingDialog.ts
@@ -0,0 +1,16 @@
+import { screen, within } from '@testing-library/react'
+import type { UserEvent } from '@testing-library/user-event'
+
+/** Opens a finding field's dropdown by its label and picks the option with this exact name. */
+export async function pick(user: UserEvent, label: string, optionName: string): Promise {
+ await user.click(screen.getByRole('combobox', { name: label }))
+ await user.click(within(await screen.findByRole('listbox')).getByRole('option', { name: optionName }))
+}
+
+// The confirmation's title wraps the key in , so it is found by its Keep it button instead of its name.
+export async function findRemovalConfirmation(): Promise {
+ const dialogs = await screen.findAllByRole('dialog')
+ const confirmation = dialogs.find((dialog: HTMLElement) => within(dialog).queryByRole('button', { name: 'Keep it' }))
+ if (confirmation === undefined) throw new Error('No removal confirmation is open')
+ return confirmation
+}
diff --git a/frontend/src/test-utils/findingFieldsFixtures.ts b/frontend/src/test-utils/findingFieldsFixtures.ts
new file mode 100644
index 0000000000..ce84619c4b
--- /dev/null
+++ b/frontend/src/test-utils/findingFieldsFixtures.ts
@@ -0,0 +1,117 @@
+import type { FindingField, FindingFieldOption, FindingFields } from '@/types'
+
+type FieldDefinition = Pick & Partial
+
+/** Fills in the backend's defaults, so each fixture field has the full JSON shape. */
+function field(definition: FieldDefinition): FindingField {
+ return {
+ required: false,
+ options: [],
+ levels: [],
+ default: null,
+ allow_other: false,
+ colors: {},
+ hint: null,
+ section: null,
+ internal: false,
+ ...definition,
+ }
+}
+
+export function option(key: string, label: string, children: FindingFieldOption[] = []): FindingFieldOption {
+ return { key, label, children }
+}
+
+/** A Contoso deployment's fields: a severity level, a cascade, Other text, a required multiline and an internal field. */
+export const CONTOSO_FINDING_FIELDS: FindingFields = {
+ severity: field({
+ key: 'severity',
+ label: 'Severity',
+ type: 'choice',
+ required: true,
+ options: [
+ option('sev1', 'Sev 1', [option('customer_impact', 'Customer impact')]),
+ option('sev2', 'Sev 2', [option('internal_impact', 'Internal impact')]),
+ ],
+ levels: [['severity_rationale', 'Severity rationale']],
+ colors: { sev1: 'red', sev2: 'yellow' },
+ }),
+ fields: [
+ field({
+ key: 'category',
+ label: 'Category',
+ type: 'choice',
+ required: true,
+ options: [
+ option('model_security', 'Model security', [
+ option('system_prompt_leak', 'System prompt leak'),
+ option('tool_misuse', 'Tool misuse'),
+ ]),
+ option('content_safety', 'Content safety', [
+ option('hate_speech', 'Hate speech'),
+ option('self_harm', 'Self-harm'),
+ ]),
+ ],
+ levels: [['subcategory', 'Subcategory']],
+ section: 'Classification',
+ }),
+ field({
+ key: 'technique',
+ label: 'Technique',
+ type: 'choice',
+ options: [option('prompt_injection', 'Prompt injection'), option('role_play', 'Role play')],
+ allow_other: true,
+ section: 'Reproduction',
+ }),
+ field({
+ key: 'steps',
+ label: 'Steps to reproduce',
+ type: 'multiline',
+ required: true,
+ hint: 'List each prompt you sent to the Contoso assistant, in order.',
+ section: 'Reproduction',
+ }),
+ field({ key: 'owner', label: 'Owner', type: 'text', section: 'Tracking', internal: true }),
+ ],
+}
+
+export function contosoField(key: string): FindingField {
+ const match = CONTOSO_FINDING_FIELDS.fields.find((candidate: FindingField) => candidate.key === key)
+ if (match === undefined) throw new Error(`CONTOSO_FINDING_FIELDS has no field ${key}`)
+ return match
+}
+
+/** A copy of the backend's `DEFAULT_FINDING_FIELDS`, with three harm types instead of the whole taxonomy. */
+export const DEFAULT_FINDING_FIELDS_FIXTURE: FindingFields = {
+ severity: field({
+ key: 'severity',
+ label: 'Severity',
+ type: 'choice',
+ required: true,
+ options: [
+ option('critical', 'Critical'),
+ option('important', 'Important'),
+ option('moderate', 'Moderate'),
+ option('low', 'Low'),
+ option('informational', 'Informational'),
+ ],
+ default: 'moderate',
+ allow_other: true,
+ colors: { critical: 'red', important: 'yellow', moderate: 'yellow', low: 'gray' },
+ }),
+ fields: [
+ field({
+ key: 'harm_type',
+ label: 'Harm-type',
+ type: 'choice',
+ options: [
+ option('Hate Speech', 'Hate Speech'),
+ option('Insecure Code', 'Insecure Code'),
+ option('Malware', 'Malware'),
+ ],
+ allow_other: true,
+ hint: 'Optional. Select a category, or choose Other for custom text.',
+ }),
+ field({ key: 'description', label: 'Description', type: 'multiline', hint: 'Optional' }),
+ ],
+}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 060be99542..cf60cfba9f 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -2303,7 +2303,6 @@ export interface RuntimeStatus {
outcome: string
message: string
}
-export type FindingSeverity = 'critical' | 'important' | 'moderate' | 'low' | 'informational' | 'other'
export interface OperationCreate {
name: string
@@ -2333,7 +2332,8 @@ export interface OperationDetail extends Operation {
}
export interface OperationListItem extends Operation {
- finding_counts: Partial>
+ /** Finding counts by stored severity, including severities that match no current option. */
+ finding_counts: Record
}
export interface OperationListResponse {
@@ -2342,11 +2342,10 @@ export interface OperationListResponse {
export interface FindingCreate {
title: string
- description: string
- severity: FindingSeverity
- severity_other?: string | null
- harm_type?: string | null
- harm_type_other?: string | null
+ /** A severity option key, or Other text. */
+ severity: string
+ /** Values of the other fields and of every level, by key. Severity isn't one of them. */
+ field_values: Record
}
/** Resending the same id and content returns the finding an earlier, unanswered request saved. */
@@ -2356,8 +2355,46 @@ export interface FindingCreateRequest extends FindingCreate {
attack_result_id?: string
}
-export interface FindingOptionsResponse {
- harm_types: string[]
+/** A finding edit. Each key in `remove_retired_keys` drops that retired value from the stored finding. */
+export interface FindingUpdateRequest extends FindingCreate {
+ remove_retired_keys: string[]
+}
+
+export type FindingFieldType = 'text' | 'multiline' | 'choice'
+
+export type FindingSeverityColor = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'gray' | 'dark' | 'neutral'
+
+export interface FindingFieldOption {
+ key: string
+ label: string
+ /** The options of the next dropdown when this option is chosen. */
+ children: FindingFieldOption[]
+}
+
+/** One field of the finding dialog, as the deployment defines it. */
+export interface FindingField {
+ key: string
+ label: string
+ type: FindingFieldType
+ required: boolean
+ options: FindingFieldOption[]
+ /** The `[key, label]` of each dropdown under a cascade's first one. */
+ levels: Array<[string, string]>
+ default: string | null
+ /** Whether a single-level choice also accepts typed text. */
+ allow_other: boolean
+ /** The severity slot's badge color for each top-level option. */
+ colors: Record
+ hint: string | null
+ section: string | null
+ /** Shown on the dashboard but left out of the Word and Markdown exports. */
+ internal: boolean
+}
+
+/** The active finding fields: the severity slot and the fields shown after it. */
+export interface FindingFields {
+ severity: FindingField
+ fields: FindingField[]
}
export interface Finding extends FindingCreate {
diff --git a/frontend/src/utils/findingFields.test.ts b/frontend/src/utils/findingFields.test.ts
new file mode 100644
index 0000000000..07b123b5fc
--- /dev/null
+++ b/frontend/src/utils/findingFields.test.ts
@@ -0,0 +1,157 @@
+import { CONTOSO_FINDING_FIELDS, contosoField, DEFAULT_FINDING_FIELDS_FIXTURE } from '@/test-utils/findingFieldsFixtures'
+import type { FindingFieldOption, FindingFields } from '@/types'
+
+import {
+ choiceLevels,
+ describeFindingValues,
+ fieldKeys,
+ firstMissingRequired,
+ FLUENT_BADGE_COLOR,
+ type FindingValueRow,
+ levelOptions,
+ severityBadge,
+ severityCountBadges,
+} from './findingFields'
+
+function optionKeys(options: FindingFieldOption[]): string[] {
+ return options.map((option: FindingFieldOption) => option.key)
+}
+
+// Field and level keys such as `constructor` pass the backend's key pattern, and option keys can be any text.
+const PROTOTYPE_KEY_FIELDS: FindingFields = {
+ severity: {
+ ...DEFAULT_FINDING_FIELDS_FIXTURE.severity,
+ options: [...DEFAULT_FINDING_FIELDS_FIXTURE.severity.options, { key: 'toString', label: 'To string', children: [] }],
+ },
+ fields: [{ ...contosoField('category'), key: 'constructor', label: 'Constructor' }],
+}
+
+describe('findingFields', () => {
+ it('maps every author color to one Fluent badge color', () => {
+ expect(FLUENT_BADGE_COLOR).toEqual({ red: 'danger', orange: 'severe', yellow: 'warning', green: 'success',
+ blue: 'brand', gray: 'informative', dark: 'important', neutral: 'subtle' })
+ })
+
+ it('gives the default severities their badge colors', () => {
+ expect(['critical', 'important', 'moderate', 'low', 'informational']
+ .map((key: string) => severityBadge(DEFAULT_FINDING_FIELDS_FIXTURE, key).color))
+ .toEqual(['danger', 'warning', 'warning', 'informative', 'subtle'])
+ })
+
+ it('shows an unmatched severity as typed on a neutral badge', () => {
+ expect(severityBadge(CONTOSO_FINDING_FIELDS, 'Sev 2.5')).toEqual({ label: 'Sev 2.5', color: 'subtle' })
+ expect(severityBadge(CONTOSO_FINDING_FIELDS, 'sev1')).toEqual({ label: 'Sev 1', color: 'danger' })
+ })
+
+ it('lists a field with its levels and every key except severity', () => {
+ expect(choiceLevels(contosoField('category'))).toEqual([
+ { key: 'category', label: 'Category' }, { key: 'subcategory', label: 'Subcategory' }])
+ expect(choiceLevels(contosoField('steps'))).toEqual([{ key: 'steps', label: 'Steps to reproduce' }])
+ expect([...fieldKeys(CONTOSO_FINDING_FIELDS)]).toEqual(
+ ['severity_rationale', 'category', 'subcategory', 'technique', 'steps', 'owner'])
+ })
+
+ it('describes filled values in configured order and lists retired keys', () => {
+ const { rows, retired } = describeFindingValues(CONTOSO_FINDING_FIELDS, 'sev1', {
+ owner: 'dana', category: 'model_security', subcategory: 'system_prompt_leak',
+ technique: 'Many-shot priming', description: 'Old text',
+ })
+ expect(rows.map((row: FindingValueRow) => `${row.label}: ${row.value}`)).toEqual([
+ 'Category: Model security', 'Subcategory: System prompt leak', 'Technique: Many-shot priming', 'Owner: dana'])
+ expect(retired).toEqual([{ key: 'description', value: 'Old text' }])
+ })
+
+ it('shows a cascade child that is not under its stored parent as typed', () => {
+ const { rows } = describeFindingValues(CONTOSO_FINDING_FIELDS, 'sev1', {
+ category: 'model_security', subcategory: 'hate_speech',
+ })
+ expect(rows).toEqual([
+ { key: 'category', label: 'Category', value: 'Model security', multiline: false },
+ { key: 'subcategory', label: 'Subcategory', value: 'hate_speech', multiline: false },
+ ])
+ })
+
+ it('labels a severity level from the given severity and skips blank values', () => {
+ const { rows, retired } = describeFindingValues(CONTOSO_FINDING_FIELDS, 'sev1', {
+ severity_rationale: 'customer_impact', owner: ' ', steps: 'Send the Contoso prompt twice.',
+ })
+ expect(rows).toEqual([
+ { key: 'severity_rationale', label: 'Severity rationale', value: 'Customer impact', multiline: false },
+ { key: 'steps', label: 'Steps to reproduce', value: 'Send the Contoso prompt twice.', multiline: true },
+ ])
+ expect(retired).toEqual([])
+ expect(describeFindingValues(CONTOSO_FINDING_FIELDS, 'sev2', { severity_rationale: 'customer_impact' }).rows)
+ .toEqual([{ key: 'severity_rationale', label: 'Severity rationale', value: 'customer_impact', multiline: false }])
+ })
+
+ it('treats a stored severity key as retired and resolves levels under the given severity', () => {
+ const { rows, retired } = describeFindingValues(CONTOSO_FINDING_FIELDS, 'sev1', {
+ severity: 'sev2', severity_rationale: 'customer_impact',
+ })
+ expect(rows).toEqual([
+ { key: 'severity_rationale', label: 'Severity rationale', value: 'Customer impact', multiline: false },
+ ])
+ expect(retired).toEqual([{ key: 'severity', value: 'sev2' }])
+ })
+
+ it('offers each cascade level the options under the chosen parent', () => {
+ const category = contosoField('category')
+ expect(optionKeys(levelOptions(category, {}, 0))).toEqual(['model_security', 'content_safety'])
+ expect(optionKeys(levelOptions(category, { category: 'model_security' }, 1)))
+ .toEqual(['system_prompt_leak', 'tool_misuse'])
+ expect(optionKeys(levelOptions(category, { category: 'content_safety' }, 1))).toEqual(['hate_speech', 'self_harm'])
+ expect(levelOptions(category, {}, 1)).toEqual([])
+ expect(levelOptions(category, { category: 'Prompt injection' }, 1)).toEqual([])
+ })
+
+ it('names the first missing required value', () => {
+ const missingSteps = {
+ severity_rationale: 'customer_impact', category: 'model_security', subcategory: 'tool_misuse', steps: ' ',
+ }
+ expect(firstMissingRequired(CONTOSO_FINDING_FIELDS, 'sev1', missingSteps)).toBe('Steps to reproduce')
+ const missingSubcategory = {
+ ...missingSteps, category: 'content_safety', subcategory: '', steps: 'Send the Contoso prompt twice.',
+ }
+ expect(firstMissingRequired(CONTOSO_FINDING_FIELDS, 'sev1', missingSubcategory)).toBe('Subcategory')
+ expect(firstMissingRequired(CONTOSO_FINDING_FIELDS, 'sev1', { ...missingSubcategory, subcategory: 'self_harm' }))
+ .toBeNull()
+ })
+
+ it('always requires the severity and each of its levels that offers options', () => {
+ const complete = {
+ severity_rationale: 'customer_impact', category: 'model_security', subcategory: 'tool_misuse',
+ steps: 'Send the Contoso prompt twice.',
+ }
+ expect(firstMissingRequired(CONTOSO_FINDING_FIELDS, ' ', complete)).toBe('Severity')
+ expect(firstMissingRequired(CONTOSO_FINDING_FIELDS, 'sev1', { ...complete, severity_rationale: '' }))
+ .toBe('Severity rationale')
+ expect(firstMissingRequired(CONTOSO_FINDING_FIELDS, 'Sev 2.5', { ...complete, severity_rationale: '' })).toBeNull()
+ expect(firstMissingRequired(DEFAULT_FINDING_FIELDS_FIXTURE, 'moderate', {})).toBeNull()
+ })
+
+ it('groups unmatched severity counts into one Other badge with a tooltip', () => {
+ expect(severityCountBadges(DEFAULT_FINDING_FIELDS_FIXTURE, { low: 1, critical: 2, 'Sev 2.5': 1, sev3: 3 })).toEqual([
+ { key: 'critical', label: 'Critical', color: 'danger', count: 2, tooltip: null },
+ { key: 'low', label: 'Low', color: 'informative', count: 1, tooltip: null },
+ { key: 'other', label: 'Other', color: 'subtle', count: 4, tooltip: 'Sev 2.5: 1, sev3: 3' },
+ ])
+ })
+
+ it('returns no Other badge when every count matches an option', () => {
+ expect(severityCountBadges(CONTOSO_FINDING_FIELDS, { sev2: 1, sev1: 0 })).toEqual([
+ { key: 'sev2', label: 'Sev 2', color: 'warning', count: 1, tooltip: null },
+ ])
+ expect(severityCountBadges(CONTOSO_FINDING_FIELDS, {})).toEqual([])
+ })
+
+ it('ignores inherited members when a key names an Object.prototype member', () => {
+ const constructorField = PROTOTYPE_KEY_FIELDS.fields[0]
+ expect(severityBadge(PROTOTYPE_KEY_FIELDS, 'toString')).toEqual({ label: 'To string', color: 'subtle' })
+ expect(levelOptions(constructorField, {}, 1)).toEqual([])
+ expect(describeFindingValues(PROTOTYPE_KEY_FIELDS, 'toString', {})).toEqual({ rows: [], retired: [] })
+ expect(firstMissingRequired(PROTOTYPE_KEY_FIELDS, 'toString', {})).toBe('Constructor')
+ expect(severityCountBadges(PROTOTYPE_KEY_FIELDS, { low: 1 })).toEqual([
+ { key: 'low', label: 'Low', color: 'informative', count: 1, tooltip: null },
+ ])
+ })
+})
diff --git a/frontend/src/utils/findingFields.ts b/frontend/src/utils/findingFields.ts
new file mode 100644
index 0000000000..f93fc35799
--- /dev/null
+++ b/frontend/src/utils/findingFields.ts
@@ -0,0 +1,205 @@
+import type { BadgeProps } from '@fluentui/react-components'
+
+import type { FindingField, FindingFieldOption, FindingFields, FindingSeverityColor } from '@/types'
+
+export type BadgeColor = NonNullable
+
+export const FLUENT_BADGE_COLOR: Record = {
+ red: 'danger',
+ orange: 'severe',
+ yellow: 'warning',
+ green: 'success',
+ blue: 'brand',
+ gray: 'informative',
+ dark: 'important',
+ neutral: 'subtle',
+}
+
+/** The key under which the severity slot's own value travels with the other values. */
+export const SEVERITY_KEY = 'severity'
+
+export interface FindingValueRow {
+ key: string
+ label: string
+ /** The option label of a matching choice value, otherwise the stored text. */
+ value: string
+ multiline: boolean
+}
+
+export interface RetiredValue {
+ key: string
+ value: string
+}
+
+export interface SeverityCountBadge {
+ key: string
+ label: string
+ color: BadgeColor
+ count: number
+ /** The `value: count` of each unmatched severity, on the Other badge only. */
+ tooltip: string | null
+}
+
+const OTHER_SEVERITY_BADGE: Pick = {
+ key: 'other',
+ label: 'Other',
+ color: FLUENT_BADGE_COLOR.neutral,
+}
+
+/** The value, or null when it is missing or blank. */
+export function filled(value: string | undefined): string | null {
+ return value !== undefined && value.trim() !== '' ? value : null
+}
+
+/** The value stored under a configured key, ignoring `Object.prototype` members such as `constructor`. */
+export function ownValue(record: Readonly>, key: string): T | undefined {
+ return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined
+}
+
+export function findOption(options: FindingFieldOption[], key: string | null): FindingFieldOption | undefined {
+ return options.find((option: FindingFieldOption) => option.key === key)
+}
+
+function allFields(fields: FindingFields): FindingField[] {
+ return [fields.severity, ...fields.fields]
+}
+
+/** The severity's option label and badge color, or the stored text on a neutral badge when no option matches. */
+export function severityBadge(fields: FindingFields, severity: string): { label: string; color: BadgeColor } {
+ const match = findOption(fields.severity.options, severity)
+ if (match === undefined) return { label: severity, color: FLUENT_BADGE_COLOR.neutral }
+ return { label: match.label, color: FLUENT_BADGE_COLOR[ownValue(fields.severity.colors, severity) ?? 'neutral'] }
+}
+
+/** The field itself, then each of its levels: one entry per dropdown of a choice field. */
+export function choiceLevels(field: FindingField): Array<{ key: string; label: string }> {
+ return [
+ { key: field.key, label: field.label },
+ ...field.levels.map(([key, label]: [string, string]) => ({ key, label })),
+ ]
+}
+
+/**
+ * The options a choice field's dropdown offers under the values chosen above it.
+ * @param values - Values by key. The severity slot's own value goes under `severity`.
+ * @param depth - 0 for the field's own dropdown, 1 for its first level, and so on.
+ * @returns No options when a value above is missing or isn't an option.
+ */
+export function levelOptions(field: FindingField, values: Record, depth: number): FindingFieldOption[] {
+ let options = field.options
+ for (const level of choiceLevels(field).slice(0, depth)) {
+ const match = findOption(options, filled(ownValue(values, level.key)))
+ if (match === undefined) return []
+ options = match.children
+ }
+ return options
+}
+
+/** The key of every field and level, except `severity`, which isn't stored in `field_values`. */
+export function fieldKeys(fields: FindingFields): Set {
+ const keys = new Set()
+ for (const field of allFields(fields)) {
+ for (const level of choiceLevels(field)) keys.add(level.key)
+ }
+ keys.delete(SEVERITY_KEY)
+ return keys
+}
+
+/** Sets apart the stored values whose keys the active fields no longer define, keeping their stored order. */
+export function splitRetiredValues(
+ fields: FindingFields,
+ fieldValues: Record,
+): { current: Record; retired: RetiredValue[] } {
+ const known = fieldKeys(fields)
+ const current: Record = {}
+ const retired: RetiredValue[] = []
+ for (const [key, value] of Object.entries(fieldValues)) {
+ if (known.has(key)) current[key] = value
+ else retired.push({ key, value })
+ }
+ return { current, retired }
+}
+
+function displayValue(field: FindingField, values: Record, depth: number, value: string): string {
+ if (field.type !== 'choice') return value
+ return findOption(levelOptions(field, values, depth), value)?.label ?? value
+}
+
+/**
+ * Lists a finding's filled values in configured order, and its retired values in stored order.
+ * The severity itself isn't a row; it is the parent path of the severity slot's levels.
+ */
+export function describeFindingValues(
+ fields: FindingFields,
+ severity: string,
+ fieldValues: Record,
+): { rows: FindingValueRow[]; retired: RetiredValue[] } {
+ const values: Record = { ...fieldValues, [SEVERITY_KEY]: severity }
+ const rows: FindingValueRow[] = []
+ for (const field of allFields(fields)) {
+ for (const [depth, level] of choiceLevels(field).entries()) {
+ if (field === fields.severity && depth === 0) continue
+ const value = filled(ownValue(fieldValues, level.key))
+ if (value === null) continue
+ rows.push({
+ key: level.key,
+ label: level.label,
+ value: displayValue(field, values, depth, value),
+ multiline: field.type === 'multiline',
+ })
+ }
+ }
+ return { rows, retired: splitRetiredValues(fields, fieldValues).retired }
+}
+
+function firstMissingLevel(field: FindingField, values: Record): string | null {
+ for (const [depth, level] of choiceLevels(field).entries()) {
+ if (levelOptions(field, values, depth).length === 0) return null
+ if (filled(ownValue(values, level.key)) === null) return level.label
+ }
+ return null
+}
+
+/**
+ * The label of the first missing required value, as the backend checks them: the severity always,
+ * a required text field when blank, and each level of a required choice that offers options under
+ * the chosen parent.
+ */
+export function firstMissingRequired(
+ fields: FindingFields,
+ severity: string,
+ fieldValues: Record,
+): string | null {
+ const values: Record = { ...fieldValues, [SEVERITY_KEY]: severity }
+ for (const field of allFields(fields)) {
+ if (field !== fields.severity && !field.required) continue
+ if (field.type === 'choice') {
+ const missing = firstMissingLevel(field, values)
+ if (missing !== null) return missing
+ } else if (filled(ownValue(values, field.key)) === null) {
+ return field.label
+ }
+ }
+ return null
+}
+
+/** One badge per counted severity option, in option order, then one Other badge for every unmatched severity. */
+export function severityCountBadges(fields: FindingFields, counts: Record): SeverityCountBadge[] {
+ const badges: SeverityCountBadge[] = []
+ for (const option of fields.severity.options) {
+ const count = ownValue(counts, option.key) ?? 0
+ if (count > 0) badges.push({ key: option.key, ...severityBadge(fields, option.key), count, tooltip: null })
+ }
+ const optionKeys = new Set(fields.severity.options.map((option: FindingFieldOption) => option.key))
+ const unmatched = Object.entries(counts)
+ .filter(([severity, count]: [string, number]) => count > 0 && !optionKeys.has(severity))
+ .sort(([left]: [string, number], [right]: [string, number]) => left.localeCompare(right))
+ if (unmatched.length > 0) {
+ badges.push({
+ ...OTHER_SEVERITY_BADGE,
+ count: unmatched.reduce((total: number, [, count]: [string, number]) => total + count, 0),
+ tooltip: unmatched.map(([severity, count]: [string, number]) => `${severity}: ${count}`).join(', '),
+ })
+ }
+ return badges
+}
diff --git a/frontend/src/utils/findingSeverity.test.ts b/frontend/src/utils/findingSeverity.test.ts
deleted file mode 100644
index 47463f8341..0000000000
--- a/frontend/src/utils/findingSeverity.test.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { findingSeverityColor, findingSeverityLabel, FINDING_SEVERITY_LABELS } from './findingSeverity'
-import type { FindingSeverity } from '@/types'
-
-it.each<[FindingSeverity, string, string]>([
- ['critical', 'Critical', 'danger'],
- ['important', 'Important', 'warning'],
- ['moderate', 'Moderate', 'warning'],
- ['low', 'Low', 'informative'],
- ['informational', 'Informational', 'subtle'],
- ['other', 'Other', 'subtle'],
-])('uses the Operations label and badge color for %s', (severity, label, color) => {
- expect(FINDING_SEVERITY_LABELS[severity]).toBe(label)
- expect(findingSeverityColor(severity)).toBe(color)
-})
-
-it('shows the custom assessment as a neutral badge instead of assigning its text a preset priority', () => {
- expect(findingSeverityLabel({ severity: 'other', severity_other: 'Critical' })).toBe('Critical')
- expect(findingSeverityColor('other')).toBe('subtle')
- expect(findingSeverityLabel({ severity: 'low' })).toBe('Low')
-})
diff --git a/frontend/src/utils/findingSeverity.ts b/frontend/src/utils/findingSeverity.ts
deleted file mode 100644
index fd2babd206..0000000000
--- a/frontend/src/utils/findingSeverity.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { FindingCreate, FindingSeverity } from '@/types'
-
-export const FINDING_SEVERITY_LABELS: Record = {
- critical: 'Critical', important: 'Important', moderate: 'Moderate',
- low: 'Low', informational: 'Informational', other: 'Other',
-}
-
-export function findingSeverityLabel(finding: Pick): string {
- return finding.severity === 'other' ? finding.severity_other ?? FINDING_SEVERITY_LABELS.other : FINDING_SEVERITY_LABELS[finding.severity]
-}
-
-export function findingSeverityColor(severity: FindingSeverity): 'danger' | 'warning' | 'informative' | 'subtle' {
- if (severity === 'critical') return 'danger'
- if (severity === 'important' || severity === 'moderate') return 'warning'
- return severity === 'informational' || severity === 'other' ? 'subtle' : 'informative'
-}
diff --git a/pyrit/backend/models/operations.py b/pyrit/backend/models/operations.py
index 727b13f081..7b16f088c6 100644
--- a/pyrit/backend/models/operations.py
+++ b/pyrit/backend/models/operations.py
@@ -7,29 +7,22 @@
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, field_validator
from pyrit.backend.models.attacks import ScoreView
+from pyrit.backend.models.common import MAX_ITEMS, IdentifierStr, TextStr
from pyrit.models import (
AttackOutcome,
Finding,
FindingCreate,
FindingEvidence,
- FindingSeverity,
Operation,
OperationUsage,
)
-from pyrit.models.harm_category import HarmCategory
from pyrit.models.operation import OperationName
-class FindingOptionsResponse(BaseModel):
- """Harm categories a finding can use."""
-
- harm_types: list[HarmCategory]
-
-
class OperationListItem(Operation):
- """An operation with its finding counts by severity. Severities with no findings are omitted."""
+ """An operation with its finding counts by stored severity value. Severities with no findings are omitted."""
- finding_counts: dict[FindingSeverity, int]
+ finding_counts: dict[str, int]
class OperationListResponse(BaseModel):
@@ -67,17 +60,40 @@ class FindingListResponse(BaseModel):
next_offset: int | None
-class FindingCreateRequest(FindingCreate):
+class _FindingRequestFields(FindingCreate):
+ """A finding's values as a request carries them, within the request limits."""
+
+ title: TextStr
+ severity: TextStr = Field(
+ description="The key of a severity option, or Other text; surrounding whitespace is removed"
+ )
+ field_values: dict[IdentifierStr, TextStr] = Field(
+ default_factory=dict,
+ max_length=MAX_ITEMS,
+ description="Values by field or level key, each an option's key or text; blank values are dropped",
+ )
+
+
+class FindingCreateRequest(_FindingRequestFields):
"""
A finding to record, optionally with one saved attack result as evidence.
- Sending the same ``id`` and content again returns the saved finding, so a client can retry safely.
+ Sending the same ``id`` and content again returns the saved finding, as long as it still fits the active finding
+ fields, so a client can retry safely.
"""
id: UUID | None = None
attack_result_id: UUID | None = Field(None, description="A saved attack result to link as evidence")
+class FindingUpdateRequest(_FindingRequestFields):
+ """A finding's new editable values, and any retired values to remove."""
+
+ remove_retired_keys: list[IdentifierStr] = Field(
+ default_factory=list, max_length=MAX_ITEMS, description="Keys of the retired values to delete from the finding"
+ )
+
+
class FindingEvidenceCreateRequest(BaseModel):
"""The saved attack result to link."""
diff --git a/pyrit/backend/routes/operations.py b/pyrit/backend/routes/operations.py
index 69894dcab3..510ea80e3a 100644
--- a/pyrit/backend/routes/operations.py
+++ b/pyrit/backend/routes/operations.py
@@ -14,7 +14,7 @@
FindingEvidenceListResponse,
FindingExportRequest,
FindingListResponse,
- FindingOptionsResponse,
+ FindingUpdateRequest,
OperationDetail,
OperationListResponse,
OperationUpdateRequest,
@@ -29,8 +29,8 @@
OperationService,
)
from pyrit.memory.memory_interface import OperationInUseError
-from pyrit.models import Finding, FindingCreate, Operation, OperationCreate
-from pyrit.models.harm_category import HarmCategory
+from pyrit.models import Finding, FindingFields, FindingValuesError, Operation, OperationCreate
+from pyrit.setup.finding_fields import get_active_finding_fields
T = TypeVar("T")
@@ -65,15 +65,15 @@ async def list_operations_async() -> OperationListResponse:
return OperationListResponse(items=await OperationService().list_async())
-@router.get("/finding-options", response_model=FindingOptionsResponse)
-async def get_finding_options_async() -> FindingOptionsResponse:
+@router.get("/finding-fields", response_model=FindingFields)
+async def get_finding_fields_async() -> FindingFields:
"""
- List the harm categories a finding can use.
+ Read the finding fields this deployment uses.
Returns:
- FindingOptionsResponse: Selectable categories, including Other.
+ FindingFields: The severity slot and the fields shown after it.
"""
- return FindingOptionsResponse(harm_types=list(HarmCategory))
+ return get_active_finding_fields()
@router.get("/{operation_id}", response_model=OperationDetail)
@@ -131,13 +131,14 @@ async def create_operation_finding_async(*, operation_id: UUID, request: Finding
"""
Record a finding within an operation, optionally with one saved attack result as evidence.
- Retrying with the same ID and content returns the saved finding.
+ Retrying with the same ID and content returns the saved finding, as long as it still fits the active finding fields.
Returns:
Finding: The saved finding.
Raises:
- HTTPException: 409 when the ID already belongs to a different finding.
+ HTTPException: 409 when the ID already belongs to a different finding, and 422 when the values
+ do not fit the active finding fields.
"""
try:
return await _require_operation_async(
@@ -145,6 +146,8 @@ async def create_operation_finding_async(*, operation_id: UUID, request: Finding
)
except FindingConflictError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
+ except FindingValuesError as exc:
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.get("/{operation_id}/findings", response_model=FindingListResponse)
@@ -185,16 +188,28 @@ async def export_operation_findings_async(*, operation_id: UUID, request: Findin
@router.put("/{operation_id}/findings/{finding_id}", response_model=Finding)
-async def update_operation_finding_async(*, operation_id: UUID, finding_id: UUID, request: FindingCreate) -> Finding:
+async def update_operation_finding_async(
+ *, operation_id: UUID, finding_id: UUID, request: FindingUpdateRequest
+) -> Finding:
"""
- Replace the editable fields of a finding in its operation.
+ Replace a finding's current field values in its operation.
+
+ A current field left out is cleared. Values of fields that are no longer configured are kept and can't
+ change; list them in ``remove_retired_keys`` to delete them. Values that haven't changed may use options
+ that no longer exist.
Returns:
Finding: The updated finding.
+
+ Raises:
+ HTTPException: 422 when the values do not fit the active finding fields.
"""
- return await _require_operation_async(
- OperationService().update_finding_async(operation_id=operation_id, finding_id=finding_id, request=request)
- )
+ try:
+ return await _require_operation_async(
+ OperationService().update_finding_async(operation_id=operation_id, finding_id=finding_id, request=request)
+ )
+ except FindingValuesError as exc:
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.delete("/{operation_id}/findings/{finding_id}", status_code=status.HTTP_204_NO_CONTENT)
diff --git a/pyrit/backend/services/finding_export.py b/pyrit/backend/services/finding_export.py
index efefdcdcd7..75cadba20e 100644
--- a/pyrit/backend/services/finding_export.py
+++ b/pyrit/backend/services/finding_export.py
@@ -29,8 +29,7 @@
FindingExportRequest,
)
from pyrit.backend.services.operation_service import OperationService
-from pyrit.models import Finding, FindingSeverity, ScoreStatus
-from pyrit.models.harm_category import HarmCategory
+from pyrit.models import Finding, FindingFields, ScoreStatus
from pyrit.output.finding_report.markdown import MarkdownFindingReportPrinter
from pyrit.output.finding_report.report import (
DETAIL_SEPARATOR,
@@ -44,6 +43,7 @@
FindingReportField,
plain_text,
)
+from pyrit.setup.finding_fields import get_active_finding_fields
_MARKDOWN_MEDIA_TYPE = "text/markdown; charset=utf-8"
_DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
@@ -113,14 +113,15 @@ async def _build_report_async(*, operation_id: UUID, request: FindingExportReque
selected = await service.get_findings_with_evidence_async(
operation_id=operation_id, finding_ids=request.finding_ids
)
+ fields = get_active_finding_fields()
report_findings = []
for finding, evidence in selected:
- severity, fields = _project_finding_fields(finding)
+ severity, projected = _project_finding_fields(finding, fields=fields)
report_findings.append(
FindingReportEntry(
title=finding.title,
severity=severity,
- fields=fields,
+ fields=projected,
evidence=tuple(
_project_evidence(item=item, operation_id=operation_id, dashboard_origin=request.dashboard_origin)
for item in evidence
@@ -130,28 +131,29 @@ async def _build_report_async(*, operation_id: UUID, request: FindingExportReque
return FindingReport(operation_name=operation.name, findings=tuple(report_findings))
-def _project_finding_fields(finding: Finding) -> tuple[FindingReportField, tuple[FindingReportField, ...]]:
+def _project_finding_fields(
+ finding: Finding, *, fields: FindingFields
+) -> tuple[FindingReportField, tuple[FindingReportField, ...]]:
"""
- Label a finding's severity and its populated fields, in report order, and place each in a Word column.
+ Label a finding's severity and its filled fields, in configured order, and place each in a Word column.
- Narrative text goes in the Finding column and classifications in the Assessment column.
+ Multiline text goes in the Finding column; choices and single-line text go in the Assessment column.
+ Internal fields and retired values are left out.
Returns:
- tuple[FindingReportField, tuple[FindingReportField, ...]]: The severity, then the populated fields.
+ tuple[FindingReportField, tuple[FindingReportField, ...]]: The severity, then the filled fields.
"""
- severity = (
- finding.severity_other or "Other"
- if finding.severity == FindingSeverity.OTHER
- else finding.severity.value.capitalize()
+ description = fields.describe_values(severity=finding.severity, field_values=finding.field_values)
+ projected = tuple(
+ FindingReportField(
+ label=row.label,
+ value=row.value,
+ column=FindingReportColumn.FINDING if row.multiline else FindingReportColumn.ASSESSMENT,
+ )
+ for row in description.rows
+ if not row.internal
)
- harm_type = finding.harm_type_other if finding.harm_type == HarmCategory.OTHER else finding.harm_type
- fields = [
- FindingReportField(label="Harm-type", value=str(harm_type), column=FindingReportColumn.ASSESSMENT)
- if harm_type
- else None,
- FindingReportField(label="Description", value=finding.description) if finding.description else None,
- ]
- return FindingReportField(label="Severity", value=severity), tuple(field for field in fields if field)
+ return FindingReportField(label=fields.severity.label, value=description.severity_label), projected
def _project_evidence(
diff --git a/pyrit/backend/services/operation_service.py b/pyrit/backend/services/operation_service.py
index 8fa55438f0..4296e26f71 100644
--- a/pyrit/backend/services/operation_service.py
+++ b/pyrit/backend/services/operation_service.py
@@ -14,6 +14,7 @@
FindingEvidenceListResponse,
FindingListItem,
FindingListResponse,
+ FindingUpdateRequest,
OperationDetail,
OperationListItem,
OperationUpdateRequest,
@@ -25,9 +26,12 @@
Finding,
FindingCreate,
FindingEvidence,
+ FindingFields,
+ FindingValuesError,
Operation,
OperationCreate,
)
+from pyrit.setup.finding_fields import get_active_finding_fields
# Keeps every ID list a batch reads within SQL Server's 2100-parameter limit.
_EVIDENCE_BATCH_SIZE = 500
@@ -162,8 +166,10 @@ async def create_finding_async(self, *, operation_id: UUID, request: FindingCrea
Raises:
FindingConflictError: If the ID already belongs to a different finding.
+ FindingValuesError: If the values do not fit the active finding fields.
"""
await self.get_async(operation_id)
+ get_active_finding_fields().check_values(severity=request.severity, field_values=request.field_values)
finding = Finding(
operation_id=operation_id,
id=request.id or uuid4(),
@@ -196,7 +202,11 @@ async def list_findings_async(
"""
await self.get_async(operation_id)
findings = await CentralMemory.get_memory_instance().get_findings_async(
- operation_id=operation_id, limit=limit + 1, offset=offset, title_query=title_query
+ operation_id=operation_id,
+ limit=limit + 1,
+ offset=offset,
+ title_query=title_query,
+ severity_order=get_active_finding_fields().severity_order,
)
has_more = len(findings) > limit
page = findings[:limit]
@@ -269,7 +279,11 @@ async def get_findings_with_evidence_async(
"""
await self.get_async(operation_id)
memory = CentralMemory.get_memory_instance()
- findings = await memory.get_findings_by_ids_async(operation_id=operation_id, finding_ids=finding_ids)
+ findings = await memory.get_findings_by_ids_async(
+ operation_id=operation_id,
+ finding_ids=finding_ids,
+ severity_order=get_active_finding_fields().severity_order,
+ )
found = {finding.id for finding in findings}
missing = next((finding_id for finding_id in finding_ids if finding_id not in found), None)
if missing is not None:
@@ -422,24 +436,65 @@ async def _require_evidence_source_async(*, attack_result_id: UUID) -> None:
if not any(stat.message_count > 0 for stat in stats.values()):
raise FindingEvidenceNotFoundError("The attack result has no saved messages.")
- async def update_finding_async(self, *, operation_id: UUID, finding_id: UUID, request: FindingCreate) -> Finding:
+ async def update_finding_async(
+ self, *, operation_id: UUID, finding_id: UUID, request: FindingUpdateRequest
+ ) -> Finding:
"""
- Replace a finding's editable fields within an existing operation.
+ Replace a finding's current field values within an existing operation.
+
+ A current field left out is cleared. Values of fields that are no longer configured are kept and can't
+ change; list them in ``remove_retired_keys`` to delete them. Values that haven't changed may use options
+ that no longer exist.
Returns:
Finding: The updated finding.
Raises:
FindingNotFoundError: If the finding is missing or belongs to another operation.
+ FindingValuesError: If ``remove_retired_keys`` lists a key that isn't a retired value of the finding
+ or that is also sent, or the values do not fit the active finding fields.
"""
await self.get_async(operation_id)
+ stored = await self._require_finding_async(operation_id=operation_id, finding_id=finding_id)
+ fields = get_active_finding_fields()
+ values = self._merge_retired_values(fields=fields, stored=stored, request=request)
+ fields.check_values(
+ severity=request.severity,
+ field_values=values,
+ previous_severity=stored.severity,
+ previous_values=stored.field_values,
+ )
+ replacement = FindingCreate(title=request.title, severity=request.severity, field_values=values)
try:
return await CentralMemory.get_memory_instance().update_finding_async(
- operation_id=operation_id, finding_id=finding_id, request=request
+ operation_id=operation_id, finding_id=finding_id, request=replacement
)
except LookupError as exc:
raise FindingNotFoundError(str(exc)) from exc
+ @staticmethod
+ def _merge_retired_values(
+ *, fields: FindingFields, stored: Finding, request: FindingUpdateRequest
+ ) -> dict[str, str]:
+ """
+ Add the stored values of fields that are no longer configured to the sent values.
+
+ Returns:
+ dict[str, str]: The sent values, plus each retired value not listed in ``remove_retired_keys``.
+
+ Raises:
+ FindingValuesError: If ``remove_retired_keys`` lists a key that isn't a retired value of the finding
+ or that is also sent.
+ """
+ retired = dict(fields.describe_values(severity=stored.severity, field_values=stored.field_values).retired)
+ for key in request.remove_retired_keys:
+ if key not in retired:
+ raise FindingValuesError(f"{key!r} is not a retired value of this finding.")
+ if key in request.field_values:
+ raise FindingValuesError(f"{key!r} can't be both sent and removed.")
+ kept = {key: value for key, value in retired.items() if key not in request.remove_retired_keys}
+ return {**kept, **request.field_values}
+
async def delete_finding_async(self, *, operation_id: UUID, finding_id: UUID) -> None:
"""
Permanently remove a finding within an existing operation.
diff --git a/pyrit/memory/alembic/versions/d43b1bcf9c56_store_finding_field_values.py b/pyrit/memory/alembic/versions/d43b1bcf9c56_store_finding_field_values.py
new file mode 100644
index 0000000000..49f63af9bb
--- /dev/null
+++ b/pyrit/memory/alembic/versions/d43b1bcf9c56_store_finding_field_values.py
@@ -0,0 +1,123 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Store each finding as a severity plus a JSON object of field values.
+
+Drops the three classification CHECK constraints on ``FindingEntries``, makes ``severity`` unbounded Unicode
+(binary-collated on SQL Server), replaces an Other severity with its custom text without surrounding whitespace, and
+moves the harm type (its custom text when it is Other) and any description that isn't empty or only spaces into the
+new ``field_values`` column. Finding IDs, Operation links, creation times, and evidence links do not change.
+Downgrading restores the old columns only while no findings exist.
+
+Revision ID: d43b1bcf9c56
+Revises: e7c4a2d9f013
+Create Date: 2026-10-27 12:00:00.000000
+"""
+
+import logging
+from collections.abc import Sequence # noqa: TC003
+
+import sqlalchemy as sa
+from alembic import op
+
+from pyrit.memory.memory_models import CustomUUID
+
+# revision identifiers, used by Alembic.
+revision: str = "d43b1bcf9c56"
+down_revision: str | None = "e7c4a2d9f013"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+logger = logging.getLogger(__name__)
+
+_SEVERITY_TYPE = sa.Unicode().with_variant(sa.Unicode(collation="Latin1_General_100_BIN2"), "mssql")
+_HARM_TYPE_SQL = "CASE WHEN harm_type = 'Other' THEN harm_type_other ELSE harm_type END"
+_DESCRIPTION_SQL = "CASE WHEN LTRIM(RTRIM(description)) = '' THEN NULL ELSE description END"
+_OLD_COLUMNS = ("description", "severity_other", "harm_type", "harm_type_other")
+_CHECKS = {
+ "ck_findings_severity": "severity IN ('critical', 'important', 'moderate', 'low', 'informational', 'other')",
+ "ck_findings_severity_other": (
+ "(severity = 'other' AND severity_other IS NOT NULL AND LTRIM(RTRIM(severity_other)) <> '') "
+ "OR (severity <> 'other' AND severity_other IS NULL)"
+ ),
+ "ck_findings_harm_type_other": (
+ "(harm_type IS NOT NULL AND harm_type = 'Other' AND harm_type_other IS NOT NULL "
+ "AND LTRIM(RTRIM(harm_type_other)) <> '') "
+ "OR ((harm_type IS NULL OR harm_type <> 'Other') AND harm_type_other IS NULL)"
+ ),
+}
+
+
+def upgrade() -> None:
+ """Drop the classification checks and move each finding's values into ``severity`` and ``field_values``."""
+ # SQL Server rejects ALTER COLUMN while a CHECK references the column, so the checks go first.
+ with op.batch_alter_table("FindingEntries") as batch_op:
+ for name in _CHECKS:
+ batch_op.drop_constraint(name, type_="check")
+ batch_op.add_column(sa.Column("field_values", sa.JSON(), nullable=True))
+ batch_op.alter_column("severity", existing_type=sa.String(32), type_=_SEVERITY_TYPE, existing_nullable=False)
+ op.execute(sa.text("UPDATE FindingEntries SET severity = severity_other WHERE LOWER(severity) = 'other'"))
+ _strip_custom_severities()
+ op.execute(sa.text(f"UPDATE FindingEntries SET field_values = {_field_values_sql()}"))
+ with op.batch_alter_table("FindingEntries") as batch_op:
+ batch_op.alter_column("field_values", existing_type=sa.JSON(), nullable=False)
+ for column in _OLD_COLUMNS:
+ batch_op.drop_column(column)
+
+
+def _strip_custom_severities() -> None:
+ """
+ Remove the surrounding whitespace from each severity copied from Other text, as new findings have it removed.
+
+ SQL trimming keeps tabs, line breaks and non-breaking spaces, so Python strips the text instead.
+ """
+ if op.get_context().as_sql:
+ logger.warning("Skipping custom severity trimming: offline SQL generation cannot read existing findings.")
+ return
+ bind = op.get_bind()
+ findings = sa.table(
+ "FindingEntries",
+ sa.column("id", CustomUUID()),
+ sa.column("severity", sa.Unicode()),
+ sa.column("severity_other", sa.Unicode()),
+ )
+ custom = sa.select(findings.c.id, findings.c.severity).where(findings.c.severity_other.is_not(None))
+ for finding_id, severity in bind.execute(custom).all():
+ if severity != severity.strip():
+ bind.execute(findings.update().where(findings.c.id == finding_id).values(severity=severity.strip()))
+
+
+def _field_values_sql() -> str:
+ """
+ Build the dialect's SQL expression for a JSON object of harm type and description, leaving out NULL values.
+
+ Returns:
+ str: The SQL expression.
+ """
+ if op.get_context().dialect.name == "mssql":
+ return (
+ f"JSON_MODIFY(JSON_MODIFY(N'{{}}', '$.harm_type', {_HARM_TYPE_SQL}), '$.description', {_DESCRIPTION_SQL})"
+ )
+ return f"json_patch('{{}}', json_object('harm_type', {_HARM_TYPE_SQL}, 'description', {_DESCRIPTION_SQL}))"
+
+
+def downgrade() -> None:
+ """
+ Restore the classification columns and checks only when no findings exist.
+
+ Raises:
+ ValueError: If the downgrade is offline or findings would be lost.
+ """
+ if op.get_context().as_sql:
+ raise ValueError("Finding downgrade requires online data validation.")
+ if op.get_bind().execute(sa.text("SELECT COUNT(*) FROM FindingEntries")).scalar():
+ raise ValueError("Remove retained findings explicitly before downgrading.")
+ with op.batch_alter_table("FindingEntries") as batch_op:
+ batch_op.add_column(sa.Column("description", sa.Unicode(), nullable=False))
+ for column in _OLD_COLUMNS[1:]:
+ batch_op.add_column(sa.Column(column, sa.Unicode(), nullable=True))
+ batch_op.alter_column("severity", existing_type=_SEVERITY_TYPE, type_=sa.String(32), existing_nullable=False)
+ batch_op.drop_column("field_values")
+ for name, condition in _CHECKS.items():
+ batch_op.create_check_constraint(name, condition)
diff --git a/pyrit/memory/memory_interface.py b/pyrit/memory/memory_interface.py
index b05add68b7..8faf84a636 100644
--- a/pyrit/memory/memory_interface.py
+++ b/pyrit/memory/memory_interface.py
@@ -121,7 +121,6 @@
Finding,
FindingCreate,
FindingEvidence,
- FindingSeverity,
IdentifierFilter,
IdentifierType,
Message,
@@ -1045,9 +1044,20 @@ async def get_findings_async(
limit: int = 20,
offset: int = 0,
title_query: str | None = None,
+ severity_order: Sequence[str] = (),
) -> list[Finding]:
"""
- Read one page of findings, highest severity first, then newest first.
+ Read one page of findings in ``severity_order``, then newest first.
+
+ Args:
+ operation_id (uuid.UUID | None): The operation whose findings to read. Defaults to None, which reads
+ findings from every operation.
+ limit (int): Maximum number of findings to return. Defaults to 20.
+ offset (int): Number of findings to skip. Defaults to 0.
+ title_query (str | None): Case-insensitive text the title must contain. Wildcard characters match
+ literally. Defaults to None.
+ severity_order (Sequence[str]): Severity keys from highest to lowest. Stored values that aren't listed
+ sort after them. Defaults to an empty order, which sorts newest first.
Returns:
list[Finding]: The findings on the page.
@@ -1064,18 +1074,24 @@ async def get_findings_async(
# SQL Server also treats "[" as a LIKE wildcard.
escaped_title = title_query.replace("/", "//").replace("%", "/%").replace("_", "/_").replace("[", "/[")
statement = statement.where(FindingEntry.title.ilike(f"%{escaped_title}%", escape="/"))
- statement = statement.order_by(*self._finding_order()).limit(limit).offset(offset)
+ statement = statement.order_by(*self._finding_order(severity_order)).limit(limit).offset(offset)
async with await self.get_session_async() as session:
return [entry.get_finding() for entry in (await session.scalars(statement)).all()]
async def get_findings_by_ids_async(
- self, *, operation_id: uuid.UUID, finding_ids: Sequence[uuid.UUID]
+ self, *, operation_id: uuid.UUID, finding_ids: Sequence[uuid.UUID], severity_order: Sequence[str] = ()
) -> list[Finding]:
"""
- Read selected findings of one operation, highest severity first, then newest first.
+ Read selected findings of one operation in ``severity_order``, then newest first.
IDs that are not in the operation are omitted.
+ Args:
+ operation_id (uuid.UUID): The operation whose findings to read.
+ finding_ids (Sequence[uuid.UUID]): The findings to read.
+ severity_order (Sequence[str]): Severity keys from highest to lowest, as for
+ ``get_findings_async``. Defaults to an empty order, which sorts newest first.
+
Returns:
list[Finding]: The selected findings, in the same order as ``get_findings_async``.
"""
@@ -1088,7 +1104,7 @@ async def get_findings_by_ids_async(
ordered_ids = await session.scalars(
select(FindingEntry.id)
.where(FindingEntry.operation_id == operation_id)
- .order_by(*self._finding_order())
+ .order_by(*self._finding_order(severity_order))
)
selected_ids = [finding_id for finding_id in ordered_ids if finding_id in requested]
findings: dict[uuid.UUID, Finding] = {}
@@ -1252,29 +1268,34 @@ async def get_finding_evidence_counts_async(self, *, finding_ids: Sequence[uuid.
async with await self.get_session_async() as session:
return dict((await session.execute(statement)).all())
- async def get_finding_severity_counts_async(self) -> dict[uuid.UUID, dict[FindingSeverity, int]]:
+ async def get_finding_severity_counts_async(self) -> dict[uuid.UUID, dict[str, int]]:
"""
Count every operation's findings by severity in one query.
Returns:
- dict[uuid.UUID, dict[FindingSeverity, int]]: Counts for operations with findings.
+ dict[uuid.UUID, dict[str, int]]: Counts for operations with findings.
"""
statement = select(FindingEntry.operation_id, FindingEntry.severity, func.count()).group_by(
FindingEntry.operation_id, FindingEntry.severity
)
- counts: dict[uuid.UUID, dict[FindingSeverity, int]] = {}
+ counts: dict[uuid.UUID, dict[str, int]] = {}
async with await self.get_session_async() as session:
for operation_id, severity, count in (await session.execute(statement)).all():
- counts.setdefault(operation_id, {})[FindingSeverity(severity)] = count
+ counts.setdefault(operation_id, {})[severity] = count
return counts
@staticmethod
- def _finding_order() -> "tuple[ColumnElement[Any], ...]":
- severity_order = case(
- {severity.value: position for position, severity in enumerate(FindingSeverity)},
+ def _finding_order(severity_order: Sequence[str]) -> "tuple[ColumnElement[Any], ...]":
+ newest_first = (FindingEntry.created_at.desc(), FindingEntry.id.desc())
+ if not severity_order:
+ # case() with no branches compiles to SQL that both dialects reject.
+ return newest_first
+ rank = case(
+ {key: position for position, key in enumerate(severity_order)},
value=FindingEntry.severity,
+ else_=len(severity_order),
)
- return severity_order, FindingEntry.created_at.desc(), FindingEntry.id.desc()
+ return rank, *newest_first
def _uses_legacy_memory_override(self) -> bool:
return any(
diff --git a/pyrit/memory/memory_models.py b/pyrit/memory/memory_models.py
index f9f1c3b383..c5d86f636d 100644
--- a/pyrit/memory/memory_models.py
+++ b/pyrit/memory/memory_models.py
@@ -16,7 +16,6 @@
INTEGER,
JSON,
Boolean,
- CheckConstraint,
Computed,
DateTime,
Float,
@@ -84,7 +83,6 @@
TargetIdentifier,
scorable_from_dict,
)
-from pyrit.models.harm_category import HarmCategory
from pyrit.models.operation import operation_name_key
from pyrit.models.results.attack_result import normalize_legacy_attack_attribution
@@ -295,36 +293,18 @@ class FindingEntry(Base):
"""A saved finding, kept separate from scores and attack results."""
__tablename__ = "FindingEntries"
- # Harm types are deliberately unconstrained: HarmCategory changes over time, so reads map unknown values instead.
- __table_args__ = (
- Index("ix_FindingEntries_operation_id", "operation_id"),
- CheckConstraint(
- "severity IN ('critical', 'important', 'moderate', 'low', 'informational', 'other')",
- name="ck_findings_severity",
- ),
- CheckConstraint(
- "(severity = 'other' AND severity_other IS NOT NULL AND LTRIM(RTRIM(severity_other)) <> '') "
- "OR (severity <> 'other' AND severity_other IS NULL)",
- name="ck_findings_severity_other",
- ),
- CheckConstraint(
- "(harm_type IS NOT NULL AND harm_type = 'Other' AND harm_type_other IS NOT NULL "
- "AND LTRIM(RTRIM(harm_type_other)) <> '') "
- "OR ((harm_type IS NULL OR harm_type <> 'Other') AND harm_type_other IS NULL)",
- name="ck_findings_harm_type_other",
- ),
- )
+ __table_args__ = (Index("ix_FindingEntries_operation_id", "operation_id"),)
id: Mapped[uuid.UUID] = mapped_column(CustomUUID, primary_key=True)
operation_id: Mapped[uuid.UUID] = mapped_column(
CustomUUID, ForeignKey(f"{OperationEntry.__tablename__}.id"), nullable=False
)
title: Mapped[str] = mapped_column(Unicode, nullable=False)
- description: Mapped[str] = mapped_column(Unicode, nullable=False)
- severity: Mapped[str] = mapped_column(String(32), nullable=False)
- severity_other: Mapped[str | None] = mapped_column(Unicode, nullable=True)
- harm_type: Mapped[str | None] = mapped_column(Unicode, nullable=True)
- harm_type_other: Mapped[str | None] = mapped_column(Unicode, nullable=True)
+ # Binary collation keeps SQL Server from merging severities that differ only in case or accents, as SQLite does.
+ severity: Mapped[str] = mapped_column(
+ Unicode().with_variant(Unicode(collation="Latin1_General_100_BIN2"), "mssql"), nullable=False
+ )
+ field_values: Mapped[dict[str, str]] = mapped_column(JSON, nullable=False)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
def __init__(self, finding: Finding) -> None:
@@ -333,37 +313,23 @@ def __init__(self, finding: Finding) -> None:
self.id = validated.id
self.operation_id = validated.operation_id
self.title = validated.title
- self.description = validated.description
- self.severity = validated.severity.value
- self.severity_other = validated.severity_other
- self.harm_type = validated.harm_type.value if validated.harm_type is not None else None
- self.harm_type_other = validated.harm_type_other
+ self.severity = validated.severity
+ self.field_values = dict(validated.field_values)
self.created_at = validated.created_at.astimezone(UTC)
def get_finding(self) -> Finding:
"""
Rebuild the finding model.
- A stored harm type that is no longer a ``HarmCategory`` maps through its aliases, or to
- Other with the stored text, rather than making the finding unreadable.
-
Returns:
Finding: The stored finding.
"""
- harm_type, harm_type_other = self.harm_type, self.harm_type_other
- if harm_type is not None and harm_type not in set(HarmCategory):
- # parse() logs a warning when it falls back to Other.
- parsed = HarmCategory.parse(harm_type)
- harm_type, harm_type_other = parsed, harm_type if parsed == HarmCategory.OTHER else None
return Finding(
id=self.id,
operation_id=self.operation_id,
title=self.title,
- description=self.description,
severity=self.severity,
- severity_other=self.severity_other,
- harm_type=harm_type,
- harm_type_other=harm_type_other,
+ field_values=self.field_values,
created_at=self.created_at,
)
diff --git a/pyrit/models/__init__.py b/pyrit/models/__init__.py
index ebaebdd636..e6f9bcdb91 100644
--- a/pyrit/models/__init__.py
+++ b/pyrit/models/__init__.py
@@ -73,7 +73,15 @@
from pyrit.models.conversation_stats import ConversationStats
from pyrit.models.conversation_summary import ConversationSummary
from pyrit.models.embeddings import EmbeddingData, EmbeddingResponse, EmbeddingSupport, EmbeddingUsageInformation
- from pyrit.models.finding import Finding, FindingCreate, FindingEvidence, FindingSeverity
+ from pyrit.models.finding import Finding, FindingCreate, FindingEvidence
+ from pyrit.models.finding_fields import (
+ DEFAULT_FINDING_FIELDS,
+ FindingField,
+ FindingFields,
+ FindingFieldType,
+ FindingSeverityColor,
+ FindingValuesError,
+ )
from pyrit.models.harm_definition import HarmDefinition, ScaleDescription, get_all_harm_definitions
from pyrit.models.identifiers import (
REGISTRY_NAME_PATTERN,
@@ -445,7 +453,12 @@
"Finding": "pyrit.models.finding",
"FindingCreate": "pyrit.models.finding",
"FindingEvidence": "pyrit.models.finding",
- "FindingSeverity": "pyrit.models.finding",
+ "DEFAULT_FINDING_FIELDS": "pyrit.models.finding_fields",
+ "FindingField": "pyrit.models.finding_fields",
+ "FindingFields": "pyrit.models.finding_fields",
+ "FindingFieldType": "pyrit.models.finding_fields",
+ "FindingSeverityColor": "pyrit.models.finding_fields",
+ "FindingValuesError": "pyrit.models.finding_fields",
"ConversationType": "pyrit.models.messages.conversation_reference",
"ContentEntryScorable": "pyrit.models.score",
"ContentScorable": "pyrit.models.score",
diff --git a/pyrit/models/finding.py b/pyrit/models/finding.py
index 09563386b3..ccefd151d1 100644
--- a/pyrit/models/finding.py
+++ b/pyrit/models/finding.py
@@ -2,24 +2,9 @@
# Licensed under the MIT license.
from datetime import UTC, datetime
-from enum import Enum
-from typing import Self
from uuid import UUID, uuid4
-from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
-
-from pyrit.models.harm_category import HarmCategory
-
-
-class FindingSeverity(str, Enum):
- """Operator-selected severity, ordered from highest to lowest."""
-
- CRITICAL = "critical"
- IMPORTANT = "important"
- MODERATE = "moderate"
- LOW = "low"
- INFORMATIONAL = "informational"
- OTHER = "other"
+from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator
class FindingCreate(BaseModel):
@@ -28,39 +13,16 @@ class FindingCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str
- description: str = ""
- severity: FindingSeverity
- severity_other: str | None = None
- harm_type: HarmCategory | None = None
- harm_type_other: str | None = None
-
- @model_validator(mode="after")
- def _validate_classifications(self) -> Self:
- """
- Require custom text only for an Other selection.
-
- Returns:
- Self: The validated finding.
-
- Raises:
- ValueError: If custom text is missing, blank, or has no Other selection.
- """
- for field, is_other, text in [
- ("severity", self.severity == FindingSeverity.OTHER, self.severity_other),
- ("harm_type", self.harm_type == HarmCategory.OTHER, self.harm_type_other),
- ]:
- if is_other:
- if text is None or not text.strip():
- raise ValueError(f"{field}_other must not be blank when {field} is Other.")
- elif text is not None:
- raise ValueError(f"{field}_other is only allowed when {field} is Other.")
- return self
+ severity: str = Field(description="The key of a severity option, or Other text; surrounding whitespace is removed")
+ field_values: dict[str, str] = Field(
+ {}, description="Values by field or level key, each an option's key or text; blank values are dropped"
+ )
@field_validator("title")
@classmethod
def _reject_blank(cls, value: str) -> str:
"""
- Reject a blank title without normalizing its contents.
+ Reject blank text without normalizing its contents.
Returns:
str: The original value.
@@ -72,6 +34,34 @@ def _reject_blank(cls, value: str) -> str:
raise ValueError("Must not be blank.")
return value
+ @field_validator("severity")
+ @classmethod
+ def _strip_severity(cls, value: str) -> str:
+ """
+ Remove surrounding whitespace and reject blank text.
+
+ Returns:
+ str: The severity without surrounding whitespace.
+
+ Raises:
+ ValueError: If the severity is blank.
+ """
+ stripped = value.strip()
+ if not stripped:
+ raise ValueError("Must not be blank.")
+ return stripped
+
+ @field_validator("field_values")
+ @classmethod
+ def _drop_blank_values(cls, value: dict[str, str]) -> dict[str, str]:
+ """
+ Drop empty and whitespace-only values, keeping other text exactly as entered.
+
+ Returns:
+ dict[str, str]: The non-blank values.
+ """
+ return {key: text for key, text in value.items() if text.strip()}
+
class Finding(FindingCreate):
"""A saved finding in one operation."""
diff --git a/pyrit/models/finding_fields.py b/pyrit/models/finding_fields.py
new file mode 100644
index 0000000000..2b25eae09b
--- /dev/null
+++ b/pyrit/models/finding_fields.py
@@ -0,0 +1,613 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import re
+from collections.abc import Iterable, Iterator
+from enum import StrEnum
+from typing import Any, ClassVar, Self
+
+from pydantic import BaseModel, ConfigDict, field_validator, model_validator
+
+from pyrit.models.harm_category import HarmCategory
+
+
+class FindingFieldType(StrEnum):
+ """How the finding dialog collects a field's value."""
+
+ TEXT = "text"
+ MULTILINE = "multiline"
+ CHOICE = "choice"
+
+
+class FindingSeverityColor(StrEnum):
+ """Badge color of a severity option. CoPyRIT renders each one with a Fluent UI badge color."""
+
+ RED = "red"
+ ORANGE = "orange"
+ YELLOW = "yellow"
+ GREEN = "green"
+ BLUE = "blue"
+ GRAY = "gray"
+ DARK = "dark"
+ NEUTRAL = "neutral"
+
+
+class FindingValuesError(ValueError):
+ """A finding's values do not fit the deployment's finding fields."""
+
+
+class FindingFieldOption(BaseModel):
+ """One option of a choice field, with the options of the next dropdown under it."""
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ key: str
+ label: str
+ children: list["FindingFieldOption"] = []
+
+
+class FindingField(BaseModel):
+ """
+ One field of the finding dialog, as a deployment defines it.
+
+ Attributes:
+ key (str): The stored name of the value. Keep it fixed; change ``label`` to rename what users see.
+ label (str): The name the finding dialog and cards show.
+ type (FindingFieldType): ``text``, ``multiline`` or ``choice``.
+ required (bool): Whether every finding needs a value. For a cascade, every level with options needs one.
+ options (list[FindingFieldOption]): A ``choice`` field's options. Write ``(key, label)`` pairs for one
+ dropdown, or a dict from each pair to its children for a cascade.
+ levels (list[tuple[str, str]]): The ``(key, label)`` of each dropdown under a cascade's first one.
+ Each level's value is stored under its own key.
+ default (str | None): A top-level option the finding dialog preselects for new findings.
+ allow_other (bool): Whether a single-level ``choice`` field also accepts typed text.
+ colors (dict[str, FindingSeverityColor]): The severity slot's badge color for each top-level option.
+ hint (str | None): Help text under the field in the finding dialog.
+ section (str | None): A heading the finding dialog shows where the section changes.
+ internal (bool): Whether the field and its levels stay out of the Word and Markdown exports. The dashboard
+ still shows them.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ _KEY_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"[a-z0-9_]+")
+
+ key: str
+ label: str
+ type: FindingFieldType
+ required: bool = False
+ options: list[FindingFieldOption] = []
+ levels: list[tuple[str, str]] = []
+ default: str | None = None
+ allow_other: bool = False
+ colors: dict[str, FindingSeverityColor] = {}
+ hint: str | None = None
+ section: str | None = None
+ internal: bool = False
+
+ @field_validator("options", mode="before")
+ @classmethod
+ def _normalize_options(cls, value: Any) -> Any:
+ """
+ Turn ``(key, label)`` pairs and cascade dicts into option dicts.
+
+ Returns:
+ Any: The options in a form pydantic validates as ``FindingFieldOption`` objects.
+ """
+ return _parse_options(value)
+
+ @model_validator(mode="after")
+ def _validate_definition(self) -> Self:
+ """
+ Check keys, labels and the settings that the field's type allows.
+
+ Returns:
+ Self: The validated field.
+ """
+ self._check_key(self.key)
+ self._check_not_blank()
+ for level_key, _ in self.levels:
+ self._check_key(level_key)
+ if self.type == FindingFieldType.CHOICE:
+ self._check_choice()
+ else:
+ self._check_no_choice_settings()
+ return self
+
+ def _check_key(self, key: str) -> None:
+ """
+ Require a key of lowercase letters, digits and underscores, other than ``__proto__``.
+
+ Raises:
+ ValueError: If the key uses any other character, is empty, or is ``__proto__``.
+ """
+ if not self._KEY_PATTERN.fullmatch(key):
+ raise ValueError(f"Key '{key}' must use only lowercase letters, digits and underscores.")
+ # JavaScript objects treat this name as their prototype, so CoPyRIT couldn't keep a value under it.
+ if key == "__proto__":
+ raise ValueError("Key '__proto__' is reserved.")
+
+ def _check_not_blank(self) -> None:
+ """
+ Require non-blank labels, option keys and option labels, and option keys without surrounding whitespace.
+
+ Raises:
+ ValueError: If any of them is blank, or an option key has surrounding whitespace.
+ """
+ options = list(_walk(self.options))
+ texts = [self.label, *(label for _, label in self.levels)]
+ for option in options:
+ texts += [option.key, option.label]
+ if any(not text.strip() for text in texts):
+ raise ValueError(f"Labels, option keys and option labels of '{self.key}' must not be blank.")
+ # Saved severities lose surrounding whitespace, so a padded key could never be chosen.
+ padded = next((option.key for option in options if option.key != option.key.strip()), None)
+ if padded is not None:
+ raise ValueError(f"Option key {padded!r} of '{self.key}' has surrounding whitespace.")
+
+ def _check_no_choice_settings(self) -> None:
+ """
+ Reject the settings that only a choice field can use.
+
+ Raises:
+ ValueError: If the field sets options, levels, default, allow_other or colors.
+ """
+ settings = {
+ "options": bool(self.options),
+ "levels": bool(self.levels),
+ "default": self.default is not None,
+ "allow_other": self.allow_other,
+ "colors": bool(self.colors),
+ }
+ for name, is_set in settings.items():
+ if is_set:
+ raise ValueError(f"Field '{self.key}' is a {self.type} field and cannot have {name}.")
+
+ def _check_choice(self) -> None:
+ """
+ Check the option tree of a choice field and the settings that refer to it.
+
+ Raises:
+ ValueError: If the options are missing, repeat a sibling key, nest to a depth that
+ ``levels`` does not match, or ``allow_other`` is combined with levels.
+ """
+ if not self.options:
+ raise ValueError(f"Choice field '{self.key}' needs at least one option.")
+ self._check_unique_siblings()
+ depth = _depth(self.options)
+ if depth != len(self.levels) + 1:
+ raise ValueError(
+ f"Field '{self.key}' needs one entry in levels for each dropdown below the field; "
+ f"its options imply {depth - 1}, but levels has {len(self.levels)}."
+ )
+ self._check_top_level_references()
+ if self.allow_other and self.levels:
+ raise ValueError(f"Field '{self.key}' can take Other text only without levels.")
+
+ def _check_unique_siblings(self) -> None:
+ """
+ Require unique option keys among the options of each dropdown.
+
+ Raises:
+ ValueError: If two sibling options share a key.
+ """
+ for siblings in [self.options, *(option.children for option in _walk(self.options))]:
+ repeated = _first_repeated(option.key for option in siblings)
+ if repeated is not None:
+ raise ValueError(f"Field '{self.key}' repeats the option key '{repeated}' among sibling options.")
+
+ def _check_top_level_references(self) -> None:
+ """
+ Require ``default`` and every ``colors`` key to name a top-level option.
+
+ Raises:
+ ValueError: If either names anything else.
+ """
+ top_level = {option.key for option in self.options}
+ defaults = [] if self.default is None else [self.default]
+ for key in [*defaults, *self.colors]:
+ if key not in top_level:
+ raise ValueError(f"'{key}' is not a top-level option of '{self.key}'.")
+
+
+class FindingValueRow(BaseModel):
+ """One filled value of a finding, labelled for display."""
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ key: str
+ label: str
+ value: str
+ multiline: bool
+ internal: bool
+
+
+class FindingValueDescription(BaseModel):
+ """A finding's values labelled for display, with the stored values that no field uses."""
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ severity_label: str
+ rows: list[FindingValueRow]
+ retired: list[tuple[str, str]]
+
+
+class FindingFields(BaseModel):
+ """A deployment's finding fields: the severity slot and the fields shown after it."""
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ severity: FindingField
+ fields: list[FindingField] = []
+
+ @property
+ def severity_order(self) -> list[str]:
+ """The top-level option keys of the severity slot, from highest to lowest severity."""
+ return [option.key for option in self.severity.options]
+
+ @property
+ def field_keys(self) -> frozenset[str]:
+ """The key of every field and level, except ``severity``."""
+ keys = (key for field in [self.severity, *self.fields] for key, _ in _keys_and_labels(field))
+ return frozenset(keys) - {"severity"}
+
+ @field_validator("severity")
+ @classmethod
+ def _validate_severity_slot(cls, value: FindingField) -> FindingField:
+ """
+ Check the severity slot and mark it required.
+
+ Returns:
+ FindingField: The slot with ``required`` set to True.
+
+ Raises:
+ ValueError: If the slot's key is not ``severity``, it is not a choice field, it sets
+ ``required=False``, or it is internal.
+ """
+ if value.key != "severity":
+ raise ValueError(f"The severity slot's key must be 'severity', not '{value.key}'.")
+ if value.type != FindingFieldType.CHOICE:
+ raise ValueError("The severity slot must be a choice field.")
+ if "required" in value.model_fields_set and not value.required:
+ raise ValueError("The severity slot is always required; leave required out or set it to True.")
+ if value.internal:
+ raise ValueError("The severity slot can't be internal.")
+ return value.model_copy(update={"required": True})
+
+ @model_validator(mode="after")
+ def _validate_keys(self) -> Self:
+ """
+ Check colors and keys across the severity slot, the fields and their levels.
+
+ Returns:
+ Self: The validated fields.
+
+ Raises:
+ ValueError: If a field other than severity sets colors, a key is used more than once,
+ or a key is ``title``.
+ """
+ colored = next((field.key for field in self.fields if field.colors), None)
+ if colored is not None:
+ raise ValueError(f"Only the severity field can set colors, not '{colored}'.")
+ keys = [key for field in [self.severity, *self.fields] for key, _ in _keys_and_labels(field)]
+ repeated = _first_repeated(keys)
+ if repeated is not None:
+ raise ValueError(f"The key '{repeated}' is used more than once across fields and levels.")
+ if "title" in keys:
+ raise ValueError("The key 'title' is reserved for the finding title.")
+ return self
+
+ def check_values(
+ self,
+ *,
+ severity: str,
+ field_values: dict[str, str],
+ previous_severity: str | None = None,
+ previous_values: dict[str, str] | None = None,
+ ) -> None:
+ """
+ Check a finding's severity and field values against these fields.
+
+ On an edit, pass the stored finding's severity and values. A value that has not changed
+ since then may name an option or key that no longer exists.
+
+ Args:
+ severity (str): The severity slot's value.
+ field_values (dict[str, str]): The values of the other fields and of every level, by key.
+ previous_severity (str | None): The stored finding's severity, or None for a new finding.
+ previous_values (dict[str, str] | None): The stored finding's field values, or None for a
+ new finding.
+
+ Raises:
+ FindingValuesError: For the first unknown key, changed value of a field that is no longer
+ configured, missing required value, value that is not an option, or Other text that
+ matches an option.
+ """
+ previous_values = previous_values or {}
+ self._check_known_keys(field_values=field_values, previous_values=previous_values)
+ values = {**field_values, "severity": severity}
+ previous = {**previous_values, "severity": previous_severity or ""}
+ for field in [self.severity, *self.fields]:
+ _check_field_values(field=field, values=values, previous_values=previous)
+
+ def describe_values(self, *, severity: str, field_values: dict[str, str]) -> FindingValueDescription:
+ """
+ Label a finding's filled values for display.
+
+ Rows follow the configured order: the severity slot's levels, then each field and its levels.
+ A choice value shows the label of the matching option under its stored parent; any other value
+ shows as typed.
+
+ Args:
+ severity (str): The severity slot's value.
+ field_values (dict[str, str]): The values of the other fields and of every level, by key.
+
+ Returns:
+ FindingValueDescription: The severity's label, one row per filled value, and the
+ ``(key, value)`` of each stored value that no field or level uses, in stored order.
+ """
+ values = {**field_values, "severity": severity}
+ rows = [row for field in [self.severity, *self.fields] for row in _value_rows(field=field, values=values)]
+ severity_option = _find_option(options=self.severity.options, value=severity)
+ known = self.field_keys
+ return FindingValueDescription(
+ severity_label=severity_option.label if severity_option is not None else severity,
+ rows=[row for row in rows if row.key != "severity"],
+ retired=[(key, value) for key, value in field_values.items() if key not in known],
+ )
+
+ def _check_known_keys(self, *, field_values: dict[str, str], previous_values: dict[str, str]) -> None:
+ """
+ Reject a key that no field or level uses, unless its value has not changed.
+
+ Raises:
+ FindingValuesError: If a key is unknown and its value differs from the stored one.
+ """
+ known = self.field_keys
+ for key, value in field_values.items():
+ if key in known or previous_values.get(key) == value:
+ continue
+ if key in previous_values:
+ raise FindingValuesError(
+ f"Finding field {key!r} is no longer configured, so its saved value can't change."
+ )
+ raise FindingValuesError(f"Unknown finding field {key!r}.")
+
+
+def _parse_options(value: Any) -> Any:
+ """
+ Turn authoring shorthand for options into a list of option dicts.
+
+ A dict maps each ``(key, label)`` pair to its children, written the same way. In a list, a
+ ``(key, label)`` pair becomes an option without children, and anything else passes through.
+
+ Returns:
+ Any: The options as a list, or ``value`` unchanged if it is neither a dict nor a list.
+ """
+ if isinstance(value, dict):
+ return [_parse_branch(pair=pair, children=children) for pair, children in value.items()]
+ if isinstance(value, list):
+ return [{"key": item[0], "label": item[1]} if _is_pair(item) else item for item in value]
+ return value
+
+
+def _parse_branch(*, pair: Any, children: Any) -> dict[str, Any]:
+ """
+ Turn one cascade dict entry into an option dict with its children.
+
+ Returns:
+ dict[str, Any]: The option with its parsed children.
+
+ Raises:
+ ValueError: If ``pair`` is not a ``(key, label)`` pair.
+ """
+ if not _is_pair(pair):
+ raise ValueError(f"Option {pair!r} must be a (key, label) pair.")
+ return {"key": pair[0], "label": pair[1], "children": _parse_options(children)}
+
+
+def _is_pair(value: Any) -> bool:
+ """
+ Check whether a value is a 2-item tuple or list of strings.
+
+ Returns:
+ bool: True if the value is a ``(key, label)`` pair.
+ """
+ return isinstance(value, tuple | list) and len(value) == 2 and all(isinstance(part, str) for part in value)
+
+
+def _walk(options: list[FindingFieldOption]) -> Iterator[FindingFieldOption]:
+ """
+ Yield every option in a tree, each parent before its children.
+
+ Yields:
+ FindingFieldOption: Each option in the tree.
+ """
+ for option in options:
+ yield option
+ yield from _walk(option.children)
+
+
+def _depth(options: list[FindingFieldOption]) -> int:
+ """
+ Count the dropdowns that the deepest branch of an option tree needs.
+
+ Returns:
+ int: The depth of the tree, or 0 for no options.
+ """
+ if not options:
+ return 0
+ return 1 + max(_depth(option.children) for option in options)
+
+
+def _first_repeated(keys: Iterable[str]) -> str | None:
+ """
+ Find the first key that appears a second time.
+
+ Returns:
+ str | None: The first repeated key, or None if all keys are unique.
+ """
+ seen: set[str] = set()
+ for key in keys:
+ if key in seen:
+ return key
+ seen.add(key)
+ return None
+
+
+def _keys_and_labels(field: FindingField) -> list[tuple[str, str]]:
+ """
+ List the ``(key, label)`` of a field followed by those of its levels.
+
+ Returns:
+ list[tuple[str, str]]: The field's and its levels' keys and labels in written order.
+ """
+ return [(field.key, field.label), *field.levels]
+
+
+def _walk_values(
+ *, field: FindingField, values: dict[str, str]
+) -> Iterator[tuple[str, str, str | None, list[FindingFieldOption], FindingFieldOption | None]]:
+ """
+ Walk a field and its levels, following each value to the options of the next level.
+
+ Yields:
+ tuple[str, str, str | None, list[FindingFieldOption], FindingFieldOption | None]: The key, label,
+ non-blank value or None, offered options, and matching option or None of the field and of each level.
+ """
+ options = field.options
+ for key, label in _keys_and_labels(field):
+ value = _non_blank(values.get(key))
+ match = _find_option(options=options, value=value)
+ yield key, label, value, options, match
+ options = match.children if match is not None else []
+
+
+def _check_field_values(*, field: FindingField, values: dict[str, str], previous_values: dict[str, str]) -> None:
+ """
+ Check the value of one field and of its levels.
+
+ Raises:
+ FindingValuesError: If a required value is missing or a value is not an option.
+ """
+ if field.type == FindingFieldType.CHOICE:
+ _check_choice_values(field=field, values=values, previous_values=previous_values)
+ elif field.required and _non_blank(values.get(field.key)) is None:
+ raise FindingValuesError(f"{field.label} is required.")
+
+
+def _check_choice_values(*, field: FindingField, values: dict[str, str], previous_values: dict[str, str]) -> None:
+ """
+ Walk a choice field's dropdowns, checking each value against the options under its parent.
+
+ A value that is not an option passes if it and every value above it are unchanged.
+
+ Raises:
+ FindingValuesError: If a required dropdown with options has no value, or a changed value is
+ not an option and is not allowed as Other text.
+ """
+ path_unchanged = True
+ for key, label, value, options, match in _walk_values(field=field, values=values):
+ path_unchanged = path_unchanged and value == _non_blank(previous_values.get(key))
+ if value is None:
+ if field.required and options:
+ raise FindingValuesError(f"{label} is required.")
+ elif match is None and not path_unchanged:
+ _check_other_text(label=label, value=value, options=options, allow_other=field.allow_other)
+
+
+def _check_other_text(*, label: str, value: str, options: list[FindingFieldOption], allow_other: bool) -> None:
+ """
+ Accept a value that is not an option only as Other text that names no option.
+
+ Raises:
+ FindingValuesError: If the field takes no Other text, or the text matches an option's key or
+ label apart from case and surrounding spaces.
+ """
+ if not allow_other:
+ raise FindingValuesError(f"{label} has no option {value!r}.")
+ folded = value.strip().casefold()
+ for option in options:
+ if folded in (option.key.strip().casefold(), option.label.strip().casefold()):
+ raise FindingValuesError(
+ f"{label} {value!r} matches the option {option.label!r} (key {option.key!r}). "
+ "Choose that option instead."
+ )
+
+
+def _non_blank(value: str | None) -> str | None:
+ """
+ Treat a blank value as missing.
+
+ Args:
+ value (str | None): The value, or None if it is missing.
+
+ Returns:
+ str | None: The value, or None if it is missing or blank.
+ """
+ return value if value and value.strip() else None
+
+
+def _find_option(*, options: list[FindingFieldOption], value: str | None) -> FindingFieldOption | None:
+ """
+ Find the option whose key is a dropdown's value.
+
+ Returns:
+ FindingFieldOption | None: The matching option, or None if no option has that key.
+ """
+ return next((option for option in options if option.key == value), None)
+
+
+def _value_rows(*, field: FindingField, values: dict[str, str]) -> Iterator[FindingValueRow]:
+ """
+ Label the filled values of one field and of its levels.
+
+ Yields:
+ FindingValueRow: A row for each filled value, in level order.
+ """
+ for key, label, value, _, match in _walk_values(field=field, values=values):
+ if value is not None:
+ yield FindingValueRow(
+ key=key,
+ label=label,
+ value=match.label if match is not None else value,
+ multiline=field.type == FindingFieldType.MULTILINE,
+ internal=field.internal,
+ )
+
+
+DEFAULT_FINDING_FIELDS = FindingFields(
+ severity=FindingField(
+ key="severity",
+ label="Severity",
+ type=FindingFieldType.CHOICE,
+ options=[
+ ("critical", "Critical"),
+ ("important", "Important"),
+ ("moderate", "Moderate"),
+ ("low", "Low"),
+ ("informational", "Informational"),
+ ],
+ default="moderate",
+ allow_other=True,
+ colors={
+ "critical": FindingSeverityColor.RED,
+ "important": FindingSeverityColor.YELLOW,
+ "moderate": FindingSeverityColor.YELLOW,
+ "low": FindingSeverityColor.GRAY,
+ },
+ ),
+ fields=[
+ FindingField(
+ key="harm_type",
+ label="Harm-type",
+ type=FindingFieldType.CHOICE,
+ options=[
+ (category.value, category.value) for category in HarmCategory if category is not HarmCategory.OTHER
+ ],
+ allow_other=True,
+ hint="Optional. Select a category, or choose Other for custom text.",
+ ),
+ FindingField(key="description", label="Description", type=FindingFieldType.MULTILINE, hint="Optional"),
+ ],
+)
diff --git a/pyrit/setup/configuration_loader.py b/pyrit/setup/configuration_loader.py
index baf670ca3a..bcc601d798 100644
--- a/pyrit/setup/configuration_loader.py
+++ b/pyrit/setup/configuration_loader.py
@@ -24,7 +24,7 @@
from pyrit.common.utils import verify_and_resolve_path
from pyrit.common.yaml_helper import safe_load_yaml
from pyrit.common.yaml_loadable import YamlLoadable
-from pyrit.models import class_name_to_snake_case
+from pyrit.models import FindingFields, class_name_to_snake_case
from pyrit.setup.environment_loading import validate_env_akv_strict
from pyrit.setup.initialization import AZURE_SQL, IN_MEMORY, SQLITE, initialize_pyrit_async, reinitialize_pyrit_async
@@ -91,6 +91,37 @@ class _ConfigurationLayer:
reset_extensions: bool
+def _check_findings_initializers(initializers: Sequence["PyRITInitializer"]) -> None:
+ """
+ Check the finding fields that ``FindingsInitializer`` subclasses would set, without making them active.
+
+ Code that calls ``set_active_finding_fields`` directly isn't checked.
+
+ Args:
+ initializers (Sequence[PyRITInitializer]): The initializers of the replacement setup pass.
+
+ Raises:
+ ValueError: If two or more ``FindingsInitializer`` subclasses are configured, or the configured one
+ returns invalid fields or something other than ``FindingFields``.
+ """
+ from pyrit.setup.initializers import FindingsInitializer
+
+ setters = [initializer for initializer in initializers if isinstance(initializer, FindingsInitializer)]
+ if len(setters) > 1:
+ first, second = (type(setter).__name__ for setter in setters[:2])
+ raise ValueError(
+ f"{first} and {second} both set the finding fields. Use one FindingsInitializer per deployment."
+ )
+ if not setters:
+ return
+ initializer = setters[0]
+ result = initializer.get_finding_fields()
+ if not isinstance(result, FindingFields):
+ raise ValueError(
+ f"{type(initializer).__name__}.get_finding_fields() must return FindingFields, not {type(result).__name__}."
+ )
+
+
@dataclass
class ConfigurationLoader(YamlLoadable):
"""
@@ -706,7 +737,9 @@ async def preflight_reinitialization_async(
PreparedReinitialization: Immutable inputs ready for the mutation phase.
Raises:
- ValueError: If an initializer is invalid or a required environment value is missing.
+ ValueError: If an initializer is invalid, a required environment value is missing, more than one
+ ``FindingsInitializer`` subclass is configured, or the configured one returns invalid finding
+ fields. Code that calls ``set_active_finding_fields`` directly isn't checked.
"""
from pyrit.registry import InitializerRegistry
from pyrit.setup.initializers.targets import TargetInitializer
@@ -744,6 +777,7 @@ async def preflight_reinitialization_async(
raise ValueError(
f"Initializer '{type(initializer).__name__}' has missing required environment variables."
)
+ _check_findings_initializers(initializers)
return PreparedReinitialization(
environment_values=dict(environment_values),
diff --git a/pyrit/setup/finding_fields.py b/pyrit/setup/finding_fields.py
new file mode 100644
index 0000000000..e911dcfda7
--- /dev/null
+++ b/pyrit/setup/finding_fields.py
@@ -0,0 +1,47 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Hold the finding fields that the current setup pass configured."""
+
+from pyrit.models import DEFAULT_FINDING_FIELDS, FindingFields
+
+_active: tuple[FindingFields, str] | None = None
+
+
+def get_active_finding_fields() -> FindingFields:
+ """
+ Return the finding fields of the current setup pass.
+
+ Returns:
+ FindingFields: The fields set in this pass, or ``DEFAULT_FINDING_FIELDS`` when none were set.
+ """
+ return _active[0] if _active is not None else DEFAULT_FINDING_FIELDS
+
+
+def set_active_finding_fields(*, finding_fields: FindingFields, source: str) -> None:
+ """
+ Set the finding fields for the current setup pass.
+
+ Args:
+ finding_fields (FindingFields): The deployment's finding fields.
+ source (str): The setter's name, which the error names when two setters run in one pass.
+
+ Raises:
+ TypeError: If ``finding_fields`` is not a ``FindingFields``.
+ ValueError: If finding fields were already set in this setup pass.
+ """
+ global _active
+ if not isinstance(finding_fields, FindingFields):
+ raise TypeError(f"{source} must set FindingFields, not {type(finding_fields).__name__}.")
+ if _active is not None:
+ raise ValueError(
+ f"{source} cannot set the finding fields: {_active[1]} already set them in this setup pass. "
+ "Use one FindingsInitializer per deployment."
+ )
+ _active = (finding_fields, source)
+
+
+def reset_active_finding_fields() -> None:
+ """Restore the default finding fields at the start of a setup pass."""
+ global _active
+ _active = None
diff --git a/pyrit/setup/initialization.py b/pyrit/setup/initialization.py
index be385594f2..686dea2365 100644
--- a/pyrit/setup/initialization.py
+++ b/pyrit/setup/initialization.py
@@ -16,6 +16,7 @@
load_environment_files,
validate_env_akv_strict,
)
+from pyrit.setup.finding_fields import reset_active_finding_fields
if TYPE_CHECKING:
from pyrit.setup.pyrit_initializer import PyRITInitializer
@@ -206,6 +207,7 @@ async def initialize_pyrit_async(
# Reset all default values before executing initialization scripts
# This ensures a clean state for each initialization
reset_default_values()
+ reset_active_finding_fields()
# Set up memory BEFORE executing initialization scripts
# This is critical because initialization scripts may instantiate objects
@@ -302,6 +304,7 @@ async def reinitialize_pyrit_async(
os.environ.update(environment_values)
configure_random_seed(seed=seed)
reset_default_values()
+ reset_active_finding_fields()
CentralMemory.set_memory_instance(memory)
initializers = await asyncio.to_thread(initializer_factory)
await _execute_initializers_async(initializers=initializers, raise_on_initializer_error=True)
diff --git a/pyrit/setup/initializers/__init__.py b/pyrit/setup/initializers/__init__.py
index d5693e3c80..a069b659ba 100644
--- a/pyrit/setup/initializers/__init__.py
+++ b/pyrit/setup/initializers/__init__.py
@@ -11,6 +11,7 @@
if TYPE_CHECKING:
from pyrit.models.parameter import Parameter
from pyrit.setup.initializers.converters import ConverterInitializer
+ from pyrit.setup.initializers.findings import FindingsInitializer
from pyrit.setup.initializers.load_default_datasets import LoadDefaultDatasets
from pyrit.setup.initializers.preload_scenario_metadata import PreloadScenarioMetadata
from pyrit.setup.initializers.refresh_datasets import RefreshDatasets
@@ -24,6 +25,7 @@
"Parameter": "pyrit.models.parameter",
"PyRITInitializer": "pyrit.setup.pyrit_initializer",
"ConverterInitializer": "pyrit.setup.initializers.converters",
+ "FindingsInitializer": "pyrit.setup.initializers.findings",
"TechniqueInitializer": "pyrit.setup.initializers.techniques",
"ToolsInitializer": "pyrit.setup.initializers.tools",
"ScorerInitializer": "pyrit.setup.initializers.scorers",
diff --git a/pyrit/setup/initializers/findings.py b/pyrit/setup/initializers/findings.py
new file mode 100644
index 0000000000..3fa98e82e3
--- /dev/null
+++ b/pyrit/setup/initializers/findings.py
@@ -0,0 +1,39 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Base initializer for a deployment's findings."""
+
+from abc import abstractmethod
+from typing import Any
+
+from pyrit.models import FindingFields
+from pyrit.setup.finding_fields import set_active_finding_fields
+from pyrit.setup.pyrit_initializer import PyRITInitializer
+
+
+class FindingsInitializer(PyRITInitializer):
+ """Set a deployment's finding fields from ``get_finding_fields``."""
+
+ @abstractmethod
+ def get_finding_fields(self) -> FindingFields:
+ """
+ Return the deployment's finding fields, the same whatever the environment variables.
+
+ A live apply checks these fields before it applies the new environment.
+
+ Returns:
+ FindingFields: The fields that setup makes active.
+ """
+
+ async def initialize_async(self) -> None:
+ """Make the fields from ``get_finding_fields`` the active finding fields."""
+ set_active_finding_fields(finding_fields=self.get_finding_fields(), source=type(self).__name__)
+
+ async def get_dynamic_default_values_info_async(self) -> dict[str, Any]:
+ """
+ Report no default values or global variables without running ``initialize_async``.
+
+ Returns:
+ dict[str, Any]: Empty ``default_values`` and ``global_variables`` lists.
+ """
+ return {"default_values": [], "global_variables": []}
diff --git a/tests/unit/backend/conftest.py b/tests/unit/backend/conftest.py
index 6d0ce4bde3..6b989f3ff1 100644
--- a/tests/unit/backend/conftest.py
+++ b/tests/unit/backend/conftest.py
@@ -18,6 +18,8 @@
from pyrit.backend.services.manual_send_scheduler import get_manual_send_scheduler
from pyrit.backend.services.message_send_service import get_message_send_service
from pyrit.memory import SQLiteMemory
+from pyrit.models import Finding, FindingField, FindingFields, Operation
+from pyrit.setup.finding_fields import set_active_finding_fields
from test_support.fixtures import authentication_policy, compatibility_headers, compatibility_id # noqa: F401
from unit.async_utils import TEST_WATCHDOG_SECONDS
@@ -57,3 +59,43 @@ def isolated_manual_message_services() -> Iterator[None]:
yield
for factory in (get_attack_service, get_message_send_service, get_manual_send_scheduler, get_dataset_service):
factory.cache_clear()
+
+
+@pytest.fixture
+def contoso_finding_fields(reset_finding_fields: None) -> FindingFields:
+ """Make Contoso's finding fields active: severities ``sev1`` and ``sev2``, and a required ``steps`` field."""
+ fields = FindingFields(
+ severity=FindingField(
+ key="severity", label="Severity", type="choice", options=[("sev1", "Sev 1"), ("sev2", "Sev 2")]
+ ),
+ fields=[FindingField(key="steps", label="Steps to reproduce", type="multiline", required=True)],
+ )
+ set_active_finding_fields(finding_fields=fields, source="test")
+ return fields
+
+
+@pytest.fixture
+async def retired_value_finding_async(sqlite_instance: SQLiteMemory, contoso_finding_fields: FindingFields) -> Finding:
+ """Seed a finding with ``steps`` and a ``description`` value that Contoso's fields don't use."""
+ return await _seed_contoso_finding_async(
+ sqlite_instance=sqlite_instance, severity="sev1", field_values={"steps": "1. Open", "description": "Old text"}
+ )
+
+
+@pytest.fixture
+async def unmatched_severity_finding_async(
+ sqlite_instance: SQLiteMemory, contoso_finding_fields: FindingFields
+) -> Finding:
+ """Seed a finding with severity ``low``, which Contoso's fields don't offer."""
+ return await _seed_contoso_finding_async(
+ sqlite_instance=sqlite_instance, severity="low", field_values={"steps": "1. Open"}
+ )
+
+
+async def _seed_contoso_finding_async(
+ *, sqlite_instance: SQLiteMemory, severity: str, field_values: dict[str, str]
+) -> Finding:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Contoso"))
+ return await sqlite_instance.add_finding_async(
+ finding=Finding(operation_id=operation.id, title="Leak", severity=severity, field_values=field_values)
+ )
diff --git a/tests/unit/backend/mocks.py b/tests/unit/backend/mocks.py
index 285d705371..2f09f0fbaf 100644
--- a/tests/unit/backend/mocks.py
+++ b/tests/unit/backend/mocks.py
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
-"""Shared attack fixtures for backend service tests."""
+"""Shared attack and finding helpers for backend service tests."""
import asyncio
from collections.abc import AsyncGenerator
@@ -12,11 +12,18 @@
from pyrit.backend.models.message_sends import MessageSendState, MessageSendStatus
from pyrit.backend.services.message_send_service import MessageSendService
from pyrit.memory import MemoryInterface
-from pyrit.models import AtomicAttackIdentifier, AttackOutcome, AttackResult, ComponentIdentifier
+from pyrit.models import AtomicAttackIdentifier, AttackOutcome, AttackResult, ComponentIdentifier, Finding
from pyrit.prompt_target import PromptTarget
from unit.async_utils import TEST_WATCHDOG_SECONDS
+async def read_finding_async(*, memory: MemoryInterface, finding: Finding) -> Finding:
+ """Read a finding back from memory, failing if it is gone."""
+ stored = await memory.get_finding_async(operation_id=finding.operation_id, finding_id=finding.id)
+ assert stored is not None
+ return stored
+
+
async def _settle_send_async(
*, service: MessageSendService, status: MessageSendStatus, timeout_seconds: float = TEST_WATCHDOG_SECONDS
) -> MessageSendStatus:
diff --git a/tests/unit/backend/test_common_models.py b/tests/unit/backend/test_common_models.py
index 0e63b7958a..ed240ce1e7 100644
--- a/tests/unit/backend/test_common_models.py
+++ b/tests/unit/backend/test_common_models.py
@@ -37,6 +37,7 @@
)
from pyrit.backend.models.converters import ConverterPreviewRequest, CreateConverterRequest
from pyrit.backend.models.initializers import RegisterInitializerRequest
+from pyrit.backend.models.operations import FindingCreateRequest, FindingUpdateRequest
from pyrit.backend.models.scores import ManualScoreRequest
from pyrit.backend.models.targets import CreateTargetRequest
from pyrit.backend.models.techniques import CreateTechniqueRequest
@@ -441,6 +442,44 @@ def test_update_attack_request_expected_objective_is_not_length_limited() -> Non
assert len(request.expected_objective or "") == MAX_TEXT_LENGTH + 1
+@pytest.mark.parametrize("model", [FindingCreateRequest, FindingUpdateRequest])
+@pytest.mark.parametrize(
+ "overrides",
+ [
+ {"title": "t" * (MAX_TEXT_LENGTH + 1)},
+ {"severity": "s" * (MAX_TEXT_LENGTH + 1)},
+ {"field_values": {"k" * (MAX_IDENTIFIER_LENGTH + 1): "value"}},
+ {"field_values": {"key": "v" * (MAX_TEXT_LENGTH + 1)}},
+ {"field_values": {f"key{i}": "value" for i in range(MAX_ITEMS + 1)}},
+ ],
+)
+def test_finding_requests_reject_values_over_limits(model: type[BaseModel], overrides: dict[str, object]) -> None:
+ with pytest.raises(ValidationError):
+ model.model_validate({"title": "Finding", "severity": "moderate", **overrides})
+
+
+@pytest.mark.parametrize(
+ "remove_retired_keys",
+ [["k" * (MAX_IDENTIFIER_LENGTH + 1)], [f"key{i}" for i in range(MAX_ITEMS + 1)]],
+)
+def test_finding_update_request_rejects_retired_keys_over_limits(remove_retired_keys: list[str]) -> None:
+ with pytest.raises(ValidationError):
+ FindingUpdateRequest(title="Finding", severity="moderate", remove_retired_keys=remove_retired_keys)
+
+
+def test_finding_update_request_accepts_values_at_limits() -> None:
+ keys = [f"{i:0{MAX_IDENTIFIER_LENGTH}d}" for i in range(MAX_ITEMS)]
+ request = FindingUpdateRequest(
+ title="t" * MAX_TEXT_LENGTH,
+ severity="s" * MAX_TEXT_LENGTH,
+ field_values={key: "v" * MAX_TEXT_LENGTH if key == keys[0] else "v" for key in keys},
+ remove_retired_keys=keys,
+ )
+
+ assert len(request.field_values) == MAX_ITEMS
+ assert len(request.remove_retired_keys) == MAX_ITEMS
+
+
@pytest.mark.parametrize(
"overrides",
[
diff --git a/tests/unit/backend/test_finding_export.py b/tests/unit/backend/test_finding_export.py
index 7f00f4388e..ddb4f6b9f3 100644
--- a/tests/unit/backend/test_finding_export.py
+++ b/tests/unit/backend/test_finding_export.py
@@ -36,13 +36,14 @@
ConversationType,
Finding,
FindingEvidence,
+ FindingField,
+ FindingFields,
Message,
MessagePiece,
Operation,
Score,
ScoreStatus,
)
-from pyrit.models.harm_category import HarmCategory
from pyrit.output.finding_report.markdown import MarkdownFindingReportPrinter
from pyrit.output.finding_report.report import (
FindingReport,
@@ -51,6 +52,7 @@
FindingReportEvidence,
FindingReportField,
)
+from pyrit.setup.finding_fields import set_active_finding_fields
pytestmark = pytest.mark.usefixtures("patch_central_database")
@@ -80,6 +82,31 @@
)
NOT_SCORED = FindingReportField(label="Score", value="Not scored")
NOT_IN_AN_OPERATION = FindingReportField(label="From operation", value="Not in an operation")
+CONTOSO_FIELDS = FindingFields(
+ severity=FindingField(
+ key="severity",
+ label="Impact",
+ type="choice",
+ levels=[("severity_rationale", "Impact rationale")],
+ options={
+ ("sev1", "Sev 1"): [("remote_no_auth", "Remote, no sign-in needed")],
+ ("sev2", "Sev 2"): [("signed_in_user", "Needs a signed-in user")],
+ },
+ ),
+ fields=[
+ FindingField(
+ key="category",
+ label="Category",
+ type="choice",
+ required=True,
+ levels=[("subcategory", "Subcategory")],
+ options={("model_security", "Model security"): [("system_prompt_leak", "System prompt leak")]},
+ ),
+ FindingField(key="steps", label="Steps to reproduce", type="multiline"),
+ FindingField(key="build", label="Build", type="text"),
+ FindingField(key="owner", label="Owner", type="text", internal=True),
+ ],
+)
def _client() -> TestClient:
@@ -212,8 +239,7 @@ async def _save_named_findings_async(
operation=operation,
title="Critical finding",
severity="critical",
- harm_type=HarmCategory.PROFANITY,
- description="Synthetic description\nsecond line",
+ field_values={"harm_type": "Profanity", "description": "Synthetic description\nsecond line"},
),
"Included finding": await _add_finding_async(memory=memory, operation=operation, title="Included finding"),
"Excluded finding": await _add_finding_async(memory=memory, operation=operation, title="Excluded finding"),
@@ -543,7 +569,7 @@ async def test_report_matches_the_dashboard_listing_order(sqlite_instance: SQLit
)
for index, severity in enumerate(["low", "critical", "moderate", "critical"])
]
- dashboard = await sqlite_instance.get_findings_async(operation_id=operation.id, limit=10)
+ dashboard = (await OperationService().list_findings_async(operation_id=operation.id, limit=10, offset=0)).items
report = await _report_async(operation_id=operation.id, finding_ids=[finding.id for finding in findings])
@@ -743,17 +769,15 @@ async def test_report_projects_optional_and_custom_fields(sqlite_instance: SQLit
memory=sqlite_instance,
operation=operation,
title="Custom finding",
- severity="other",
- severity_other="Team severity",
- harm_type=HarmCategory.OTHER,
- harm_type_other="Team harm",
+ severity="Team severity",
+ field_values={"harm_type": "Team harm"},
)
plain = await _add_finding_async(
memory=sqlite_instance,
operation=operation,
title="Plain finding",
severity="informational",
- description="Only text",
+ field_values={"description": "Only text"},
)
attack = await _add_attack_async(memory=sqlite_instance, conversation_id="main", objective=" ")
evidence = await _link_async(memory=sqlite_instance, finding=custom, attack_result_id=attack.attack_result_id)
@@ -784,6 +808,53 @@ async def test_report_projects_optional_and_custom_fields(sqlite_instance: SQLit
)
+@pytest.mark.usefixtures("reset_finding_fields")
+async def test_report_places_configured_fields_by_type(sqlite_instance: SQLiteMemory) -> None:
+ set_active_finding_fields(finding_fields=CONTOSO_FIELDS, source="test")
+ operation = await sqlite_instance.add_operation_async(Operation(name="Contoso assistant"))
+ finding = await _add_finding_async(
+ memory=sqlite_instance,
+ operation=operation,
+ title="System prompt leak",
+ severity="Sev 2.5",
+ field_values={
+ "owner": "dana",
+ "description": "Retired text",
+ "steps": "1. Ask for the instructions\n2. Repeat them",
+ "build": "2026.10",
+ "subcategory": "system_prompt_leak",
+ "category": "model_security",
+ "severity_rationale": "remote_no_auth",
+ },
+ )
+
+ report = await _report_async(operation_id=operation.id, finding_ids=[finding.id])
+
+ # Internal fields and retired values stay out; the rationale has no matching parent, so it shows as typed.
+ assert report.findings == (
+ FindingReportEntry(
+ title="System prompt leak",
+ severity=FindingReportField(label="Impact", value="Sev 2.5"),
+ fields=(
+ FindingReportField(
+ label="Impact rationale", value="remote_no_auth", column=FindingReportColumn.ASSESSMENT
+ ),
+ FindingReportField(label="Category", value="Model security", column=FindingReportColumn.ASSESSMENT),
+ FindingReportField(
+ label="Subcategory", value="System prompt leak", column=FindingReportColumn.ASSESSMENT
+ ),
+ FindingReportField(
+ label="Steps to reproduce",
+ value="1. Ask for the instructions\n2. Repeat them",
+ column=FindingReportColumn.FINDING,
+ ),
+ FindingReportField(label="Build", value="2026.10", column=FindingReportColumn.ASSESSMENT),
+ ),
+ evidence=(),
+ ),
+ )
+
+
@pytest.mark.parametrize(
("score_value", "scorer", "expected"),
[
diff --git a/tests/unit/backend/test_operation_service_findings.py b/tests/unit/backend/test_operation_service_findings.py
new file mode 100644
index 0000000000..33739af52f
--- /dev/null
+++ b/tests/unit/backend/test_operation_service_findings.py
@@ -0,0 +1,104 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import re
+
+import pytest
+
+from pyrit.backend.models.operations import FindingUpdateRequest
+from pyrit.backend.services.operation_service import OperationService
+from pyrit.memory import SQLiteMemory
+from pyrit.models import Finding, FindingValuesError
+from unit.backend.mocks import read_finding_async
+
+pytestmark = pytest.mark.usefixtures("patch_central_database")
+
+
+async def _update_async(
+ *,
+ finding: Finding,
+ field_values: dict[str, str],
+ remove_retired_keys: list[str] | None = None,
+ severity: str = "sev1",
+) -> Finding:
+ request = FindingUpdateRequest(
+ title="Leak", severity=severity, field_values=field_values, remove_retired_keys=remove_retired_keys or []
+ )
+ return await OperationService().update_finding_async(
+ operation_id=finding.operation_id, finding_id=finding.id, request=request
+ )
+
+
+async def test_update_keeps_an_omitted_retired_value(
+ sqlite_instance: SQLiteMemory, retired_value_finding_async: Finding
+) -> None:
+ finding = retired_value_finding_async
+ updated = await _update_async(finding=finding, field_values={"steps": "1. Open"})
+ assert updated.field_values == finding.field_values
+ assert (await read_finding_async(memory=sqlite_instance, finding=finding)).field_values == finding.field_values
+
+
+async def test_update_keeps_a_blank_retired_value(
+ sqlite_instance: SQLiteMemory, retired_value_finding_async: Finding
+) -> None:
+ finding = retired_value_finding_async
+ await _update_async(finding=finding, field_values={"steps": "1. Open", "description": " "})
+ assert (await read_finding_async(memory=sqlite_instance, finding=finding)).field_values == finding.field_values
+
+
+async def test_update_removes_a_listed_retired_value(
+ sqlite_instance: SQLiteMemory, retired_value_finding_async: Finding
+) -> None:
+ finding = retired_value_finding_async
+ updated = await _update_async(
+ finding=finding, field_values={"steps": "1. Open"}, remove_retired_keys=["description"]
+ )
+ assert updated.field_values == {"steps": "1. Open"}
+ assert (await read_finding_async(memory=sqlite_instance, finding=finding)).field_values == {"steps": "1. Open"}
+
+
+@pytest.mark.parametrize("key", ["steps", "nonexistent"])
+async def test_update_rejects_removing_a_value_that_is_not_retired(
+ sqlite_instance: SQLiteMemory, retired_value_finding_async: Finding, key: str
+) -> None:
+ finding = retired_value_finding_async
+ with pytest.raises(FindingValuesError, match=re.escape(f"{key!r} is not a retired value of this finding.")):
+ await _update_async(finding=finding, field_values={"steps": "1. Open"}, remove_retired_keys=[key])
+ assert (await read_finding_async(memory=sqlite_instance, finding=finding)).field_values == finding.field_values
+
+
+async def test_update_rejects_sending_and_removing_a_value(
+ sqlite_instance: SQLiteMemory, retired_value_finding_async: Finding
+) -> None:
+ finding = retired_value_finding_async
+ with pytest.raises(FindingValuesError, match=re.escape("'description' can't be both sent and removed.")):
+ await _update_async(finding=finding, field_values=finding.field_values, remove_retired_keys=["description"])
+ assert (await read_finding_async(memory=sqlite_instance, finding=finding)).field_values == finding.field_values
+
+
+async def test_update_rejects_changing_a_retired_value(
+ sqlite_instance: SQLiteMemory, retired_value_finding_async: Finding
+) -> None:
+ finding = retired_value_finding_async
+ message = "Finding field 'description' is no longer configured, so its saved value can't change."
+ with pytest.raises(FindingValuesError, match=re.escape(message)):
+ await _update_async(finding=finding, field_values={"steps": "1. Open", "description": "New text"})
+ assert (await read_finding_async(memory=sqlite_instance, finding=finding)).field_values == finding.field_values
+
+
+async def test_update_keeps_an_unchanged_severity_that_is_no_longer_an_option(
+ sqlite_instance: SQLiteMemory, unmatched_severity_finding_async: Finding
+) -> None:
+ finding = unmatched_severity_finding_async
+ updated = await _update_async(finding=finding, severity="low", field_values={"steps": "1. Open"})
+ assert updated.severity == "low"
+ assert (await read_finding_async(memory=sqlite_instance, finding=finding)).severity == "low"
+
+
+async def test_update_rejects_changing_to_another_severity_that_is_not_an_option(
+ sqlite_instance: SQLiteMemory, unmatched_severity_finding_async: Finding
+) -> None:
+ finding = unmatched_severity_finding_async
+ with pytest.raises(FindingValuesError, match=re.escape("Severity has no option 'medium'.")):
+ await _update_async(finding=finding, severity="medium", field_values={"steps": "1. Open"})
+ assert (await read_finding_async(memory=sqlite_instance, finding=finding)).severity == "low"
diff --git a/tests/unit/backend/test_operations.py b/tests/unit/backend/test_operations.py
index 2a907c9431..4fb042c615 100644
--- a/tests/unit/backend/test_operations.py
+++ b/tests/unit/backend/test_operations.py
@@ -18,6 +18,7 @@
from pyrit.memory import CentralMemory, SQLiteMemory
from pyrit.memory.memory_models import AttackResultEntry, Base, PromptMemoryEntry
from pyrit.models import (
+ DEFAULT_FINDING_FIELDS,
AttackOutcome,
AttackResult,
ComponentIdentifier,
@@ -25,12 +26,15 @@
ConversationType,
Finding,
FindingEvidence,
+ FindingField,
+ FindingFields,
Message,
MessagePiece,
Operation,
Score,
)
-from pyrit.models.harm_category import HarmCategory
+from pyrit.setup.finding_fields import reset_active_finding_fields, set_active_finding_fields
+from unit.backend.mocks import read_finding_async
pytestmark = pytest.mark.usefixtures("patch_central_database")
@@ -139,7 +143,7 @@ def test_findings_are_created_and_listed_under_an_operation(compatibility_header
assert created.status_code == 201
finding = created.json()
assert finding["operation_id"] == operation_id
- assert finding["description"] == ""
+ assert finding["field_values"] == {}
client.post(f"/api/operations/{other_id}/findings", json={"title": "Elsewhere", "severity": "low"})
listed = client.get(f"/api/operations/{operation_id}/findings", params={"limit": 1}).json()
assert listed == {"items": [{**finding, "evidence_count": 0}], "has_more": False, "next_offset": None}
@@ -147,48 +151,75 @@ def test_findings_are_created_and_listed_under_an_operation(compatibility_header
assert counts == {"Case": {"informational": 1}, "Other": {"low": 1}}
-def test_finding_options_exposes_canonical_harm_types(compatibility_headers: dict[str, str]) -> None:
+@pytest.mark.usefixtures("contoso_finding_fields")
+async def test_findings_list_in_the_active_severity_order_async(
+ sqlite_instance: SQLiteMemory, compatibility_headers: dict[str, str]
+) -> None:
client = TestClient(app, headers=compatibility_headers)
- response = client.get("/api/operations/finding-options")
- assert response.status_code == 200
- assert response.json() == {"harm_types": [category.value for category in HarmCategory]}
- assert response.json()["harm_types"].count("Other") == 1
- assert TestClient(app).get("/api/operations/finding-options").status_code != 200
+ operation_id = client.post("/api/operations", json={"name": "Contoso"}).json()["id"]
+ path = f"/api/operations/{operation_id}/findings"
+ steps = {"steps": "1. Open"}
+ assert client.post(path, json={"title": "Second", "severity": "sev2", "field_values": steps}).status_code == 201
+ await sqlite_instance.add_finding_async(
+ finding=Finding(operation_id=UUID(operation_id), title="Unmatched", severity="Sev 2.5", field_values=steps)
+ )
+ assert client.post(path, json={"title": "First", "severity": "sev1", "field_values": steps}).status_code == 201
+ listed = client.get(path).json()["items"]
+ assert [item["severity"] for item in listed] == ["sev1", "sev2", "Sev 2.5"]
+
+
+def test_finding_fields_returns_the_active_fields(
+ compatibility_headers: dict[str, str], contoso_finding_fields: FindingFields
+) -> None:
+ client = TestClient(app, headers=compatibility_headers)
+ assert FindingFields.model_validate(client.get("/api/operations/finding-fields").json()) == contoso_finding_fields
+ reset_active_finding_fields()
+ assert FindingFields.model_validate(client.get("/api/operations/finding-fields").json()) == DEFAULT_FINDING_FIELDS
+ assert client.get("/api/operations/finding-options").status_code != 200
+ assert TestClient(app).get("/api/operations/finding-fields").status_code != 200
+
+
+@pytest.mark.usefixtures("contoso_finding_fields")
+def test_finding_create_is_checked_against_the_active_fields(compatibility_headers: dict[str, str]) -> None:
+ client = TestClient(app, headers=compatibility_headers)
+ operation_id = client.post("/api/operations", json={"name": "Contoso"}).json()["id"]
+ path = f"/api/operations/{operation_id}/findings"
+ rejected = client.post(path, json={"title": "Leak", "severity": "sev1"})
+ assert rejected.status_code == 422
+ assert rejected.json()["detail"] == "Steps to reproduce is required."
+ assert client.get(path).json()["items"] == []
+ created = client.post(path, json={"title": "Leak", "severity": "sev1", "field_values": {"steps": "1. Open"}})
+ assert created.status_code == 201
-def test_finding_api_round_trips_optional_and_custom_classifications(compatibility_headers: dict[str, str]) -> None:
+def test_finding_api_round_trips_field_values_and_drops_blanks(compatibility_headers: dict[str, str]) -> None:
client = TestClient(app, headers=compatibility_headers)
- operation_id = client.post("/api/operations", json={"name": "Classifications"}).json()["id"]
+ operation_id = client.post("/api/operations", json={"name": "Field values"}).json()["id"]
path = f"/api/operations/{operation_id}/findings"
body = {
"title": "Custom",
- "severity": "other",
- "severity_other": " Team severity ",
- "harm_type": "Other",
- "harm_type_other": " Team harm ",
+ "severity": " Team severity ",
+ "field_values": {"harm_type": "Malware", "description": " "},
}
response = client.post(path, json=body)
assert response.status_code == 201
finding = response.json()
- assert {key: finding[key] for key in body} == body
+ assert finding["severity"] == "Team severity"
+ assert finding["field_values"] == {"harm_type": "Malware"}
assert client.get(path).json()["items"] == [{**finding, "evidence_count": 0}]
- updated = client.put(f"{path}/{finding['id']}", json={"title": "Edited", "severity": "low", "harm_type": "Malware"})
+ updated = client.put(f"{path}/{finding['id']}", json={"title": "Edited", "severity": "low"})
assert updated.status_code == 200
- assert updated.json()["harm_type"] == "Malware"
- assert updated.json()["severity_other"] is None
- assert updated.json()["harm_type_other"] is None
+ assert updated.json()["field_values"] == {}
assert updated.json()["id"] == finding["id"]
assert updated.json()["created_at"] == finding["created_at"]
for invalid in [
- {"severity": "other"},
- {"severity": "low", "severity_other": "Stray"},
- {"severity": "low", "harm_type": "Other"},
- {"severity": "low", "harm_type": "Unknown"},
+ {"severity": " "},
+ {"severity": "low", "harm_type": "Malware"},
+ {"severity": "low", "field_values": {"harm_type": 3}},
]:
assert client.post(path, json={"title": "Invalid", **invalid}).status_code == 422
assert client.put(f"{path}/{finding['id']}", json={"title": "Invalid", **invalid}).status_code == 422
- cleared = client.put(f"{path}/{finding['id']}", json={"title": "No harm type", "severity": "moderate"})
- assert cleared.json()["harm_type"] is None
+ assert client.get(path).json()["items"] == [{**updated.json(), "evidence_count": 0}]
def test_findings_require_an_existing_operation(compatibility_headers: dict[str, str]) -> None:
@@ -265,7 +296,8 @@ def test_finding_create_saves_nothing_when_its_evidence_cannot_be_linked(
("/api/operations", {"name": " "}),
("/api/operations", {"name": "😀" * 65}),
("findings", {"title": "", "severity": "low"}),
- ("findings", {"title": "Assessment", "severity": "urgent"}),
+ ("findings", {"title": "Assessment", "severity": " "}),
+ ("findings", {"title": "Assessment", "severity": "low", "description": "x"}),
("findings", {"title": "Assessment", "severity": "low", "operation": "Case"}),
],
)
@@ -297,9 +329,16 @@ def test_finding_update_and_delete_preserve_identity_and_operation(
path = f"/api/operations/{operation['id']}/findings"
original = client.post(path, json={"title": "Original", "severity": "low"}).json()
finding_path = f"{path}/{original['id']}"
- edited = client.put(finding_path, json={"title": "Edited", "severity": "critical", "description": "Notes"})
+ edited = client.put(
+ finding_path, json={"title": "Edited", "severity": "critical", "field_values": {"description": "Notes"}}
+ )
assert edited.status_code == 200
- assert edited.json() == {**original, "title": "Edited", "severity": "critical", "description": "Notes"}
+ assert edited.json() == {
+ **original,
+ "title": "Edited",
+ "severity": "critical",
+ "field_values": {"description": "Notes"},
+ }
assert client.get(path).json()["items"] == [{**edited.json(), "evidence_count": 0}]
deleted = client.delete(finding_path)
assert deleted.status_code == 204
@@ -326,7 +365,8 @@ def test_finding_mutations_reject_wrong_operations_and_invalid_fields(
finding_path = f"{path}/{original['id']}"
for invalid in [
{**body, "title": " "},
- {**body, "severity": "urgent"},
+ {"title": "Assessment", "severity": " "},
+ {"title": "Assessment", "severity": "low", "description": "x"},
{**body, "operation_id": other_id},
{**body, "created_at": original["created_at"]},
]:
@@ -336,6 +376,105 @@ def test_finding_mutations_reject_wrong_operations_and_invalid_fields(
assert client.get(path).json()["items"] == [{**original, "evidence_count": 0}]
+async def test_update_keeps_an_unchanged_retired_value_async(
+ sqlite_instance: SQLiteMemory, retired_value_finding_async: Finding, compatibility_headers: dict[str, str]
+) -> None:
+ finding = retired_value_finding_async
+ response = TestClient(app, headers=compatibility_headers).put(
+ f"/api/operations/{finding.operation_id}/findings/{finding.id}",
+ json={"title": "Leak", "severity": "sev1", "field_values": {"steps": "1. Open", "description": "Old text"}},
+ )
+ assert response.status_code == 200
+ stored = await read_finding_async(memory=sqlite_instance, finding=finding)
+ assert stored.field_values == {"steps": "1. Open", "description": "Old text"}
+
+
+async def test_update_rejects_a_changed_retired_value_async(
+ sqlite_instance: SQLiteMemory, retired_value_finding_async: Finding, compatibility_headers: dict[str, str]
+) -> None:
+ finding = retired_value_finding_async
+ client = TestClient(app, headers=compatibility_headers)
+ path = f"/api/operations/{finding.operation_id}/findings"
+ body = {"title": "Leak", "severity": "sev1", "field_values": {"steps": "1. Open", "description": "New text"}}
+ response = client.put(f"{path}/{finding.id}", json=body)
+ assert response.status_code == 422
+ assert (
+ response.json()["detail"]
+ == "Finding field 'description' is no longer configured, so its saved value can't change."
+ )
+ stored = await read_finding_async(memory=sqlite_instance, finding=finding)
+ assert stored.field_values == {"steps": "1. Open", "description": "Old text"}
+ assert client.put(f"{path}/{MISSING_ID}", json=body).status_code == 404
+
+
+async def test_update_removes_a_listed_retired_value_async(
+ sqlite_instance: SQLiteMemory, retired_value_finding_async: Finding, compatibility_headers: dict[str, str]
+) -> None:
+ finding = retired_value_finding_async
+ response = TestClient(app, headers=compatibility_headers).put(
+ f"/api/operations/{finding.operation_id}/findings/{finding.id}",
+ json={
+ "title": "Leak",
+ "severity": "sev1",
+ "field_values": {"steps": "1. Open"},
+ "remove_retired_keys": ["description"],
+ },
+ )
+ assert response.status_code == 200
+ assert (await read_finding_async(memory=sqlite_instance, finding=finding)).field_values == {"steps": "1. Open"}
+
+
+async def test_update_keeps_a_current_value_that_a_stale_client_resends_async(
+ sqlite_instance: SQLiteMemory,
+ retired_value_finding_async: Finding,
+ contoso_finding_fields: FindingFields,
+ compatibility_headers: dict[str, str],
+) -> None:
+ finding = retired_value_finding_async
+ # A live apply makes ``description`` current again, while the client still shows it as retired.
+ reset_active_finding_fields()
+ description = FindingField(key="description", label="Description", type="multiline")
+ set_active_finding_fields(
+ finding_fields=FindingFields(
+ severity=contoso_finding_fields.severity, fields=[*contoso_finding_fields.fields, description]
+ ),
+ source="test",
+ )
+ response = TestClient(app, headers=compatibility_headers).put(
+ f"/api/operations/{finding.operation_id}/findings/{finding.id}",
+ json={"title": "Leak", "severity": "sev1", "field_values": {"steps": "1. Open", "description": "Old text"}},
+ )
+ assert response.status_code == 200
+ stored = await read_finding_async(memory=sqlite_instance, finding=finding)
+ assert stored.field_values == {"steps": "1. Open", "description": "Old text"}
+
+
+async def test_update_keeps_an_unchanged_severity_that_is_no_longer_an_option_async(
+ sqlite_instance: SQLiteMemory, unmatched_severity_finding_async: Finding, compatibility_headers: dict[str, str]
+) -> None:
+ finding = unmatched_severity_finding_async
+ response = TestClient(app, headers=compatibility_headers).put(
+ f"/api/operations/{finding.operation_id}/findings/{finding.id}",
+ json={"title": "Leak", "severity": "low", "field_values": {"steps": "1. Open"}},
+ )
+ assert response.status_code == 200
+ assert response.json()["severity"] == "low"
+ assert (await read_finding_async(memory=sqlite_instance, finding=finding)).severity == "low"
+
+
+async def test_update_rejects_changing_to_another_severity_that_is_not_an_option_async(
+ sqlite_instance: SQLiteMemory, unmatched_severity_finding_async: Finding, compatibility_headers: dict[str, str]
+) -> None:
+ finding = unmatched_severity_finding_async
+ response = TestClient(app, headers=compatibility_headers).put(
+ f"/api/operations/{finding.operation_id}/findings/{finding.id}",
+ json={"title": "Leak", "severity": "medium", "field_values": {"steps": "1. Open"}},
+ )
+ assert response.status_code == 422
+ assert response.json()["detail"] == "Severity has no option 'medium'."
+ assert (await read_finding_async(memory=sqlite_instance, finding=finding)).severity == "low"
+
+
@pytest.fixture
async def evidence_source_async(sqlite_instance: SQLiteMemory) -> tuple[Operation, Finding, AttackResult]:
return await _store_evidence_source_async(sqlite_instance)
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index 544741a1c2..d69bfdf01d 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -7,7 +7,7 @@
import tempfile
import time
import weakref
-from collections.abc import AsyncGenerator, Generator
+from collections.abc import AsyncGenerator, Generator, Iterator
from contextlib import closing, contextmanager
from typing import Any
from unittest.mock import AsyncMock, patch
@@ -22,6 +22,7 @@
from pyrit.datasets import SeedDatasetProvider
from pyrit.memory import CentralMemory, SQLiteMemory, migration
from pyrit.models import SeedDataset
+from pyrit.setup.finding_fields import reset_active_finding_fields
from test_support.fixtures import use_sqlite_memory
# This limits retries and speeds up execution
@@ -171,3 +172,10 @@ def patch_central_database(sqlite_instance):
"""Fixture to mock CentralMemory.get_memory_instance"""
with patch.object(CentralMemory, "get_memory_instance", return_value=sqlite_instance) as sqlite_memory:
yield sqlite_memory
+
+
+@pytest.fixture
+def reset_finding_fields() -> Iterator[None]:
+ """Restore the default finding fields after the test."""
+ yield
+ reset_active_finding_fields()
diff --git a/tests/unit/memory/test_finding_field_values_migration.py b/tests/unit/memory/test_finding_field_values_migration.py
new file mode 100644
index 0000000000..bad10ae242
--- /dev/null
+++ b/tests/unit/memory/test_finding_field_values_migration.py
@@ -0,0 +1,230 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import importlib
+import io
+import json
+import re
+from pathlib import Path
+
+import pytest
+import sqlalchemy as sa
+from alembic import command
+from alembic.config import Config
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from sqlalchemy.dialects import mssql, sqlite
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.schema import CreateTable
+
+from pyrit.memory.memory_models import FindingEntry
+from pyrit.memory.migration import check_schema_migrations
+
+PREVIOUS_REVISION = "e7c4a2d9f013"
+REVISION = "d43b1bcf9c56"
+CHECKS = ("ck_findings_severity", "ck_findings_severity_other", "ck_findings_harm_type_other")
+OPERATION_ID = "00000000-0000-0000-0000-000000000001"
+ATTACK_ID = "00000000-0000-0000-0000-0000000000aa"
+A, B, C, D = (f"00000000-0000-0000-0000-00000000010{number}" for number in range(1, 5))
+STAMP = "2026-10-09 12:00:00"
+
+
+def _config_for(connection: sa.Connection) -> Config:
+ config = Config()
+ config.set_main_option("script_location", str(Path(__file__).resolve().parents[3] / "pyrit" / "memory" / "alembic"))
+ config.attributes["connection"] = connection
+ config.attributes["version_table"] = "pyrit_memory_alembic_version"
+ return config
+
+
+def _migration():
+ return importlib.import_module("pyrit.memory.alembic.versions.d43b1bcf9c56_store_finding_field_values")
+
+
+def _columns(inspector: sa.Inspector, table: str) -> list[tuple[str, str, bool]]:
+ return [(c["name"], str(c["type"]), c["nullable"]) for c in inspector.get_columns(table)]
+
+
+def _insert_operation(connection: sa.Connection) -> None:
+ connection.execute(
+ sa.text(
+ "INSERT INTO OperationEntries (id, name, name_key, created_at, description) "
+ "VALUES (:id, 'Contoso review', 'contoso review', :stamp, '')"
+ ),
+ {"id": OPERATION_ID, "stamp": STAMP},
+ )
+
+
+def _insert_finding(*, connection: sa.Connection, **values: str) -> None:
+ row = {"operation_id": OPERATION_ID, "title": "Contoso finding", "created_at": STAMP, **values}
+ connection.execute(
+ sa.text(f"INSERT INTO FindingEntries ({', '.join(row)}) VALUES ({', '.join(f':{name}' for name in row)})"),
+ row,
+ )
+
+
+def test_upgrade_moves_existing_findings_into_field_values() -> None:
+ engine = sa.create_engine("sqlite:///:memory:")
+ try:
+ with engine.begin() as connection:
+ config = _config_for(connection)
+ command.upgrade(config, PREVIOUS_REVISION)
+ _insert_operation(connection)
+ _insert_finding(connection=connection, id=A, severity="important", description="")
+ _insert_finding(
+ connection=connection,
+ id=B,
+ severity="other",
+ severity_other="Sev 2.5",
+ harm_type="Other",
+ harm_type_other="Model theft",
+ description="Steps to reproduce",
+ )
+ connection.execute(
+ sa.text(
+ "INSERT INTO FindingEvidenceEntries (id, finding_id, attack_result_id, attached_at) "
+ "VALUES ('00000000-0000-0000-0000-000000000201', :finding_id, :attack_id, :stamp)"
+ ),
+ {"finding_id": B, "attack_id": ATTACK_ID, "stamp": STAMP},
+ )
+ _insert_finding(connection=connection, id=C, severity="low", harm_type="Jailbreak", description=" ")
+ _insert_finding(connection=connection, id=D, severity="critical", description='Quote " and \\ backslash')
+
+ command.upgrade(config, REVISION)
+
+ rows = {
+ finding_id: (severity, json.loads(field_values))
+ for finding_id, severity, field_values in connection.execute(
+ sa.text("SELECT id, severity, field_values FROM FindingEntries")
+ )
+ }
+ assert rows == {
+ A: ("important", {}),
+ B: ("Sev 2.5", {"harm_type": "Model theft", "description": "Steps to reproduce"}),
+ C: ("low", {"harm_type": "Jailbreak"}),
+ D: ("critical", {"description": 'Quote " and \\ backslash'}),
+ }
+ kept = {
+ finding_id: (operation_id, created_at)
+ for finding_id, operation_id, created_at in connection.execute(
+ sa.text("SELECT id, operation_id, created_at FROM FindingEntries")
+ )
+ }
+ assert kept == dict.fromkeys((A, B, C, D), (OPERATION_ID, STAMP))
+ evidence_rows = [
+ tuple(row)
+ for row in connection.execute(
+ sa.text("SELECT finding_id, attack_result_id FROM FindingEvidenceEntries")
+ )
+ ]
+ assert evidence_rows == [(B, ATTACK_ID)]
+ check_schema_migrations(engine=engine)
+ finally:
+ engine.dispose()
+
+
+def test_upgrade_moves_an_other_severity_stored_in_any_case() -> None:
+ engine = sa.create_engine("sqlite:///:memory:")
+ try:
+ with engine.begin() as connection:
+ config = _config_for(connection)
+ command.upgrade(config, PREVIOUS_REVISION)
+ _insert_operation(connection)
+ # SQL Server's default case-insensitive collation let the old checks accept 'Other'.
+ connection.execute(sa.text("PRAGMA ignore_check_constraints = ON"))
+ _insert_finding(connection=connection, id=A, severity="Other", severity_other="Sev 2.5", description="")
+ connection.execute(sa.text("PRAGMA ignore_check_constraints = OFF"))
+
+ command.upgrade(config, REVISION)
+
+ assert connection.execute(sa.text("SELECT severity FROM FindingEntries")).scalar_one() == "Sev 2.5"
+ finally:
+ engine.dispose()
+
+
+@pytest.mark.parametrize("custom_text", [" Review ", "\tReview\n", "\u00a0Review\u00a0"])
+def test_upgrade_removes_surrounding_whitespace_from_other_severity_text(custom_text: str) -> None:
+ engine = sa.create_engine("sqlite:///:memory:")
+ try:
+ with engine.begin() as connection:
+ config = _config_for(connection)
+ command.upgrade(config, PREVIOUS_REVISION)
+ _insert_operation(connection)
+ _insert_finding(connection=connection, id=A, severity="other", severity_other=custom_text, description="")
+
+ command.upgrade(config, REVISION)
+
+ assert connection.execute(sa.text("SELECT severity FROM FindingEntries")).scalar_one() == "Review"
+ finally:
+ engine.dispose()
+
+
+def test_upgrade_drops_the_finding_check_constraints() -> None:
+ engine = sa.create_engine("sqlite:///:memory:")
+ try:
+ with engine.begin() as connection:
+ command.upgrade(_config_for(connection), REVISION)
+ _insert_operation(connection)
+ _insert_finding(connection=connection, id=A, severity="sev1", field_values='{"category": "x"}')
+ assert connection.execute(sa.text("SELECT severity, field_values FROM FindingEntries")).one() == (
+ "sev1",
+ '{"category": "x"}',
+ )
+ finally:
+ engine.dispose()
+
+
+def test_downgrade_restores_the_old_columns_only_without_findings() -> None:
+ engine = sa.create_engine("sqlite:///:memory:")
+ try:
+ with engine.begin() as connection:
+ config = _config_for(connection)
+ command.upgrade(config, PREVIOUS_REVISION)
+ columns = sorted(_columns(sa.inspect(connection), "FindingEntries"))
+ command.upgrade(config, REVISION)
+ command.downgrade(config, PREVIOUS_REVISION)
+ assert sorted(_columns(sa.inspect(connection), "FindingEntries")) == columns
+ with pytest.raises(IntegrityError, match="CHECK"):
+ _insert_finding(connection=connection, id=A, severity="urgent", description="")
+
+ command.upgrade(config, REVISION)
+ _insert_operation(connection)
+ _insert_finding(connection=connection, id=A, severity="low", field_values="{}")
+ with pytest.raises(ValueError, match="findings"):
+ command.downgrade(config, PREVIOUS_REVISION)
+ assert connection.scalar(sa.text("SELECT id FROM FindingEntries")) == A
+ finally:
+ engine.dispose()
+
+
+def test_downgrade_refuses_offline() -> None:
+ context = MigrationContext.configure(dialect_name="mssql", opts={"as_sql": True, "output_buffer": io.StringIO()})
+ with Operations.context(context), pytest.raises(ValueError, match="online"):
+ _migration().downgrade()
+
+
+def test_upgrade_compiles_for_sql_server(caplog: pytest.LogCaptureFixture) -> None:
+ output = io.StringIO()
+ context = MigrationContext.configure(dialect_name="mssql", opts={"as_sql": True, "output_buffer": output})
+ with Operations.context(context):
+ _migration().upgrade()
+ ddl = output.getvalue()
+ assert "Skipping custom severity trimming" in caplog.text
+ assert _migration().down_revision == "e7c4a2d9f013"
+ for name in CHECKS:
+ assert f"DROP CONSTRAINT {name}" in ddl
+ drops = [match.start() for match in re.finditer("DROP CONSTRAINT", ddl)]
+ assert len(drops) == 3 and max(drops) < ddl.index("ALTER COLUMN severity")
+ assert "COLLATE Latin1_General_100_BIN2" in ddl
+ assert "SET severity = severity_other WHERE LOWER(severity) = 'other'" in ddl
+ assert "JSON_MODIFY" in ddl
+ for column in ("description", "severity_other", "harm_type", "harm_type_other"):
+ assert f"DROP COLUMN {column}" in ddl
+
+
+def test_finding_severity_is_binary_collated_only_on_sql_server() -> None:
+ table = FindingEntry.__table__
+ assert "severity NVARCHAR(max) COLLATE Latin1_General_100_BIN2 NOT NULL" in str(
+ CreateTable(table).compile(dialect=mssql.dialect())
+ )
+ assert "COLLATE" not in str(CreateTable(table).compile(dialect=sqlite.dialect()))
diff --git a/tests/unit/memory/test_findings.py b/tests/unit/memory/test_findings.py
index 94d45c0e7a..419e264363 100644
--- a/tests/unit/memory/test_findings.py
+++ b/tests/unit/memory/test_findings.py
@@ -9,7 +9,7 @@
import pytest
from pydantic import ValidationError
-from sqlalchemy import func, insert, select
+from sqlalchemy import func, select
from sqlalchemy.dialects import mssql
from sqlalchemy.engine import ScalarResult
from sqlalchemy.exc import IntegrityError
@@ -24,11 +24,12 @@
PromptMemoryEntry,
ScoreEntry,
)
-from pyrit.models import Finding, FindingCreate, FindingEvidence, FindingSeverity, Operation
-from pyrit.models.harm_category import HarmCategory
+from pyrit.models import Finding, FindingCreate, FindingEvidence, Operation
pytestmark = pytest.mark.usefixtures("patch_central_database")
+SEVERITY_ORDER = ["critical", "important", "moderate", "low", "informational"]
+
async def test_operations_are_unique_by_trimmed_case_folded_name(sqlite_instance: SQLiteMemory) -> None:
saved = await sqlite_instance.add_operation_async(Operation(name=" Operation A "))
@@ -123,34 +124,14 @@ async def test_operation_identity_follows_casefold_not_database_collation(
assert len(await sqlite_instance.get_operations_async()) == (1 if same else 2)
-@pytest.mark.parametrize(
- ("stored", "harm_type", "harm_type_other"),
- [("Malwar", HarmCategory.OTHER, "Malwar"), ("malware", HarmCategory.MALWARE, None)],
-)
-async def test_unknown_stored_harm_type_stays_readable(
- sqlite_instance: SQLiteMemory,
- caplog: pytest.LogCaptureFixture,
- stored: str,
- harm_type: HarmCategory,
- harm_type_other: str | None,
-) -> None:
- operation = await sqlite_instance.add_operation_async(Operation(name="Written elsewhere"))
- with sqlite_instance.engine.begin() as connection:
- connection.execute(
- insert(FindingEntry).values(
- id=uuid4(),
- operation_id=operation.id,
- title="Stored outside PyRIT",
- description="",
- severity="low",
- harm_type=stored,
- created_at=datetime.now(UTC),
- )
- )
- with caplog.at_level("WARNING"):
- [finding] = await sqlite_instance.get_findings_async(operation_id=operation.id)
- assert (finding.harm_type, finding.harm_type_other) == (harm_type, harm_type_other)
- assert ("Unknown harm category 'Malwar'" in caplog.text) is (harm_type_other is not None)
+async def test_stored_field_values_read_back_unchanged(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Retired values"))
+ field_values = {"harm_type": "Not a current category", "retired_key": "kept"}
+ saved = await sqlite_instance.add_finding_async(
+ finding=Finding(operation_id=operation.id, title="Stored", severity="low", field_values=field_values)
+ )
+ read = await sqlite_instance.get_finding_async(operation_id=operation.id, finding_id=saved.id)
+ assert read.field_values == field_values
async def test_findings_paginate_within_one_operation(sqlite_instance: SQLiteMemory) -> None:
@@ -164,8 +145,10 @@ async def test_findings_paginate_within_one_operation(sqlite_instance: SQLiteMem
)
)
await sqlite_instance.add_finding_async(finding=Finding(operation_id=other.id, title="Other", severity="critical"))
- first = await sqlite_instance.get_findings_async(operation_id=operation.id, limit=2)
- second = await sqlite_instance.get_findings_async(operation_id=operation.id, limit=2, offset=2)
+ first = await sqlite_instance.get_findings_async(operation_id=operation.id, limit=2, severity_order=SEVERITY_ORDER)
+ second = await sqlite_instance.get_findings_async(
+ operation_id=operation.id, limit=2, offset=2, severity_order=SEVERITY_ORDER
+ )
assert [finding.title for finding in first + second] == ["3", "2", "1", "4"]
async with await sqlite_instance.get_session_async() as session:
for table in [AttackResultEntry, PromptMemoryEntry, ScoreEntry]:
@@ -211,55 +194,41 @@ async def test_update_finding_preserves_identity_and_reorders_severity(sqlite_in
updated = await sqlite_instance.update_finding_async(
operation_id=operation.id,
finding_id=original.id,
- request=FindingCreate(title="Edited", severity="critical", description="Notes"),
+ request=FindingCreate(title="Edited", severity="critical", field_values={"description": "Notes"}),
)
assert updated.id == original.id
assert updated.operation_id == original.operation_id
assert updated.created_at == original.created_at
assert updated.title == "Edited"
- assert updated.description == "Notes"
- assert [finding.title for finding in await sqlite_instance.get_findings_async()] == ["Edited", "Other"]
-
-
-async def test_finding_classifications_persist_and_clear_without_losing_evidence(sqlite_instance: SQLiteMemory) -> None:
- operation = await sqlite_instance.add_operation_async(Operation(name="Classifications"))
- original = Finding.model_validate(
- {
- "operation_id": operation.id,
- "title": "Custom",
- "severity": "other",
- "severity_other": " Review needed ",
- "harm_type": "Other",
- "harm_type_other": " Team harm ",
- }
+ assert updated.field_values == {"description": "Notes"}
+ assert [finding.title for finding in await sqlite_instance.get_findings_async(severity_order=SEVERITY_ORDER)] == [
+ "Edited",
+ "Other",
+ ]
+
+
+async def test_update_finding_replaces_field_values_without_losing_evidence(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Field values"))
+ original = Finding(operation_id=operation.id, title="Values", severity="low", field_values={"a": "1", "b": "2"})
+ saved = await sqlite_instance.add_finding_async(
+ finding=original, evidence=FindingEvidence(finding_id=original.id, attack_result_id=uuid4())
)
- saved = await sqlite_instance.add_finding_async(finding=original)
assert saved == original
- evidence = FindingEvidence(finding_id=saved.id, attack_result_id=uuid4())
- await sqlite_instance.add_finding_evidence_async(evidence=evidence)
- read = await sqlite_instance.get_finding_async(operation_id=operation.id, finding_id=saved.id)
- assert read == original
updated = await sqlite_instance.update_finding_async(
operation_id=operation.id,
finding_id=saved.id,
- request=FindingCreate(title="Canonical", severity="important", harm_type="Malware"),
+ request=FindingCreate(title="Values", severity="low", field_values={"a": "1"}),
)
- assert updated.id == saved.id
- assert updated.created_at == saved.created_at
- assert updated.severity_other is None
- assert updated.harm_type.value == "Malware"
- assert updated.harm_type_other is None
- cleared = await sqlite_instance.update_finding_async(
- operation_id=operation.id, finding_id=saved.id, request=FindingCreate(title="Optional", severity="low")
- )
- assert cleared.harm_type is None
- assert await sqlite_instance.get_finding_evidence_async(finding_id=saved.id, limit=20, offset=0) == [evidence]
+ assert (updated.id, updated.created_at) == (saved.id, saved.created_at)
+ read = await sqlite_instance.get_finding_async(operation_id=operation.id, finding_id=saved.id)
+ assert read.field_values == {"a": "1"}
+ assert await sqlite_instance.get_finding_evidence_counts_async(finding_ids=[saved.id]) == {saved.id: 1}
-async def test_custom_severity_sorts_after_every_preset(sqlite_instance: SQLiteMemory) -> None:
- operation = await sqlite_instance.add_operation_async(Operation(name="Order"))
+async def test_get_findings_sorts_unlisted_severities_after_listed_ones(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Partial order"))
stamp = datetime(2026, 10, 9, tzinfo=UTC)
- for number, severity in enumerate(["other", "informational", "low", "moderate", "important", "critical"], start=1):
+ for number, severity in enumerate(["low", "critical", "moderate"], start=1):
await sqlite_instance.add_finding_async(
finding=Finding.model_validate(
{
@@ -268,18 +237,27 @@ async def test_custom_severity_sorts_after_every_preset(sqlite_instance: SQLiteM
"operation_id": operation.id,
"title": severity,
"severity": severity,
- "severity_other": "Critical" if severity == "other" else None,
}
)
)
- assert [finding.title for finding in await sqlite_instance.get_findings_async()] == [
- "critical",
- "important",
- "moderate",
- "low",
- "informational",
- "other",
- ]
+ found = await sqlite_instance.get_findings_async(severity_order=["low"])
+ assert [finding.title for finding in found] == ["low", "moderate", "critical"]
+
+
+async def test_get_findings_without_severity_order_sorts_newest_first(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Newest first"))
+ for number, (title, severity) in enumerate([("Old critical", "critical"), ("New low", "low")], start=1):
+ await sqlite_instance.add_finding_async(
+ finding=Finding.model_validate(
+ {
+ "operation_id": operation.id,
+ "title": title,
+ "severity": severity,
+ "created_at": datetime(2026, 10, number, tzinfo=UTC),
+ }
+ )
+ )
+ assert [finding.title for finding in await sqlite_instance.get_findings_async()] == ["New low", "Old critical"]
async def test_finding_mutations_are_scoped_and_delete_only_the_finding(sqlite_instance: SQLiteMemory) -> None:
@@ -430,7 +408,9 @@ async def test_get_findings_by_ids_follows_dashboard_order_and_scope(sqlite_inst
)
requested = [saved["Moderate"].id, foreign.id, saved["Low"].id, uuid4(), saved["Newer critical"].id]
requested += [saved["Older critical"].id, saved["Low"].id]
- findings = await sqlite_instance.get_findings_by_ids_async(operation_id=operation.id, finding_ids=requested)
+ findings = await sqlite_instance.get_findings_by_ids_async(
+ operation_id=operation.id, finding_ids=requested, severity_order=SEVERITY_ORDER
+ )
assert [finding.title for finding in findings] == ["Newer critical", "Older critical", "Moderate", "Low"]
@@ -494,10 +474,9 @@ async def test_finding_severity_counts_group_by_operation(sqlite_instance: SQLit
finding=Finding(operation_id=operation.id, title="A", severity=severity)
)
await sqlite_instance.add_finding_async(finding=Finding(operation_id=other.id, title="B", severity="informational"))
- assert await sqlite_instance.get_finding_severity_counts_async() == {
- operation.id: {FindingSeverity.CRITICAL: 2, FindingSeverity.LOW: 1},
- other.id: {FindingSeverity.INFORMATIONAL: 1},
- }
+ result = await sqlite_instance.get_finding_severity_counts_async()
+ assert result == {operation.id: {"critical": 2, "low": 1}, other.id: {"informational": 1}}
+ assert all(type(key) is str for counts in result.values() for key in counts)
async def test_delete_finding_removes_evidence_without_deleting_source(sqlite_instance: SQLiteMemory) -> None:
diff --git a/tests/unit/memory/test_operations_and_findings_migration.py b/tests/unit/memory/test_operations_and_findings_migration.py
index 651487c469..423157bfd5 100644
--- a/tests/unit/memory/test_operations_and_findings_migration.py
+++ b/tests/unit/memory/test_operations_and_findings_migration.py
@@ -19,7 +19,6 @@
from pyrit.memory.memory_models import (
AttackResultEntry,
- FindingEntry,
FindingEvidenceEntry,
OperationEntry,
ScenarioResultEntry,
@@ -30,6 +29,24 @@
REVISION = "c8d3e5f7a901"
TABLES = {"OperationEntries", "FindingEntries", "FindingEvidenceEntries"}
ACTIVITY_TABLES = ("AttackResultEntries", "ScenarioResultEntries")
+# FindingEntry models the head revision, which no longer has this revision's finding columns.
+FINDINGS = sa.table(
+ "FindingEntries",
+ *(
+ sa.column(name)
+ for name in (
+ "id",
+ "operation_id",
+ "title",
+ "description",
+ "severity",
+ "severity_other",
+ "harm_type",
+ "harm_type_other",
+ "created_at",
+ )
+ ),
+)
def _config_for(connection: sa.Connection) -> Config:
@@ -65,6 +82,7 @@ def test_migration_adds_tables_without_changing_existing_ones() -> None:
assert {name: columns for name, columns in after.items() if name not in ACTIVITY_TABLES} == {
name: columns for name, columns in existing.items() if name not in ACTIVITY_TABLES
}
+ # check_schema_migrations compares the database with the head models.
command.upgrade(config, "head")
check_schema_migrations(engine=engine)
finally:
@@ -129,28 +147,28 @@ def test_migration_enforces_unique_evidence_source() -> None:
],
)
def test_migration_rejects_inconsistent_finding_classifications(invalid: dict[str, str]) -> None:
- valid = {"title": "Finding", "description": "", "severity": "low", "created_at": datetime.now(UTC)}
+ valid = {"title": "Finding", "description": "", "severity": "low", "created_at": "2026-10-09 12:00:00"}
engine = sa.create_engine("sqlite:///:memory:")
try:
with engine.begin() as connection:
command.upgrade(_config_for(connection), REVISION)
- insert = sa.insert(FindingEntry)
- connection.execute(insert.values(id=uuid4(), operation_id=uuid4(), **valid))
+ insert = sa.insert(FINDINGS)
+ connection.execute(insert.values(id=str(uuid4()), operation_id=str(uuid4()), **valid))
# Harm types are unconstrained so HarmCategory can change without a migration.
connection.execute(
- insert.values(id=uuid4(), operation_id=uuid4(), **valid, harm_type="Not a current category")
+ insert.values(id=str(uuid4()), operation_id=str(uuid4()), **valid, harm_type="Not a current category")
)
connection.execute(
insert.values(
- id=uuid4(),
- operation_id=uuid4(),
+ id=str(uuid4()),
+ operation_id=str(uuid4()),
**{**valid, "severity": "other", "severity_other": "Custom"},
harm_type="Other",
harm_type_other="Custom",
)
)
with pytest.raises(IntegrityError, match="CHECK"):
- connection.execute(insert.values(id=uuid4(), operation_id=uuid4(), **{**valid, **invalid}))
+ connection.execute(insert.values(id=str(uuid4()), operation_id=str(uuid4()), **{**valid, **invalid}))
finally:
engine.dispose()
diff --git a/tests/unit/memory/test_score_participation_migration.py b/tests/unit/memory/test_score_participation_migration.py
index ba0bf556ce..4b4bc12f29 100644
--- a/tests/unit/memory/test_score_participation_migration.py
+++ b/tests/unit/memory/test_score_participation_migration.py
@@ -60,7 +60,7 @@ def test_private_score_participation_has_unique_head_and_upgrades_current_parent
config = Config()
config.set_main_option("script_location", str(Path(__file__).resolve().parents[3] / "pyrit" / "memory" / "alembic"))
scripts = ScriptDirectory.from_config(config)
- assert scripts.get_heads() == ["e7c4a2d9f013"]
+ assert scripts.get_heads() == ["d43b1bcf9c56"]
assert scripts.get_revision("e7c4a2d9f013").down_revision == "c8d3e5f7a901"
engine = sa.create_engine("sqlite:///:memory:")
try:
diff --git a/tests/unit/models/test_finding.py b/tests/unit/models/test_finding.py
index e334e332fb..48dd7951a2 100644
--- a/tests/unit/models/test_finding.py
+++ b/tests/unit/models/test_finding.py
@@ -7,79 +7,42 @@
from pydantic import ValidationError
from pyrit.models.finding import Finding, FindingCreate
-from pyrit.models.harm_category import HarmCategory
OPERATION_ID = UUID(int=1)
-@pytest.mark.parametrize("severity", ["critical", "important", "moderate", "low", "informational"])
-def test_finding_round_trips_named_severity(severity: str) -> None:
- finding = Finding(operation_id=OPERATION_ID, title="Assessment", severity=severity)
+def test_finding_round_trips_severity_and_field_values() -> None:
+ finding = Finding(
+ operation_id=OPERATION_ID, title="Assessment", severity="moderate", field_values={"harm_type": "Malware"}
+ )
assert Finding.model_validate_json(finding.model_dump_json()) == finding
- assert finding.description == ""
+ assert FindingCreate(title="Assessment", severity="low").field_values == {}
assert finding.created_at.utcoffset().total_seconds() == 0
-def test_finding_round_trips_custom_classifications_without_normalizing_text() -> None:
- finding = Finding.model_validate(
- {
- "operation_id": OPERATION_ID,
- "title": "Assessment",
- "severity": "other",
- "severity_other": " Team assessment ",
- "harm_type": "Other",
- "harm_type_other": " Team harm ",
- }
+def test_finding_strips_severity_drops_blank_field_values_and_keeps_other_text_exactly() -> None:
+ finding = FindingCreate(
+ title=" Leak ", severity=" Sev 2.5 ", field_values={"steps": "", "owner": " ", "notes": " kept "}
)
- assert finding.severity.value == "other"
- assert finding.severity_other == " Team assessment "
- assert finding.harm_type.value == "Other"
- assert finding.harm_type_other == " Team harm "
- assert Finding.model_validate_json(finding.model_dump_json()) == finding
-
-
-def test_finding_harm_type_is_optional_and_accepts_every_canonical_category() -> None:
- assert FindingCreate(title="Optional", severity="low").harm_type is None
- for category in HarmCategory:
- finding = FindingCreate.model_validate(
- {
- "title": "Classified",
- "severity": "moderate",
- "harm_type": category.value,
- "harm_type_other": "Custom harm" if category == HarmCategory.OTHER else None,
- }
- )
- assert finding.harm_type == category
+ assert finding.title == " Leak "
+ assert finding.severity == "Sev 2.5"
+ assert finding.field_values == {"notes": " kept "}
@pytest.mark.parametrize(
- "fields",
+ "data",
[
- {"severity": "other"},
- {"severity": "other", "severity_other": " "},
- {"severity": "low", "severity_other": "Stray custom severity"},
- {"harm_type": "Other"},
- {"harm_type": "Other", "harm_type_other": " "},
- {"harm_type": "Malware", "harm_type_other": "Stray custom harm"},
- {"harm_type_other": "Custom without category"},
- {"harm_type": "Unknown category"},
+ {"title": " ", "severity": "low"},
+ {"title": "Leak", "severity": " "},
+ {"title": "Leak", "severity": "low", "description": "x"},
+ {"title": "Leak", "severity": "low", "field_values": {"steps": 3}},
],
)
-def test_finding_rejects_inconsistent_classifications(fields: dict[str, str]) -> None:
+def test_finding_rejects_invalid_input(data: dict[str, object]) -> None:
with pytest.raises(ValidationError):
- FindingCreate.model_validate({"title": "Assessment", "severity": "low", **fields})
+ FindingCreate.model_validate(data)
-@pytest.mark.parametrize("title", ["", " "])
-def test_finding_rejects_blank_title(title: str) -> None:
- with pytest.raises(ValidationError):
- FindingCreate(title=title, severity="low")
-
-
-def test_finding_rejects_unknown_input_and_requires_operation() -> None:
- with pytest.raises(ValidationError):
- FindingCreate(title="Assessment", severity="urgent")
- with pytest.raises(ValidationError):
- FindingCreate.model_validate({"title": "Assessment", "severity": "low", "operation": "Case"})
+def test_finding_requires_an_operation() -> None:
with pytest.raises(ValidationError):
Finding(title="Assessment", severity="low")
diff --git a/tests/unit/models/test_finding_fields.py b/tests/unit/models/test_finding_fields.py
new file mode 100644
index 0000000000..f281b29c88
--- /dev/null
+++ b/tests/unit/models/test_finding_fields.py
@@ -0,0 +1,446 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import pytest
+from pydantic import ValidationError
+
+from pyrit.models import (
+ DEFAULT_FINDING_FIELDS,
+ FindingField,
+ FindingFields,
+ FindingValuesError,
+)
+from pyrit.models.finding_fields import FindingFieldOption, FindingValueRow
+from pyrit.models.harm_category import HarmCategory
+
+CONTOSO = FindingFields(
+ severity=FindingField(
+ key="severity",
+ label="Severity",
+ type="choice",
+ section="Severity",
+ colors={"sev1": "red", "sev2": "yellow", "sev3": "blue"},
+ levels=[("severity_rationale", "Severity rationale")],
+ options={
+ ("sev1", "Sev 1"): [("remote_no_auth", "Remote, no sign-in needed")],
+ ("sev2", "Sev 2"): [("signed_in_user", "Needs a signed-in user")],
+ ("sev3", "Sev 3"): [("admin_only", "Needs admin access")],
+ },
+ ),
+ fields=[
+ FindingField(
+ key="category",
+ label="Category",
+ type="choice",
+ required=True,
+ section="Classification",
+ levels=[("subcategory", "Subcategory")],
+ options={
+ ("model_security", "Model security"): [
+ ("system_prompt_leak", "System prompt leak"),
+ ("tool_misuse", "Tool misuse"),
+ ],
+ ("content_safety", "Content safety"): [("hate_speech", "Hate speech"), ("self_harm", "Self-harm")],
+ },
+ ),
+ FindingField(
+ key="technique",
+ label="Technique",
+ type="choice",
+ section="Reproduction",
+ allow_other=True,
+ options=[("role_play", "Role play"), ("multi_turn", "Multi-turn escalation")],
+ ),
+ FindingField(
+ key="steps",
+ label="Steps to reproduce",
+ type="multiline",
+ required=True,
+ section="Reproduction",
+ hint="List the prompts in the order you sent them.",
+ ),
+ FindingField(key="owner", label="Owner", type="text", section="Tracking", internal=True),
+ ],
+)
+
+
+def test_finding_field_normalizes_pairs_and_nested_options() -> None:
+ assert CONTOSO.severity.options[0] == FindingFieldOption(
+ key="sev1",
+ label="Sev 1",
+ children=[FindingFieldOption(key="remote_no_auth", label="Remote, no sign-in needed")],
+ )
+ technique = CONTOSO.fields[1]
+ assert [(option.key, option.label, option.children) for option in technique.options] == [
+ ("role_play", "Role play", []),
+ ("multi_turn", "Multi-turn escalation", []),
+ ]
+
+
+def test_finding_fields_round_trip_through_json() -> None:
+ assert FindingFields.model_validate_json(CONTOSO.model_dump_json()) == CONTOSO
+
+
+def test_severity_slot_is_required_and_ordered() -> None:
+ assert CONTOSO.severity.required is True
+ assert CONTOSO.severity_order == ["sev1", "sev2", "sev3"]
+
+
+def test_field_keys_lists_every_field_and_level_key_except_severity() -> None:
+ assert CONTOSO.field_keys == frozenset(
+ {"severity_rationale", "category", "subcategory", "technique", "steps", "owner"}
+ )
+
+
+def test_default_finding_fields_define_severity_harm_type_and_description() -> None:
+ severity, (harm, description) = DEFAULT_FINDING_FIELDS.severity, DEFAULT_FINDING_FIELDS.fields
+ assert DEFAULT_FINDING_FIELDS.severity_order == ["critical", "important", "moderate", "low", "informational"]
+ assert [option.label for option in severity.options] == [
+ "Critical",
+ "Important",
+ "Moderate",
+ "Low",
+ "Informational",
+ ]
+ assert (severity.default, severity.allow_other) == ("moderate", True)
+ assert severity.colors == {"critical": "red", "important": "yellow", "moderate": "yellow", "low": "gray"}
+ assert (harm.key, harm.label, harm.type, harm.required, harm.allow_other) == (
+ "harm_type",
+ "Harm-type",
+ "choice",
+ False,
+ True,
+ )
+ assert [option.key for option in harm.options] == [c.value for c in HarmCategory if c is not HarmCategory.OTHER]
+ assert harm.hint == "Optional. Select a category, or choose Other for custom text."
+ assert (description.key, description.label, description.type, description.hint) == (
+ "description",
+ "Description",
+ "multiline",
+ "Optional",
+ )
+
+
+SEVERITY = {"key": "severity", "label": "Severity", "type": "choice", "options": [("high", "High")]}
+
+
+def _choice(**overrides: object) -> dict[str, object]:
+ return {"key": "area", "label": "Area", "type": "choice", "options": [("chat", "Chat")], **overrides}
+
+
+NESTED = {("chat", "Chat"): [("history", "History")]}
+
+
+@pytest.mark.parametrize(
+ ("severity", "fields", "match"),
+ [
+ (SEVERITY, [_choice(key="Bad-Key")], "lowercase letters, digits and underscores"),
+ (
+ SEVERITY,
+ [{"key": "notes", "label": "Notes", "type": "text", "options": [("a", "A")]}],
+ "cannot have options",
+ ),
+ (SEVERITY, [_choice(options=[])], "needs at least one option"),
+ (SEVERITY, [_choice(options=[("chat", "Chat"), ("chat", "Chat again")])], "repeats the option key 'chat'"),
+ (SEVERITY, [_choice(options=NESTED)], "one entry in levels"),
+ (SEVERITY, [_choice(levels=[("feature", "Feature")])], "one entry in levels"),
+ (SEVERITY, [_choice(default="search")], "'search' is not a top-level option of 'area'"),
+ (
+ SEVERITY,
+ [_choice(allow_other=True, levels=[("feature", "Feature")], options=NESTED)],
+ "Other text only without levels",
+ ),
+ (SEVERITY, [_choice(colors={"chat": "red"})], "Only the severity field can set colors"),
+ ({**SEVERITY, "colors": {"low": "red"}}, [], "'low' is not a top-level option of 'severity'"),
+ ({**SEVERITY, "key": "sev"}, [], "key must be 'severity'"),
+ ({**SEVERITY, "type": "text", "options": []}, [], "must be a choice field"),
+ ({**SEVERITY, "required": False}, [], "always required"),
+ (SEVERITY, [_choice(), _choice(label="Area again")], "'area' is used more than once"),
+ (SEVERITY, [_choice(levels=[("severity", "Severity")], options=NESTED)], "'severity' is used more than once"),
+ (SEVERITY, [_choice(key="title")], "'title' is reserved"),
+ (SEVERITY, [_choice(label=" ")], "must not be blank"),
+ ({**SEVERITY, "options": [("high ", "High")]}, [], "surrounding whitespace"),
+ ],
+)
+def test_finding_fields_rejects_invalid_definitions(severity: dict, fields: list, match: str) -> None:
+ with pytest.raises(ValidationError, match=match):
+ FindingFields.model_validate({"severity": severity, "fields": fields})
+
+
+@pytest.mark.parametrize(
+ ("field", "match"),
+ [
+ (_choice(levels=[("Feature", "Feature")], options=NESTED), "lowercase letters, digits and underscores"),
+ (_choice(key="__proto__"), "'__proto__' is reserved"),
+ (_choice(levels=[("__proto__", "Feature")], options=NESTED), "'__proto__' is reserved"),
+ (_choice(options=[("chat", " ")]), "must not be blank"),
+ (_choice(options=[(" chat", "Chat")]), "surrounding whitespace"),
+ (
+ _choice(levels=[("feature", "Feature")], options={("chat", "Chat"): [("history ", "History")]}),
+ "surrounding whitespace",
+ ),
+ (_choice(levels=[("feature", "Feature")], options={("chat", "Chat"): [(" ", "History")]}), "must not be blank"),
+ ({"key": "notes", "label": "Notes", "type": "text", "default": "a"}, "cannot have default"),
+ (_choice(options={"chat": []}), r"\(key, label\) pair"),
+ ],
+)
+def test_finding_field_rejects_invalid_parts(field: dict, match: str) -> None:
+ with pytest.raises(ValidationError, match=match):
+ FindingField.model_validate(field)
+
+
+def test_severity_slot_accepts_explicit_required() -> None:
+ assert FindingFields.model_validate({"severity": {**SEVERITY, "required": True}}).severity.required is True
+
+
+def test_internal_defaults_to_false_and_is_rejected_on_the_severity_slot() -> None:
+ assert FindingField.model_validate(_choice()).internal is False
+ with pytest.raises(ValidationError, match="The severity slot can't be internal."):
+ FindingFields.model_validate({"severity": {**SEVERITY, "internal": True}})
+
+
+def test_describe_values_lists_filled_values_in_configured_order_and_retired_keys() -> None:
+ description = CONTOSO.describe_values(
+ severity="sev1",
+ field_values={
+ "owner": "dana",
+ "description": "Old text",
+ "category": "model_security",
+ "subcategory": "system_prompt_leak",
+ "technique": "Many-shot priming",
+ "steps": " ",
+ "severity_rationale": "remote_no_auth",
+ "repro": "Older text",
+ },
+ )
+ assert description.severity_label == "Sev 1"
+ assert [(row.label, row.value) for row in description.rows] == [
+ ("Severity rationale", "Remote, no sign-in needed"),
+ ("Category", "Model security"),
+ ("Subcategory", "System prompt leak"),
+ ("Technique", "Many-shot priming"),
+ ("Owner", "dana"),
+ ]
+ assert description.retired == [("description", "Old text"), ("repro", "Older text")]
+
+
+def test_describe_values_shows_a_cascade_child_not_under_its_stored_parent_as_typed() -> None:
+ description = CONTOSO.describe_values(
+ severity="sev1", field_values={"category": "model_security", "subcategory": "hate_speech"}
+ )
+ assert [(row.label, row.value) for row in description.rows] == [
+ ("Category", "Model security"),
+ ("Subcategory", "hate_speech"),
+ ]
+
+
+def test_describe_values_shows_an_unmatched_severity_as_typed() -> None:
+ description = CONTOSO.describe_values(severity="Sev 2.5", field_values={"severity_rationale": "remote_no_auth"})
+ assert description.severity_label == "Sev 2.5"
+ assert [(row.label, row.value) for row in description.rows] == [("Severity rationale", "remote_no_auth")]
+
+
+def test_describe_values_carries_multiline_and_internal_on_each_row() -> None:
+ description = CONTOSO.describe_values(severity="sev2", field_values={"owner": "dana", "steps": "1. Open\n2. Ask"})
+ assert description.rows == [
+ FindingValueRow(
+ key="steps", label="Steps to reproduce", value="1. Open\n2. Ask", multiline=True, internal=False
+ ),
+ FindingValueRow(key="owner", label="Owner", value="dana", multiline=False, internal=True),
+ ]
+ assert description.retired == []
+
+
+def test_describe_values_labels_the_default_fields() -> None:
+ description = DEFAULT_FINDING_FIELDS.describe_values(
+ severity="critical", field_values={"description": "Synthetic text", "harm_type": "Hate Speech"}
+ )
+ assert description.severity_label == "Critical"
+ assert description.rows == [
+ FindingValueRow(key="harm_type", label="Harm-type", value="Hate Speech", multiline=False, internal=False),
+ FindingValueRow(key="description", label="Description", value="Synthetic text", multiline=True, internal=False),
+ ]
+
+
+VALID = {
+ "severity_rationale": "remote_no_auth",
+ "category": "model_security",
+ "subcategory": "system_prompt_leak",
+ "steps": "1. Open a new chat",
+}
+
+
+def _without(*keys: str) -> dict[str, str]:
+ return {key: value for key, value in VALID.items() if key not in keys}
+
+
+@pytest.mark.parametrize(
+ ("severity", "values", "previous_severity", "previous_values"),
+ [
+ pytest.param("sev1", VALID, None, None, id="complete"),
+ pytest.param("sev1", {**VALID, "technique": "Many-shot priming", "owner": "dana"}, None, None, id="other text"),
+ pytest.param("sev4", _without("severity_rationale"), "sev4", {}, id="unchanged unmatched severity"),
+ pytest.param("sev1", {**VALID, "repro": "old"}, "sev1", {"repro": "old"}, id="unchanged retired field"),
+ pytest.param(
+ "sev1",
+ {**VALID, "category": "gone", "subcategory": "old"},
+ "sev1",
+ {"category": "gone", "subcategory": "old"},
+ id="unchanged unmatched path",
+ ),
+ ],
+)
+def test_check_values_accepts(
+ severity: str, values: dict, previous_severity: str | None, previous_values: dict | None
+) -> None:
+ CONTOSO.check_values(
+ severity=severity, field_values=values, previous_severity=previous_severity, previous_values=previous_values
+ )
+
+
+@pytest.mark.parametrize(
+ ("severity", "values", "previous_severity", "previous_values", "message"),
+ [
+ pytest.param("", VALID, None, None, "Severity is required.", id="blank severity"),
+ pytest.param("sev9", VALID, None, None, "Severity has no option 'sev9'.", id="unknown severity"),
+ pytest.param("sev4", VALID, "sev1", VALID, "Severity has no option 'sev4'.", id="changed to unmatched"),
+ pytest.param(
+ "sev1", _without("severity_rationale"), None, None, "Severity rationale is required.", id="missing level"
+ ),
+ pytest.param(
+ "sev1", _without("category", "subcategory"), None, None, "Category is required.", id="missing field"
+ ),
+ pytest.param(
+ "sev1",
+ {**VALID, "subcategory": "hate_speech"},
+ None,
+ None,
+ "Subcategory has no option 'hate_speech'.",
+ id="child under wrong parent",
+ ),
+ pytest.param(
+ "sev1",
+ {**VALID, "category": "content_safety"},
+ "sev1",
+ VALID,
+ "Subcategory has no option 'system_prompt_leak'.",
+ id="stale child",
+ ),
+ pytest.param("sev1", {**VALID, "steps": " "}, None, None, "Steps to reproduce is required.", id="blank text"),
+ pytest.param(
+ "sev1",
+ _without("steps"),
+ "sev1",
+ _without("steps"),
+ "Steps to reproduce is required.",
+ id="required field added after saving",
+ ),
+ pytest.param(
+ "sev1", {**VALID, "reviewer": "kim"}, None, None, "Unknown finding field 'reviewer'.", id="unknown key"
+ ),
+ pytest.param(
+ "sev1",
+ {**VALID, "reviewer": "kim"},
+ "sev1",
+ VALID,
+ "Unknown finding field 'reviewer'.",
+ id="unknown key the stored finding never had",
+ ),
+ pytest.param(
+ "sev1",
+ {**VALID, "severity": "sev1"},
+ None,
+ None,
+ "Unknown finding field 'severity'.",
+ id="severity in values",
+ ),
+ pytest.param(
+ "sev1",
+ {**VALID, "repro": "new"},
+ "sev1",
+ {**VALID, "repro": "old"},
+ "Finding field 'repro' is no longer configured, so its saved value can't change.",
+ id="changed retired field",
+ ),
+ ],
+)
+def test_check_values_rejects(
+ severity: str, values: dict, previous_severity: str | None, previous_values: dict | None, message: str
+) -> None:
+ with pytest.raises(FindingValuesError) as error:
+ CONTOSO.check_values(
+ severity=severity, field_values=values, previous_severity=previous_severity, previous_values=previous_values
+ )
+ assert str(error.value) == message
+
+
+@pytest.mark.parametrize(
+ ("severity", "values", "message"),
+ [
+ pytest.param(
+ "Critical",
+ {},
+ "Severity 'Critical' matches the option 'Critical' (key 'critical'). Choose that option instead.",
+ id="severity label",
+ ),
+ pytest.param(
+ "CRITICAL",
+ {},
+ "Severity 'CRITICAL' matches the option 'Critical' (key 'critical'). Choose that option instead.",
+ id="severity key in capitals",
+ ),
+ pytest.param(
+ "low ",
+ {},
+ "Severity 'low ' matches the option 'Low' (key 'low'). Choose that option instead.",
+ id="severity key with a space",
+ ),
+ pytest.param(
+ "low",
+ {"harm_type": "malware"},
+ "Harm-type 'malware' matches the option 'Malware' (key 'Malware'). Choose that option instead.",
+ id="harm type key in lowercase",
+ ),
+ ],
+)
+def test_check_values_rejects_other_text_that_matches_an_option(
+ severity: str, values: dict[str, str], message: str
+) -> None:
+ with pytest.raises(FindingValuesError) as error:
+ DEFAULT_FINDING_FIELDS.check_values(severity=severity, field_values=values)
+ assert str(error.value) == message
+
+
+@pytest.mark.parametrize("severity", ["Sev 2.5", "critcal"])
+def test_check_values_accepts_other_text_that_matches_no_option(severity: str) -> None:
+ DEFAULT_FINDING_FIELDS.check_values(severity=severity, field_values={})
+
+
+def test_check_values_accepts_unchanged_other_text_that_matches_an_option() -> None:
+ DEFAULT_FINDING_FIELDS.check_values(severity="Critical", field_values={}, previous_severity="Critical")
+
+
+AREA = _choice(
+ levels=[("feature", "Feature")],
+ options={("chat", "Chat"): [("history", "History")], ("search", "Search"): []},
+)
+AREAS = FindingFields.model_validate({"severity": SEVERITY, "fields": [AREA]})
+REQUIRED_AREAS = FindingFields.model_validate({"severity": SEVERITY, "fields": [{**AREA, "required": True}]})
+
+
+@pytest.mark.parametrize("values", [{"area": "chat"}, {}])
+def test_check_values_optional_cascade_allows_a_partial_path(values: dict) -> None:
+ AREAS.check_values(severity="high", field_values=values)
+
+
+def test_check_values_rejects_a_child_without_its_parent() -> None:
+ with pytest.raises(FindingValuesError) as error:
+ AREAS.check_values(severity="high", field_values={"feature": "history"})
+ assert str(error.value) == "Feature has no option 'history'."
+
+
+def test_check_values_required_cascade_skips_a_level_with_no_options() -> None:
+ REQUIRED_AREAS.check_values(severity="high", field_values={"area": "search"})
+ with pytest.raises(FindingValuesError) as error:
+ REQUIRED_AREAS.check_values(severity="high", field_values={"area": "chat"})
+ assert str(error.value) == "Feature is required."
diff --git a/tests/unit/setup/conftest.py b/tests/unit/setup/conftest.py
new file mode 100644
index 0000000000..e177debda2
--- /dev/null
+++ b/tests/unit/setup/conftest.py
@@ -0,0 +1,14 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import pytest
+
+from pyrit.models import FindingField, FindingFields
+
+
+@pytest.fixture
+def custom_finding_fields() -> FindingFields:
+ """Finding fields with one severity option, unlike ``DEFAULT_FINDING_FIELDS``."""
+ return FindingFields(
+ severity=FindingField(key="severity", label="Severity", type="choice", options=[("high", "High")])
+ )
diff --git a/tests/unit/setup/test_finding_fields.py b/tests/unit/setup/test_finding_fields.py
new file mode 100644
index 0000000000..f7051019c3
--- /dev/null
+++ b/tests/unit/setup/test_finding_fields.py
@@ -0,0 +1,43 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import re
+
+import pytest
+
+from pyrit.models import DEFAULT_FINDING_FIELDS, FindingFields
+from pyrit.setup.finding_fields import (
+ get_active_finding_fields,
+ reset_active_finding_fields,
+ set_active_finding_fields,
+)
+
+pytestmark = pytest.mark.usefixtures("reset_finding_fields")
+
+
+def test_get_active_finding_fields_returns_the_default_until_set() -> None:
+ assert get_active_finding_fields() is DEFAULT_FINDING_FIELDS
+
+
+def test_set_active_finding_fields_replaces_the_default(custom_finding_fields: FindingFields) -> None:
+ set_active_finding_fields(finding_fields=custom_finding_fields, source="First")
+ assert get_active_finding_fields() is custom_finding_fields
+
+
+def test_set_active_finding_fields_twice_names_both_sources(custom_finding_fields: FindingFields) -> None:
+ set_active_finding_fields(finding_fields=custom_finding_fields, source="First")
+ with pytest.raises(ValueError, match="Second cannot set the finding fields: First already set them"):
+ set_active_finding_fields(finding_fields=DEFAULT_FINDING_FIELDS, source="Second")
+ assert get_active_finding_fields() is custom_finding_fields
+
+
+def test_set_active_finding_fields_rejects_a_value_that_is_not_finding_fields() -> None:
+ with pytest.raises(TypeError, match=re.escape("Contoso must set FindingFields, not dict.")):
+ set_active_finding_fields(finding_fields={}, source="Contoso")
+ assert get_active_finding_fields() is DEFAULT_FINDING_FIELDS
+
+
+def test_reset_restores_the_default(custom_finding_fields: FindingFields) -> None:
+ set_active_finding_fields(finding_fields=custom_finding_fields, source="First")
+ reset_active_finding_fields()
+ assert get_active_finding_fields() is DEFAULT_FINDING_FIELDS
diff --git a/tests/unit/setup/test_findings_initializer.py b/tests/unit/setup/test_findings_initializer.py
new file mode 100644
index 0000000000..2150b83d82
--- /dev/null
+++ b/tests/unit/setup/test_findings_initializer.py
@@ -0,0 +1,93 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import importlib.util
+from pathlib import Path
+
+import pytest
+
+import pyrit.setup.initializers
+from pyrit.models import DEFAULT_FINDING_FIELDS, FindingFields
+from pyrit.registry import InitializerRegistry
+from pyrit.registry.discovery import discover_in_directory
+from pyrit.setup.finding_fields import get_active_finding_fields
+from pyrit.setup.initializers import FindingsInitializer
+from pyrit.setup.pyrit_initializer import PyRITInitializer
+
+pytestmark = pytest.mark.usefixtures("reset_finding_fields")
+
+CONTOSO_SCRIPT = '''
+from pyrit.models import FindingField, FindingFields
+from pyrit.setup.initializers import FindingsInitializer
+
+
+class ContosoFindingFields(FindingsInitializer):
+ """Contoso finding fields."""
+
+ def get_finding_fields(self) -> FindingFields:
+ return FindingFields(
+ severity=FindingField(key="severity", label="Severity", type="choice", options=[("sev1", "Sev 1")])
+ )
+'''
+
+
+@pytest.fixture
+def example_initializer_class(custom_finding_fields: FindingFields) -> type[FindingsInitializer]:
+ class ExampleFindingFields(FindingsInitializer):
+ """Example finding fields."""
+
+ def get_finding_fields(self) -> FindingFields:
+ return custom_finding_fields
+
+ return ExampleFindingFields
+
+
+async def test_findings_initializer_sets_the_active_fields(
+ example_initializer_class: type[FindingsInitializer], custom_finding_fields: FindingFields
+) -> None:
+ await example_initializer_class().initialize_async()
+ assert get_active_finding_fields() is custom_finding_fields
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_get_info_leaves_the_active_fields_alone(example_initializer_class: type[FindingsInitializer]) -> None:
+ await example_initializer_class.get_info_async()
+ info = await example_initializer_class.get_info_async()
+
+ assert get_active_finding_fields() is DEFAULT_FINDING_FIELDS
+ assert info["default_values"] == []
+ assert info["global_variables"] == []
+
+
+def test_findings_initializer_is_skipped_by_builtin_discovery() -> None:
+ directory = Path(pyrit.setup.initializers.__file__).parent
+ names = [
+ cls.__name__
+ for _, _, cls in discover_in_directory(directory=directory, base_class=PyRITInitializer, recursive=True)
+ ]
+ assert "FindingsInitializer" not in names
+
+
+async def test_discovery_copy_shares_the_active_finding_fields(custom_finding_fields: FindingFields) -> None:
+ path = Path(pyrit.setup.initializers.__file__).parent / "findings.py"
+ spec = importlib.util.spec_from_file_location("discovered_module.findings", path)
+ copy = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(copy)
+
+ class CopiedFindingFields(copy.FindingsInitializer):
+ def get_finding_fields(self) -> FindingFields:
+ return custom_finding_fields
+
+ await CopiedFindingFields().initialize_async()
+ assert get_active_finding_fields() is custom_finding_fields
+
+
+async def test_custom_script_subclass_sets_the_active_fields(tmp_path: Path) -> None:
+ script = tmp_path / "contoso_fields.py"
+ script.write_text(CONTOSO_SCRIPT)
+ [initializer] = InitializerRegistry.get_registry_singleton().create_from_script_paths(
+ script_paths=[script], strict=True
+ )
+ assert type(initializer).__name__ == "ContosoFindingFields"
+ await initializer.initialize_async()
+ assert get_active_finding_fields().severity_order == ["sev1"]
diff --git a/tests/unit/setup/test_initialization.py b/tests/unit/setup/test_initialization.py
index 73c275d49f..9d0f66bcae 100644
--- a/tests/unit/setup/test_initialization.py
+++ b/tests/unit/setup/test_initialization.py
@@ -8,14 +8,18 @@
from unittest import mock
import pytest
+from pydantic import ValidationError
from pyrit.common.apply_defaults import reset_default_values
from pyrit.common.random_context import get_configured_random_seed
from pyrit.common.singleton import Singleton
from pyrit.memory import AzureSQLMemory, CentralMemory, SQLiteMemory
-from pyrit.models import MessagePiece
+from pyrit.models import DEFAULT_FINDING_FIELDS, FindingField, FindingFields, MessagePiece
from pyrit.registry import InitializerRegistry
-from pyrit.setup import IN_MEMORY, initialize_pyrit_async
+from pyrit.setup import IN_MEMORY, initialization, initialize_pyrit_async
+from pyrit.setup.finding_fields import get_active_finding_fields, set_active_finding_fields
+from pyrit.setup.initialization import reinitialize_pyrit_async
+from pyrit.setup.initializers import FindingsInitializer
from pyrit.setup.pyrit_initializer import PyRITInitializer
@@ -440,3 +444,66 @@ async def test_initialize_forwards_silent_to_memory(self, mock_load_environment,
await initialize_pyrit_async(memory_db_type=IN_MEMORY, silent=silent, load_defaults=False)
assert CentralMemory.get_memory_instance()._silent is silent
+
+
+@pytest.mark.usefixtures("reset_memory_singletons", "reset_finding_fields")
+async def test_each_setup_pass_resets_finding_fields(custom_finding_fields: FindingFields) -> None:
+ class SetsCustomFields(PyRITInitializer):
+ async def initialize_async(self) -> None:
+ set_active_finding_fields(finding_fields=custom_finding_fields, source="pass")
+
+ with mock.patch("pyrit.setup.initialization.load_environment_async", new_callable=mock.AsyncMock):
+ await initialize_pyrit_async(memory_db_type=IN_MEMORY, initializers=[SetsCustomFields()])
+ assert get_active_finding_fields() is custom_finding_fields
+
+ await initialize_pyrit_async(memory_db_type=IN_MEMORY, initializers=[SetsCustomFields()])
+ assert get_active_finding_fields() is custom_finding_fields
+
+ await initialize_pyrit_async(memory_db_type=IN_MEMORY, load_defaults=False)
+ assert get_active_finding_fields() is DEFAULT_FINDING_FIELDS
+
+
+@pytest.mark.usefixtures("reset_finding_fields")
+async def test_reinitialize_resets_finding_fields(custom_finding_fields: FindingFields) -> None:
+ set_active_finding_fields(finding_fields=custom_finding_fields, source="pass")
+ with (
+ mock.patch.object(CentralMemory, "set_memory_instance"),
+ mock.patch.object(initialization, "_execute_initializers_async", new_callable=mock.AsyncMock),
+ ):
+ await reinitialize_pyrit_async(
+ memory=mock.MagicMock(spec=SQLiteMemory), initializer_factory=list, environment_values={}, seed=None
+ )
+ assert get_active_finding_fields() is DEFAULT_FINDING_FIELDS
+
+
+@pytest.mark.usefixtures("reset_memory_singletons", "reset_finding_fields")
+async def test_second_findings_initializer_stops_setup(custom_finding_fields: FindingFields) -> None:
+ class FirstFindingFields(FindingsInitializer):
+ def get_finding_fields(self) -> FindingFields:
+ return custom_finding_fields
+
+ class SecondFindingFields(FindingsInitializer):
+ def get_finding_fields(self) -> FindingFields:
+ return custom_finding_fields
+
+ with mock.patch("pyrit.setup.initialization.load_environment_async", new_callable=mock.AsyncMock):
+ with pytest.raises(
+ ValueError, match="SecondFindingFields cannot set the finding fields: FirstFindingFields already set them"
+ ):
+ await initialize_pyrit_async(
+ memory_db_type=IN_MEMORY, initializers=[FirstFindingFields(), SecondFindingFields()]
+ )
+
+
+@pytest.mark.usefixtures("reset_memory_singletons", "reset_finding_fields")
+async def test_invalid_finding_fields_stop_setup(custom_finding_fields: FindingFields) -> None:
+ class BrokenFindingFields(FindingsInitializer):
+ def get_finding_fields(self) -> FindingFields:
+ return FindingFields(
+ severity=custom_finding_fields.severity,
+ fields=[FindingField(key="broken", label="Broken", type="choice", options=[])],
+ )
+
+ with mock.patch("pyrit.setup.initialization.load_environment_async", new_callable=mock.AsyncMock):
+ with pytest.raises(ValidationError, match="needs at least one option"):
+ await initialize_pyrit_async(memory_db_type=IN_MEMORY, initializers=[BrokenFindingFields()])
diff --git a/tests/unit/setup/test_reinitialization.py b/tests/unit/setup/test_reinitialization.py
index d8f6129d57..4ca59bf4a6 100644
--- a/tests/unit/setup/test_reinitialization.py
+++ b/tests/unit/setup/test_reinitialization.py
@@ -4,6 +4,7 @@
"""Repeatable setup uses fresh scripts, replacement values, and the same memory."""
import os
+import re
import uuid
from collections.abc import Generator, Iterator
from contextlib import contextmanager, nullcontext
@@ -15,6 +16,7 @@
from pyrit.common.apply_defaults import get_global_default_values
from pyrit.common.random_context import configure_random_seed, get_configured_random_seed
from pyrit.memory import AzureSQLMemory, CentralMemory, SQLiteMemory
+from pyrit.models import FindingFields
from pyrit.models.catalog import ScenarioSuite
from pyrit.registry import (
AttackRegistry,
@@ -26,9 +28,15 @@
)
from pyrit.setup.configuration_loader import ConfigurationLoader
from pyrit.setup.environment_loading import resolve_environment_async
+from pyrit.setup.finding_fields import get_active_finding_fields, set_active_finding_fields
from pyrit.setup.initialization import reset_setup_registries, validate_reinitialization_memory
from pyrit.setup.initializers.targets import TARGET_CONFIGS
+FINDINGS_SCRIPT_IMPORTS = """
+from pyrit.models import FindingField, FindingFields
+from pyrit.setup.initializers import FindingsInitializer
+"""
+
@contextmanager
def _isolated_reinitialization_state() -> Generator[None, None, None]:
@@ -113,6 +121,57 @@ async def test_preflight_uses_isolated_registry_without_changing_live_state() ->
assert prepared.script_initializer_types == ()
+@pytest.mark.usefixtures("reset_finding_fields")
+@pytest.mark.parametrize(
+ ("initializers", "message"),
+ [
+ pytest.param(
+ """
+SEVERITY = FindingField(key="severity", label="Severity", type="choice", options=[("sev1", "Sev 1")])
+
+class ContosoFindingFields(FindingsInitializer):
+ def get_finding_fields(self): return FindingFields(severity=SEVERITY)
+
+class ContosoBackupFields(FindingsInitializer):
+ def get_finding_fields(self): return FindingFields(severity=SEVERITY)
+""",
+ "ContosoBackupFields and ContosoFindingFields both set the finding fields. "
+ "Use one FindingsInitializer per deployment.",
+ id="two initializers",
+ ),
+ pytest.param(
+ """
+class ContosoFindingFields(FindingsInitializer):
+ def get_finding_fields(self):
+ return FindingFields(severity=FindingField(key="severity", label="Severity", type="choice", options=[]))
+""",
+ "needs at least one option",
+ id="invalid fields",
+ ),
+ pytest.param(
+ """
+class ContosoFindingFields(FindingsInitializer):
+ def get_finding_fields(self):
+ return {}
+""",
+ "ContosoFindingFields.get_finding_fields() must return FindingFields, not dict.",
+ id="wrong type",
+ ),
+ ],
+)
+async def test_preflight_rejects_bad_finding_fields_and_keeps_the_active_ones(
+ tmp_path: Path, custom_finding_fields: FindingFields, initializers: str, message: str
+) -> None:
+ script = tmp_path / "contoso_finding_fields.py"
+ script.write_text(FINDINGS_SCRIPT_IMPORTS + initializers, encoding="utf-8")
+ config = ConfigurationLoader(memory_db_type="in_memory", env_files=[], initialization_scripts=[str(script)])
+ set_active_finding_fields(finding_fields=custom_finding_fields, source="ContosoRunning")
+
+ with pytest.raises(ValueError, match=re.escape(message)):
+ await config.preflight_reinitialization_async(environment_values={})
+ assert get_active_finding_fields() is custom_finding_fields
+
+
@pytest.mark.usefixtures("patch_central_database")
@pytest.mark.parametrize("source", ["script", "configured"])
async def test_apply_reconstructs_initializers_after_environment_change(tmp_path: Path, source: str) -> None: