diff --git a/doc/code/framework.md b/doc/code/framework.md
index 07c12c7c8d..6a611ad1cf 100644
--- a/doc/code/framework.md
+++ b/doc/code/framework.md
@@ -444,7 +444,7 @@ See [message normalizers](./targets/11_message_normalizer) for capability behavi
**Responsibility**: The Output module is responsible for writing different components in different formats to different places.
-- It renders the core result types — attack results, scenario results, conversations, and scores — without those components needing to know how they are displayed.
+- It renders the core result types — attack results, scenario results, conversations, scores, and finding reports — without those components needing to know how they are displayed.
- Format and destination are decoupled: a **format** (e.g. pretty ANSI, Markdown, JSON) is separate from a **sink** (stdout, file, Jupyter), so any result can be rendered any way to anywhere.
- **Does not own**: deciding *what* to render or *when*. Components hand results to output; format classes only turn data into strings and never fetch data, touch `CentralMemory`, or call `print()` directly (that's isolated to leaf printer classes).
diff --git a/doc/code/output/0_output.ipynb b/doc/code/output/0_output.ipynb
index 4b453ca4b0..a27f89a2a0 100644
--- a/doc/code/output/0_output.ipynb
+++ b/doc/code/output/0_output.ipynb
@@ -992,6 +992,7 @@
"| `output_scorer_async` | Scorer info/metrics | `pretty`, `json` |\n",
"| `output_conversation_async` | Conversation history | `pretty`, `json` |\n",
"| `output_score_async` | Score list | `pretty`, `json` |\n",
+ "| `output_finding_report_async` | Finding report | `markdown` |\n",
"\n",
"All accept `format=` and `sink=` keyword arguments with sensible defaults."
]
diff --git a/doc/code/output/0_output.py b/doc/code/output/0_output.py
index 79248b290c..ae30a26917 100644
--- a/doc/code/output/0_output.py
+++ b/doc/code/output/0_output.py
@@ -374,6 +374,7 @@
# | `output_scorer_async` | Scorer info/metrics | `pretty`, `json` |
# | `output_conversation_async` | Conversation history | `pretty`, `json` |
# | `output_score_async` | Score list | `pretty`, `json` |
+# | `output_finding_report_async` | Finding report | `markdown` |
#
# All accept `format=` and `sink=` keyword arguments with sensible defaults.
diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md
index 7eef5adf14..e72592658b 100644
--- a/doc/gui/0_gui.md
+++ b/doc/gui/0_gui.md
@@ -135,6 +135,11 @@ Select an operation in the labels bar to associate new chats and scans with it.
The operation page brings its findings, linked evidence, and execution history
together.
+**Export findings** on the operation page downloads the findings you select as
+one Word or Markdown document to paste into a report. Evidence appears as links
+to the attacks in PyRIT, without transcripts or media. Nothing is redacted, so
+store and share exported files with care.
+

## Configuration
diff --git a/frontend/e2e/operations.spec.ts b/frontend/e2e/operations.spec.ts
index ff32c066f3..5ed51dad47 100644
--- a/frontend/e2e/operations.spec.ts
+++ b/frontend/e2e/operations.spec.ts
@@ -1,6 +1,10 @@
+import { readFileSync } from 'node:fs'
+
import { test, expect } from '@playwright/test'
-import type { Finding, Operation } from '../src/types'
+import type {
+ CreateAttackResponse, Finding, FindingEvidenceAttachResponse, FindingExportFormat, Operation,
+} from '../src/types'
import { waitForAppReady } from './_runtime'
for (const width of [1280, 390]) {
@@ -351,3 +355,157 @@ test('selects saved operations inline and revisits ID-scoped attack history @see
await expect(historyLink).toBeVisible()
await expect(page.getByRole('heading', { name: 'Independent human finding', exact: true })).toBeVisible()
})
+
+test('exports selected findings as Word and Markdown @seeded', async ({ page, request }) => {
+ test.setTimeout(90_000)
+ const version = await (await request.get('/api/version')).json()
+ const headers = { 'PyRIT-Compatibility-ID': version.compatibility_id }
+ const operationResponse = await request.post('/api/operations', {
+ headers, data: { name: `Findings export ${Date.now()}` },
+ })
+ expect(operationResponse.status()).toBe(201)
+ const operation: Operation = await operationResponse.json()
+ const description = 'Ünïcødé 😀 中文 first line\n```\n# Not a finding\nkept literal\nlast line'
+ const longTitle = `Beyond the first page ${'unbroken'.repeat(30)}`
+ // Saved out of severity order, so the document's order can only come from severity.
+ const seeds = [
+ { title: 'Export low', severity: 'low' },
+ { title: 'Export critical', severity: 'critical', description },
+ { title: longTitle, severity: 'informational' },
+ ...Array.from({ length: 17 }, (_, index) => ({
+ title: `Export moderate ${String(index).padStart(2, '0')}`, severity: 'moderate',
+ })),
+ { title: 'Export important', severity: 'important' },
+ ]
+ const findings = new Map()
+ for (const seed of seeds) {
+ const response = await request.post(`/api/operations/${operation.id}/findings`, { headers, data: seed })
+ expect(response.status()).toBe(201)
+ findings.set(seed.title, await response.json())
+ }
+ const findingId = (title: string): string => {
+ const finding = findings.get(title)
+ if (!finding) throw new Error(`Expected a saved finding titled ${title}.`)
+ return finding.id
+ }
+ const attackResponse = await request.post('/api/attacks', {
+ headers, data: {
+ target_registry_name: 'browser_offline',
+ operation_id: operation.id,
+ prepended_conversation: [{ role: 'user', pieces: [{ original_value: 'Saved offline evidence.' }] }],
+ },
+ })
+ expect(attackResponse.ok()).toBeTruthy()
+ const attack: CreateAttackResponse = await attackResponse.json()
+ const attachResponse = await request.post(
+ `/api/operations/${operation.id}/findings/${findingId('Export critical')}/evidence`,
+ { headers, data: { attack_result_id: attack.attack_result_id } },
+ )
+ expect(attachResponse.ok()).toBeTruthy()
+ const evidence: FindingEvidenceAttachResponse = await attachResponse.json()
+ const selectedTitles = ['Export critical', 'Export moderate 03', 'Export low', longTitle]
+ const excludedTitles = seeds.map(seed => seed.title).filter(title => !selectedTitles.includes(title))
+
+ await page.goto(`/operations/${operation.id}`)
+ await waitForAppReady(page)
+ const region = page.getByRole('region', { name: 'Operation', exact: true })
+ await expect(region.getByRole('listitem')).toHaveCount(20)
+ await expect(region.getByRole('heading', { name: longTitle })).toHaveCount(0)
+ const origin = new URL(page.url()).origin
+ const trigger = page.getByRole('button', { name: 'Export findings', exact: true })
+ const dialog = page.getByRole('dialog', { name: 'Export findings' })
+ const list = dialog.getByRole('list')
+ const selectAll = dialog.getByRole('checkbox', { name: 'Select all', exact: true })
+ const format = dialog.getByRole('combobox', { name: 'Format' })
+ const openWithEverySelected = async (): Promise => {
+ await trigger.click()
+ await expect(list.getByRole('checkbox', { checked: true })).toHaveCount(seeds.length)
+ await expect(selectAll).toBeChecked()
+ // The sensitive-content warning wraps, and its bar must grow to hold it.
+ const warning = dialog.locator('.fui-MessageBar').filter({ hasText: 'Nothing is redacted automatically.' })
+ expect(await warning.evaluate(element => element.scrollHeight <= element.clientHeight)).toBeTruthy()
+ }
+ const selectSubset = async (): Promise => {
+ await selectAll.click()
+ await expect(list.getByRole('checkbox', { checked: true })).toHaveCount(0)
+ for (const title of selectedTitles) {
+ await list.getByRole('checkbox', { name: title, exact: true }).check()
+ }
+ await expect(selectAll).toBeChecked({ indeterminate: true })
+ }
+ const download = async (
+ button: string, expectedFormat: FindingExportFormat,
+ ): Promise<{ filename: string; content: Buffer }> => {
+ const exportRequest = page.waitForRequest(sent =>
+ sent.url().endsWith(`/api/operations/${operation.id}/findings/export`) && sent.method() === 'POST')
+ const downloaded = page.waitForEvent('download')
+ await dialog.getByRole('button', { name: button, exact: true }).click()
+ expect((await exportRequest).postDataJSON()).toEqual({
+ finding_ids: selectedTitles.map(findingId), format: expectedFormat, dashboard_origin: origin,
+ })
+ const file = await downloaded
+ await expect(dialog).toHaveCount(0)
+ await expect(trigger).toBeFocused()
+ return { filename: file.suggestedFilename(), content: readFileSync(await file.path()) }
+ }
+
+ await openWithEverySelected()
+ await expect(list.getByRole('checkbox').first()).toHaveAccessibleName('Export critical')
+ await expect(list.getByRole('checkbox').last()).toHaveAccessibleName(longTitle)
+ await selectAll.click()
+ await expect(list.getByRole('checkbox', { checked: true })).toHaveCount(0)
+ await expect(selectAll).not.toBeChecked()
+ await expect(dialog.getByRole('button', { name: 'Download Word', exact: true })).toBeDisabled()
+ await selectAll.click()
+ await expect(list.getByRole('checkbox', { checked: true })).toHaveCount(seeds.length)
+ await selectSubset()
+ await expect(format).toHaveValue('docx')
+ await expect(format.locator('option:checked')).toHaveText('Word (.docx)')
+ const word = await download('Download Word', 'docx')
+ // Named after the Operation and the browser's local time.
+ const filenameStart = `^copyrit-findings-${operation.name.toLowerCase().replace(/ /g, '-')}-\\d{4}-\\d{2}-\\d{2}-\\d{4}`
+ expect(word.filename).toMatch(new RegExp(`${filenameStart}\\.docx$`))
+ expect(word.content.subarray(0, 4).toString('latin1')).toBe('PK\x03\x04')
+ expect(word.content.includes('word/document.xml')).toBeTruthy()
+
+ // Reopening starts again from the whole Operation.
+ await openWithEverySelected()
+ await selectSubset()
+ await format.selectOption({ label: 'Markdown (.md)' })
+ await expect(list.getByRole('checkbox', { checked: true })).toHaveCount(selectedTitles.length)
+ const markdownFile = await download('Download Markdown', 'markdown')
+ expect(markdownFile.filename).toMatch(new RegExp(`${filenameStart}\\.md$`))
+ const markdown = markdownFile.content.toString('utf8')
+ expect(markdown.startsWith(`# Findings - ${operation.name}\n\n`)).toBeTruthy()
+ expect(markdown.match(/^## .*$/gm)).toEqual(selectedTitles.map(title => `## ${title}`))
+ for (const title of excludedTitles) {
+ expect(markdown).not.toContain(title)
+ }
+ expect(markdown).toContain(`**Description:** ${description.split('\n')[0]}\\\n`)
+ const link = `${origin}/attacks/${attack.attack_result_id}/conversations/${attack.conversation_id}` +
+ `?findingEvidenceId=${evidence.item.id}`
+ // As in Word, the evidence bullet's objective is the link text.
+ expect(markdown.split('\n').filter(line => line.startsWith('- [') && line.endsWith(`](<${link}>)`))).toHaveLength(1)
+
+ await page.setViewportSize({ width: 390, height: 844 })
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBeTruthy()
+ await openWithEverySelected()
+ const content = dialog.locator('.fui-DialogContent')
+ expect(await content.evaluate(element => element.scrollHeight > element.clientHeight
+ && ['auto', 'scroll'].includes(getComputedStyle(element).overflowY))).toBeTruthy()
+ expect(await content.evaluate(element => element.scrollWidth <= element.clientWidth)).toBeTruthy()
+ expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBeTruthy()
+ const longLabel = dialog.locator('label').filter({ hasText: longTitle })
+ expect(await longLabel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBeTruthy()
+ const longBox = await longLabel.boundingBox()
+ const shortBox = await dialog.locator('label').filter({ hasText: /^Export low$/ }).boundingBox()
+ if (!longBox || !shortBox) throw new Error('Expected visible finding labels.')
+ expect(longBox.height).toBeGreaterThan(2 * shortBox.height)
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBeTruthy()
+ await dialog.getByRole('button', { name: 'Cancel', exact: true }).click()
+ await expect(dialog).toHaveCount(0)
+ await expect(trigger).toBeFocused()
+
+ await page.goto(link)
+ await expect(page.getByText('Saved offline evidence.', { exact: true })).toBeVisible()
+})
diff --git a/frontend/src/components/Operations/FindingExportDialog.styles.ts b/frontend/src/components/Operations/FindingExportDialog.styles.ts
new file mode 100644
index 0000000000..6c3cb7d412
--- /dev/null
+++ b/frontend/src/components/Operations/FindingExportDialog.styles.ts
@@ -0,0 +1,22 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+import { mobileTouchTargetHeight, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets'
+
+// Fluent's medium Checkbox indicator. With its inline margins and the label's padding, a row's title starts after it.
+const CHECKBOX_INDICATOR_SIZE = '16px'
+const TITLE_INDENT = `calc(${tokens.spacingHorizontalS} * 2 + ${CHECKBOX_INDICATOR_SIZE} + ${tokens.spacingHorizontalXS})`
+
+export const useFindingExportDialogStyles = makeStyles({
+ content: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM },
+ messageBar: { flexShrink: 0 },
+ list: { margin: 0, padding: 0, listStyleType: 'none' },
+ row: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', columnGap: tokens.spacingHorizontalS, minWidth: 0 },
+ checkbox: { flex: '1 1 12rem', minWidth: 0 },
+ label: { minWidth: 0, overflowWrap: 'anywhere' },
+ created: {
+ color: tokens.colorNeutralForeground3,
+ // On narrow screens the time gets its own line, lined up under the title.
+ [NARROW_VIEWPORT_QUERY]: { flexBasis: '100%', paddingInlineStart: TITLE_INDENT },
+ },
+ button: { ...mobileTouchTargetHeight },
+})
diff --git a/frontend/src/components/Operations/FindingExportDialog.test.tsx b/frontend/src/components/Operations/FindingExportDialog.test.tsx
new file mode 100644
index 0000000000..7950b62cf1
--- /dev/null
+++ b/frontend/src/components/Operations/FindingExportDialog.test.tsx
@@ -0,0 +1,537 @@
+import { act, render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+
+import { operationsApi } from '@/services/api'
+import { deferred } from '@/test-utils/deferred'
+import type { FindingListItem, FindingListResponse, Operation } from '@/types'
+import { downloadFile } from '@/utils/downloadFile'
+import FindingExportDialog from './FindingExportDialog'
+
+jest.mock('@/services/api', () => ({ operationsApi: { listFindings: jest.fn(), exportFindings: jest.fn() } }))
+jest.mock('@/utils/downloadFile', () => ({ downloadFile: jest.fn() }))
+
+const OPERATION: Operation = {
+ id: '6b1f0c3a-2d4e-4f5a-8b6c-7d8e9f0a1b2c', name: 'Synthetic evaluation', created_at: '2026-10-21T12:00:00Z',
+}
+const OTHER_OPERATION: Operation = {
+ id: '1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f', name: 'Another evaluation', created_at: '2026-10-21T12:00:00Z',
+}
+
+function syntheticFinding(index: number, overrides: Partial = {}): FindingListItem {
+ return {
+ id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, operation_id: OPERATION.id,
+ title: `Synthetic finding ${index}`, description: '', severity: 'moderate',
+ created_at: '2026-10-21T12:00:00Z', evidence_count: 0, ...overrides,
+ }
+}
+
+function onePage(items: FindingListItem[]): FindingListResponse {
+ return { items, has_more: false, next_offset: null }
+}
+
+const FIRST_PAGE: FindingListResponse = {
+ items: Array.from({ length: 100 }, (_, index) => syntheticFinding(index + 1)), has_more: true, next_offset: 100,
+}
+const LAST_PAGE_FINDING = syntheticFinding(101, {
+ title: 'Final-page finding', severity: 'other', severity_other: 'Team-specific severity',
+})
+const LAST_PAGE = onePage([LAST_PAGE_FINDING])
+const ALPHA = syntheticFinding(1, { title: 'Alpha', severity: 'critical' })
+const BETA = syntheticFinding(2, { title: 'Beta', severity: 'low' })
+const GAMMA = syntheticFinding(3, { title: 'Gamma', severity: 'informational' })
+const BLOB = new Blob(['synthetic document'])
+const FILENAME_TIME = '\\d{4}-\\d{2}-\\d{2}-\\d{4}'
+const FINDING_NOT_FOUND = { isAxiosError: true, response: { status: 404, data: { detail: 'Finding not found' } } }
+const OPERATION_NOT_FOUND = { isAxiosError: true, response: { status: 404, data: { detail: 'Operation not found' } } }
+const REVIEW_AGAIN = 'Review the list and download again.'
+
+function renderDialog(operation: Operation = OPERATION): ReturnType {
+ return render(
+
+
+ ,
+ )
+}
+
+async function openDialog(user: ReturnType): Promise {
+ // Tabster restores the background's accessibility after a deferred update once a dialog closes.
+ await user.click(await screen.findByRole('button', { name: 'Export findings' }))
+ return screen.getByRole('dialog', { name: 'Export findings' })
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ jest.mocked(operationsApi.listFindings).mockResolvedValue(onePage([ALPHA, BETA, GAMMA]))
+ jest.mocked(operationsApi.exportFindings).mockResolvedValue(BLOB)
+})
+
+describe('FindingExportDialog', () => {
+ it('loads every page before enabling download and sends exactly the selected final-page finding', async () => {
+ const user = userEvent.setup()
+ const lastPage = deferred()
+ jest.mocked(operationsApi.listFindings).mockImplementation(
+ async (_, { offset }) => (offset === 0 ? FIRST_PAGE : lastPage.promise),
+ )
+ renderDialog()
+ const dialog = await openDialog(user)
+ await waitFor(() => expect(operationsApi.listFindings).toHaveBeenCalledTimes(2))
+ expect(operationsApi.listFindings).toHaveBeenNthCalledWith(1, OPERATION.id, { limit: 100, offset: 0 })
+ expect(operationsApi.listFindings).toHaveBeenNthCalledWith(2, OPERATION.id, { limit: 100, offset: 100 })
+ expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument()
+ expect(within(dialog).getByRole('button', { name: 'Download Word' })).toBeDisabled()
+
+ await act(async () => { lastPage.resolve(LAST_PAGE) })
+ const selectAll = within(dialog).getByRole('checkbox', { name: 'Select all' })
+ const rows = within(dialog).getAllByRole('checkbox').filter(checkbox => checkbox !== selectAll)
+ expect(rows).toHaveLength(101)
+ for (const row of rows) expect(row).toBeChecked()
+ expect(selectAll).toBeChecked()
+ expect(within(dialog).getByText('Team-specific severity')).toBeInTheDocument()
+
+ await user.click(selectAll)
+ expect(within(dialog).queryAllByRole('checkbox', { checked: true })).toHaveLength(0)
+ expect(within(dialog).getByRole('button', { name: 'Download Word' })).toBeDisabled()
+
+ const lastRow = within(dialog).getByRole('checkbox', { name: LAST_PAGE_FINDING.title })
+ await user.click(lastRow)
+ expect(selectAll).toBePartiallyChecked()
+ await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Format' }), 'Markdown (.md)')
+ expect(lastRow).toBeChecked()
+ await user.click(within(dialog).getByRole('button', { name: 'Download Markdown' }))
+
+ await waitFor(() => expect(downloadFile).toHaveBeenCalledTimes(1))
+ expect(operationsApi.exportFindings).toHaveBeenCalledWith(
+ OPERATION.id,
+ {
+ finding_ids: [LAST_PAGE_FINDING.id],
+ format: 'markdown',
+ dashboard_origin: 'http://localhost',
+ },
+ expect.any(AbortSignal),
+ )
+ expect(downloadFile).toHaveBeenCalledWith(
+ BLOB, expect.stringMatching(new RegExp(`^copyrit-findings-synthetic-evaluation-${FILENAME_TIME}\\.md$`)),
+ )
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Export findings' })).toHaveFocus())
+ })
+
+ it('lists and exports once a finding that a later page repeats', async () => {
+ const user = userEvent.setup()
+ // A finding created while pages load shifts the next page, so it can start with the previous page's last row.
+ jest.mocked(operationsApi.listFindings)
+ .mockResolvedValueOnce({ items: [ALPHA, BETA], has_more: true, next_offset: 2 })
+ .mockResolvedValueOnce(onePage([BETA, GAMMA]))
+ renderDialog()
+ const dialog = await openDialog(user)
+ await within(dialog).findByRole('checkbox', { name: GAMMA.title })
+ expect(within(dialog).getAllByRole('checkbox', { name: BETA.title })).toHaveLength(1)
+ expect(within(dialog).getAllByRole('checkbox')).toHaveLength(4)
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+
+ await waitFor(() => expect(operationsApi.exportFindings).toHaveBeenCalledTimes(1))
+ expect(jest.mocked(operationsApi.exportFindings).mock.calls[0][1].finding_ids)
+ .toEqual([ALPHA.id, BETA.id, GAMMA.id])
+ })
+
+ it('moves between all, none and subset selections', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+ const dialog = await openDialog(user)
+ const selectAll = await within(dialog).findByRole('checkbox', { name: 'Select all' })
+ const [alpha, beta, gamma] = [ALPHA, BETA, GAMMA].map(finding => (
+ within(dialog).getByRole('checkbox', { name: finding.title })))
+ const download = within(dialog).getByRole('button', { name: 'Download Word' })
+ expect(selectAll).toBeChecked()
+ expect(selectAll).not.toBePartiallyChecked()
+
+ await user.click(beta)
+ expect(selectAll).toBePartiallyChecked()
+ expect(download).toBeEnabled()
+ await user.click(selectAll)
+ for (const row of [alpha, beta, gamma]) expect(row).toBeChecked()
+ expect(selectAll).toBeChecked()
+
+ await user.click(selectAll)
+ for (const row of [alpha, beta, gamma]) expect(row).not.toBeChecked()
+ expect(selectAll).not.toBeChecked()
+ expect(selectAll).not.toBePartiallyChecked()
+ expect(download).toBeDisabled()
+
+ await user.click(alpha)
+ expect(selectAll).toBePartiallyChecked()
+ await user.click(beta)
+ await user.click(gamma)
+ expect(selectAll).toBeChecked()
+ expect(selectAll).not.toBePartiallyChecked()
+ })
+
+ it('defaults to Word and keeps the selection when the format changes', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+ const dialog = await openDialog(user)
+ const beta = await within(dialog).findByRole('checkbox', { name: BETA.title })
+ const format = within(dialog).getByRole('combobox', { name: 'Format' })
+ expect(format).toHaveDisplayValue('Word (.docx)')
+ expect(within(format).getAllByRole('option').map(option => option.textContent))
+ .toEqual(['Word (.docx)', 'Markdown (.md)'])
+
+ await user.click(beta)
+ await user.selectOptions(format, 'Markdown (.md)')
+ expect(within(dialog).getByRole('button', { name: 'Download Markdown' })).toBeEnabled()
+ await user.selectOptions(format, 'Word (.docx)')
+ expect(beta).not.toBeChecked()
+ expect(within(dialog).getByRole('checkbox', { name: ALPHA.title })).toBeChecked()
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+
+ await waitFor(() => expect(downloadFile).toHaveBeenCalledTimes(1))
+ expect(operationsApi.exportFindings).toHaveBeenCalledWith(OPERATION.id, {
+ finding_ids: [ALPHA.id, GAMMA.id], format: 'docx', dashboard_origin: 'http://localhost',
+ }, expect.any(AbortSignal))
+ expect(downloadFile).toHaveBeenCalledWith(
+ BLOB, expect.stringMatching(new RegExp(`^copyrit-findings-synthetic-evaluation-${FILENAME_TIME}\\.docx$`)),
+ )
+ })
+
+ it('shows an empty Operation without an enabled download', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindings).mockResolvedValue(onePage([]))
+ renderDialog()
+ const dialog = await openDialog(user)
+ expect(await within(dialog).findByText('This operation has no findings to export.')).toBeInTheDocument()
+ expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument()
+ expect(within(dialog).getByRole('button', { name: 'Download Word' })).toBeDisabled()
+ })
+
+ it('offers no partial selection when a page fails, and retries the whole list', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindings)
+ .mockResolvedValueOnce(FIRST_PAGE)
+ .mockRejectedValueOnce(new Error('Page unavailable'))
+ .mockResolvedValueOnce(FIRST_PAGE)
+ .mockResolvedValueOnce(LAST_PAGE)
+ renderDialog()
+ const dialog = await openDialog(user)
+ expect(await within(dialog).findByText(/Could not load findings: Page unavailable/)).toBeInTheDocument()
+ expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument()
+ expect(within(dialog).getByRole('button', { name: 'Download Word' })).toBeDisabled()
+
+ await user.click(within(dialog).getByRole('button', { name: 'Retry' }))
+ expect(await within(dialog).findByRole('checkbox', { name: LAST_PAGE_FINDING.title })).toBeChecked()
+ expect(within(dialog).getByRole('checkbox', { name: 'Select all' })).toBeChecked()
+ expect(within(dialog).queryByText(/Could not load findings/)).not.toBeInTheDocument()
+ expect(jest.mocked(operationsApi.listFindings).mock.calls.map(([, params]) => params.offset))
+ .toEqual([0, 100, 0, 100])
+ })
+
+ it('keeps the selection after an export error so the download can be retried', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.exportFindings).mockRejectedValueOnce({
+ isAxiosError: true, response: { status: 500, data: { detail: 'Renderer unavailable' } },
+ })
+ renderDialog()
+ const dialog = await openDialog(user)
+ await user.click(await within(dialog).findByRole('checkbox', { name: BETA.title }))
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+
+ expect(await within(dialog).findByText('Could not export findings: Renderer unavailable')).toBeInTheDocument()
+ expect(downloadFile).not.toHaveBeenCalled()
+ expect(within(dialog).getByRole('checkbox', { name: BETA.title })).not.toBeChecked()
+ expect(within(dialog).getByRole('checkbox', { name: ALPHA.title })).toBeChecked()
+
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+ await waitFor(() => expect(downloadFile).toHaveBeenCalledTimes(1))
+ for (const [, request] of jest.mocked(operationsApi.exportFindings).mock.calls) {
+ expect(request.finding_ids).toEqual([ALPHA.id, GAMMA.id])
+ }
+ })
+
+ it('shows each finding\'s creation time without changing its checkbox name', async () => {
+ const user = userEvent.setup()
+ const earlier = syntheticFinding(4, { title: 'Repeated title', created_at: '2026-10-20T08:15:00Z' })
+ const later = syntheticFinding(5, { title: 'Repeated title', created_at: '2026-10-21T17:45:00Z' })
+ jest.mocked(operationsApi.listFindings).mockResolvedValue(onePage([later, earlier]))
+ renderDialog()
+ const dialog = await openDialog(user)
+
+ const rows = await within(dialog).findAllByRole('checkbox', { name: 'Repeated title' })
+ expect(rows).toHaveLength(2)
+ expect(rows[0]).toHaveAccessibleDescription(new Date(later.created_at).toLocaleString())
+ expect(rows[1]).toHaveAccessibleDescription(new Date(earlier.created_at).toLocaleString())
+ expect(within(dialog).getByText(new Date(earlier.created_at).toLocaleString())).toBeInTheDocument()
+ })
+
+ it('reloads the list, keeps the rest of the selection and leaves new findings unchecked when a finding was deleted', async () => {
+ const user = userEvent.setup()
+ const delta = syntheticFinding(4, { title: 'Delta' })
+ jest.mocked(operationsApi.listFindings)
+ .mockResolvedValueOnce(onePage([ALPHA, BETA, GAMMA]))
+ .mockResolvedValueOnce(onePage([BETA, GAMMA, delta]))
+ jest.mocked(operationsApi.exportFindings).mockRejectedValueOnce(FINDING_NOT_FOUND)
+ renderDialog()
+ const dialog = await openDialog(user)
+ await user.click(await within(dialog).findByRole('checkbox', { name: BETA.title }))
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+
+ expect(await within(dialog).findByText(
+ `"${ALPHA.title}" no longer exists and was removed from your selection. ${REVIEW_AGAIN}`,
+ )).toBeInTheDocument()
+ expect(operationsApi.listFindings).toHaveBeenCalledTimes(2)
+ expect(within(dialog).queryByRole('checkbox', { name: ALPHA.title })).not.toBeInTheDocument()
+ expect(within(dialog).getByRole('checkbox', { name: BETA.title })).not.toBeChecked()
+ expect(within(dialog).getByRole('checkbox', { name: GAMMA.title })).toBeChecked()
+ expect(within(dialog).getByRole('checkbox', { name: delta.title })).not.toBeChecked()
+ expect(within(dialog).queryByText(/Could not export findings/)).not.toBeInTheDocument()
+
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+ await waitFor(() => expect(downloadFile).toHaveBeenCalledTimes(1))
+ expect(jest.mocked(operationsApi.exportFindings).mock.calls[1][1].finding_ids).toEqual([GAMMA.id])
+ })
+
+ it('names every deleted finding in one warning', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindings)
+ .mockResolvedValueOnce(onePage([ALPHA, BETA, GAMMA]))
+ .mockResolvedValueOnce(onePage([BETA]))
+ jest.mocked(operationsApi.exportFindings).mockRejectedValueOnce(FINDING_NOT_FOUND)
+ renderDialog()
+ const dialog = await openDialog(user)
+ await within(dialog).findByRole('checkbox', { name: 'Select all' })
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+
+ expect(await within(dialog).findByText(
+ `"${ALPHA.title}", "${GAMMA.title}" no longer exist and were removed from your selection. ${REVIEW_AGAIN}`,
+ )).toBeInTheDocument()
+ expect(within(dialog).getByRole('checkbox', { name: BETA.title })).toBeChecked()
+ })
+
+ it('keeps focus on Download while the list reloads after a deleted finding', async () => {
+ const user = userEvent.setup()
+ const reload = deferred()
+ jest.mocked(operationsApi.listFindings)
+ .mockResolvedValueOnce(onePage([ALPHA, BETA, GAMMA]))
+ .mockReturnValueOnce(reload.promise)
+ jest.mocked(operationsApi.exportFindings).mockRejectedValueOnce(FINDING_NOT_FOUND)
+ renderDialog()
+ const dialog = await openDialog(user)
+ await within(dialog).findByRole('checkbox', { name: 'Select all' })
+ within(dialog).getByRole('button', { name: 'Download Word' }).focus()
+ await user.keyboard('{Enter}')
+
+ await within(dialog).findByText('Loading findings…')
+ const download = within(dialog).getByRole('button', { name: 'Download Word' })
+ // A natively disabled button drops focus out of the dialog; an aria-disabled one keeps it.
+ expect(download).not.toBeDisabled()
+ expect(download).toHaveAttribute('aria-disabled', 'true')
+ expect(download).toHaveFocus()
+
+ await act(async () => { reload.resolve(onePage([BETA, GAMMA])) })
+ expect(download).toBeEnabled()
+ expect(download).not.toHaveAttribute('aria-disabled')
+ expect(download).toHaveFocus()
+ })
+
+ it('keeps Download focusable but unavailable when every selected finding was deleted', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindings)
+ .mockResolvedValueOnce(onePage([ALPHA, BETA, GAMMA]))
+ .mockResolvedValueOnce(onePage([ALPHA, GAMMA]))
+ jest.mocked(operationsApi.exportFindings).mockRejectedValueOnce(FINDING_NOT_FOUND)
+ renderDialog()
+ const dialog = await openDialog(user)
+ await user.click(await within(dialog).findByRole('checkbox', { name: 'Select all' }))
+ await user.click(within(dialog).getByRole('checkbox', { name: BETA.title }))
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+
+ expect(await within(dialog).findByText(
+ `"${BETA.title}" no longer exists and was removed from your selection. ${REVIEW_AGAIN}`,
+ )).toBeInTheDocument()
+ const download = within(dialog).getByRole('button', { name: 'Download Word' })
+ expect(download).not.toBeDisabled()
+ expect(download).toHaveAttribute('aria-disabled', 'true')
+ expect(download).toHaveFocus()
+ await user.click(download)
+ expect(operationsApi.exportFindings).toHaveBeenCalledTimes(1)
+
+ await user.click(within(dialog).getByRole('checkbox', { name: ALPHA.title }))
+ expect(download).toBeEnabled()
+ })
+
+ it('shows the export error when the reload still finds every selected finding', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.exportFindings).mockRejectedValueOnce(FINDING_NOT_FOUND)
+ renderDialog()
+ const dialog = await openDialog(user)
+ await within(dialog).findByRole('checkbox', { name: 'Select all' })
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+
+ expect(await within(dialog).findByText('Could not export findings: Finding not found')).toBeInTheDocument()
+ expect(operationsApi.listFindings).toHaveBeenCalledTimes(2)
+ for (const finding of [ALPHA, BETA, GAMMA]) {
+ expect(within(dialog).getByRole('checkbox', { name: finding.title })).toBeChecked()
+ }
+ expect(within(dialog).queryByText(new RegExp(REVIEW_AGAIN))).not.toBeInTheDocument()
+ })
+
+ it('shows the load error when the Operation is gone after a deleted-finding reload', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindings)
+ .mockResolvedValueOnce(onePage([ALPHA, BETA, GAMMA]))
+ .mockRejectedValueOnce(OPERATION_NOT_FOUND)
+ jest.mocked(operationsApi.exportFindings).mockRejectedValueOnce(OPERATION_NOT_FOUND)
+ renderDialog()
+ const dialog = await openDialog(user)
+ await within(dialog).findByRole('checkbox', { name: 'Select all' })
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+
+ expect(await within(dialog).findByText(/Could not load findings: Operation not found/)).toBeInTheDocument()
+ expect(within(dialog).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
+ expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument()
+ expect(within(dialog).getByRole('button', { name: 'Download Word' })).toBeDisabled()
+ })
+
+ it('keeps the dialog and selection when the browser download cannot start', async () => {
+ const user = userEvent.setup()
+ jest.mocked(downloadFile).mockImplementationOnce(() => { throw new Error('Download blocked') })
+ renderDialog()
+ const dialog = await openDialog(user)
+ await user.click(await within(dialog).findByRole('checkbox', { name: BETA.title }))
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+
+ expect(await within(dialog).findByText('Could not export findings: Download blocked')).toBeInTheDocument()
+ expect(screen.getByRole('dialog', { name: 'Export findings' })).toBeInTheDocument()
+ expect(within(dialog).getByRole('checkbox', { name: BETA.title })).not.toBeChecked()
+ expect(within(dialog).getByRole('button', { name: 'Download Word' })).toBeEnabled()
+ })
+
+ it('prevents duplicate export requests while the document is prepared', async () => {
+ const user = userEvent.setup()
+ const exported = deferred()
+ jest.mocked(operationsApi.exportFindings).mockReturnValueOnce(exported.promise)
+ renderDialog()
+ const dialog = await openDialog(user)
+ await within(dialog).findByRole('checkbox', { name: 'Select all' })
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+
+ const pending = within(dialog).getByRole('button', { name: 'Preparing download…' })
+ expect(pending).toHaveAttribute('aria-disabled', 'true')
+ expect(within(dialog).getByRole('checkbox', { name: 'Select all' })).toBeDisabled()
+ expect(within(dialog).getByRole('combobox', { name: 'Format' })).toBeDisabled()
+ await user.click(pending)
+ expect(operationsApi.exportFindings).toHaveBeenCalledTimes(1)
+
+ await act(async () => { exported.resolve(BLOB) })
+ expect(downloadFile).toHaveBeenCalledTimes(1)
+ })
+
+ it.each(['Cancel', 'Escape'])('restores focus to the trigger after closing with %s', async dismiss => {
+ const user = userEvent.setup()
+ renderDialog()
+ const dialog = await openDialog(user)
+ await within(dialog).findByRole('checkbox', { name: 'Select all' })
+ if (dismiss === 'Escape') await user.keyboard('{Escape}')
+ else await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Export findings' })).toHaveFocus())
+ expect(operationsApi.exportFindings).not.toHaveBeenCalled()
+ })
+
+ it('warns that exports can contain unredacted sensitive content', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+ const dialog = await openDialog(user)
+ expect(within(dialog).getByText(
+ 'Findings, attack objectives and score rationales can contain sensitive content. '
+ + 'Nothing is redacted automatically.',
+ )).toBeInTheDocument()
+ })
+
+ it('ignores a list that finishes after the dialog was closed and reopened', async () => {
+ const user = userEvent.setup()
+ const stale = deferred()
+ const fresh = syntheticFinding(7, { title: 'Fresh finding' })
+ jest.mocked(operationsApi.listFindings)
+ .mockReturnValueOnce(stale.promise)
+ .mockResolvedValueOnce(onePage([fresh]))
+ renderDialog()
+ let dialog = await openDialog(user)
+ await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ dialog = await openDialog(user)
+ expect(await within(dialog).findByRole('checkbox', { name: fresh.title })).toBeChecked()
+
+ await act(async () => { stale.resolve(onePage([syntheticFinding(8, { title: 'Stale finding' })])) })
+ expect(within(dialog).queryByRole('checkbox', { name: 'Stale finding' })).not.toBeInTheDocument()
+ expect(within(dialog).getByRole('checkbox', { name: fresh.title })).toBeChecked()
+ })
+
+ it('does not download a file that finishes after the dialog was closed, and reopens with everything selected', async () => {
+ const user = userEvent.setup()
+ const exported = deferred()
+ jest.mocked(operationsApi.exportFindings).mockReturnValueOnce(exported.promise)
+ renderDialog()
+ let dialog = await openDialog(user)
+ await user.click(await within(dialog).findByRole('checkbox', { name: BETA.title }))
+ await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Format' }), 'Markdown (.md)')
+ await user.click(within(dialog).getByRole('button', { name: 'Download Markdown' }))
+ await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ expect(jest.mocked(operationsApi.exportFindings).mock.calls[0][2]?.aborted).toBe(true)
+
+ await act(async () => { exported.resolve(BLOB) })
+ expect(downloadFile).not.toHaveBeenCalled()
+
+ dialog = await openDialog(user)
+ expect(await within(dialog).findByRole('checkbox', { name: BETA.title })).toBeChecked()
+ expect(within(dialog).getByRole('checkbox', { name: 'Select all' })).toBeChecked()
+ expect(within(dialog).getByRole('combobox', { name: 'Format' })).toHaveDisplayValue('Word (.docx)')
+ expect(within(dialog).getByRole('button', { name: 'Download Word' })).toBeEnabled()
+ expect(within(dialog).queryByText(/Could not export findings/)).not.toBeInTheDocument()
+ })
+
+ it('does not download the previous Operation\'s file after the Operation changes', async () => {
+ const user = userEvent.setup()
+ const exported = deferred()
+ jest.mocked(operationsApi.exportFindings).mockReturnValueOnce(exported.promise)
+ const view = renderDialog(OPERATION)
+ const dialog = await openDialog(user)
+ await within(dialog).findByRole('checkbox', { name: 'Select all' })
+ await user.click(within(dialog).getByRole('button', { name: 'Download Word' }))
+
+ view.rerender(
+
+
+ ,
+ )
+ expect(jest.mocked(operationsApi.exportFindings).mock.calls[0][2]?.aborted).toBe(true)
+ await act(async () => { exported.resolve(BLOB) })
+ expect(downloadFile).not.toHaveBeenCalled()
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('ignores a list from the previous Operation that finishes after the Operation changes', async () => {
+ const user = userEvent.setup()
+ const staleList = deferred()
+ const other = syntheticFinding(9, { operation_id: OTHER_OPERATION.id, title: 'Other operation finding' })
+ jest.mocked(operationsApi.listFindings)
+ .mockReturnValueOnce(staleList.promise)
+ .mockResolvedValueOnce(onePage([other]))
+ const view = renderDialog(OPERATION)
+ await openDialog(user)
+
+ view.rerender(
+
+
+ ,
+ )
+ const dialog = await openDialog(user)
+ expect(await within(dialog).findByRole('checkbox', { name: other.title })).toBeChecked()
+ expect(operationsApi.listFindings).toHaveBeenLastCalledWith(OTHER_OPERATION.id, { limit: 100, offset: 0 })
+
+ await act(async () => { staleList.resolve(onePage([ALPHA])) })
+ expect(within(dialog).queryByRole('checkbox', { name: ALPHA.title })).not.toBeInTheDocument()
+ expect(within(dialog).getByRole('checkbox', { name: other.title })).toBeChecked()
+ })
+})
diff --git a/frontend/src/components/Operations/FindingExportDialog.tsx b/frontend/src/components/Operations/FindingExportDialog.tsx
new file mode 100644
index 0000000000..a47b8bd3d6
--- /dev/null
+++ b/frontend/src/components/Operations/FindingExportDialog.tsx
@@ -0,0 +1,270 @@
+import { useEffect, useId, useRef, useState } from 'react'
+
+import {
+ Button, Checkbox, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, DialogTrigger,
+ Field, MessageBar, MessageBarBody, Select, Spinner, Text,
+} from '@fluentui/react-components'
+import { ArrowDownloadRegular } from '@fluentui/react-icons'
+
+import { operationsApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import type { FindingExportFormat, FindingListItem, Operation } from '@/types'
+import { downloadFile } from '@/utils/downloadFile'
+import { findingExportFilename } from '@/utils/findingExportFilename'
+
+import { useFindingExportDialogStyles } from './FindingExportDialog.styles'
+import FindingSeverityBadge from './FindingSeverityBadge'
+
+const PAGE_SIZE = 100
+const FORMATS: Record = {
+ docx: { option: 'Word (.docx)', name: 'Word', extension: 'docx' },
+ markdown: { option: 'Markdown (.md)', name: 'Markdown', extension: 'md' },
+}
+const NO_SELECTION: ReadonlySet = new Set()
+const NOT_FOUND = 404
+
+type FindingsLoad =
+ | { status: 'loading' }
+ | { status: 'error'; error: string }
+ | { status: 'loaded'; findings: FindingListItem[] }
+
+/** The selection to restore after reloading for a deleted finding, and why the export failed. */
+interface SelectionRecovery {
+ selected: readonly FindingListItem[]
+ detail: string
+}
+
+function removedNotice(titles: readonly string[]): string {
+ const names = titles.map(title => `"${title}"`).join(', ')
+ const verb = titles.length === 1 ? 'no longer exists and was' : 'no longer exist and were'
+ return `${names} ${verb} removed from your selection. Review the list and download again.`
+}
+
+interface FindingExportDialogProps {
+ operation: Operation
+}
+
+export default function FindingExportDialog({ operation }: FindingExportDialogProps) {
+ const styles = useFindingExportDialogStyles()
+ const [open, setOpen] = useState(false)
+ const [loadRevision, setLoadRevision] = useState(0)
+ const [load, setLoad] = useState({ status: 'loading' })
+ const [selected, setSelected] = useState>(NO_SELECTION)
+ const [format, setFormat] = useState('docx')
+ const [exportError, setExportError] = useState('')
+ const [removedTitles, setRemovedTitles] = useState([])
+ const [recovering, setRecovering] = useState(false)
+ const [exporting, setExporting] = useState(false)
+ const submitting = useRef(false)
+ const recovery = useRef(null)
+ const triggerRef = useRef(null)
+ const rowId = useId()
+ // Aborted when the dialog closes or unmounts, so a late export never downloads into a later view.
+ const session = useRef(null)
+
+ const findings = load.status === 'loaded' ? load.findings : []
+ const selectedIds = findings.filter(finding => selected.has(finding.id)).map(finding => finding.id)
+ const allSelected = selectedIds.length === findings.length
+ const selectAllChecked = allSelected ? true : selectedIds.length === 0 ? false : 'mixed'
+ // A natively disabled button drops keyboard focus out of the dialog, so while an export runs, and while the list
+ // reloads or shows nothing selected after a deleted finding, Download stays focusable but unavailable.
+ const downloadKeepsFocus = exporting || (selectedIds.length === 0 && (recovering || removedTitles.length > 0))
+
+ useEffect(() => {
+ if (!open) return
+ const controller = new AbortController()
+ session.current = controller
+ const trigger = triggerRef.current
+ return () => {
+ controller.abort()
+ // Tabster's restore depends on the browser reporting focus loss from the removed dialog; don't rely on it.
+ trigger?.focus()
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ let ignore = false
+ const loadAllPages = async (): Promise => {
+ const loaded = new Map()
+ let offset: number | null = 0
+ while (offset !== null) {
+ const page = await operationsApi.listFindings(operation.id, { limit: PAGE_SIZE, offset })
+ if (ignore) return
+ // A finding created while pages load shifts later pages, so a page can repeat an earlier row.
+ for (const finding of page.items) {
+ if (!loaded.has(finding.id)) loaded.set(finding.id, finding)
+ }
+ offset = page.has_more ? page.next_offset : null
+ }
+ setLoad({ status: 'loaded', findings: [...loaded.values()] })
+ setRecovering(false)
+ const kept = recovery.current
+ recovery.current = null
+ if (kept === null) {
+ setSelected(new Set(loaded.keys()))
+ return
+ }
+ setSelected(new Set(kept.selected.filter(finding => loaded.has(finding.id)).map(finding => finding.id)))
+ const removed = kept.selected.filter(finding => !loaded.has(finding.id)).map(finding => finding.title)
+ if (removed.length > 0) setRemovedTitles(removed)
+ else setExportError(kept.detail)
+ }
+ loadAllPages().catch((cause: unknown) => {
+ if (ignore) return
+ setLoad({ status: 'error', error: toApiError(cause).detail })
+ setRecovering(false)
+ })
+ return () => { ignore = true }
+ }, [open, loadRevision, operation.id])
+
+ const start = (): void => {
+ submitting.current = false
+ recovery.current = null
+ setRecovering(false)
+ setExporting(false)
+ setExportError('')
+ setRemovedTitles([])
+ setFormat('docx')
+ setSelected(NO_SELECTION)
+ setLoad({ status: 'loading' })
+ setOpen(true)
+ }
+
+ const toggle = (findingId: string, checked: boolean): void => {
+ setSelected(current => {
+ const next = new Set(current)
+ if (checked) next.add(findingId)
+ else next.delete(findingId)
+ return next
+ })
+ }
+
+ const download = async (): Promise => {
+ const controller = session.current
+ if (controller === null || controller.signal.aborted || submitting.current || selectedIds.length === 0) return
+ submitting.current = true
+ setExporting(true)
+ setExportError('')
+ setRemovedTitles([])
+ try {
+ const file = await operationsApi.exportFindings(operation.id, {
+ finding_ids: selectedIds,
+ format,
+ dashboard_origin: window.location.origin,
+ }, controller.signal)
+ if (controller.signal.aborted) return
+ // The user's local time; a cross-origin deployment may not expose the server's attachment name.
+ downloadFile(file, findingExportFilename(operation, FORMATS[format].extension, new Date()))
+ setOpen(false)
+ } catch (cause: unknown) {
+ if (controller.signal.aborted) return
+ const error = toApiError(cause)
+ if (error.status === NOT_FOUND) {
+ // A selected finding (or the Operation) was deleted; reload, then keep whatever is still there.
+ recovery.current = {
+ selected: findings.filter(finding => selected.has(finding.id)),
+ detail: error.detail,
+ }
+ setRecovering(true)
+ setLoad({ status: 'loading' })
+ setLoadRevision(value => value + 1)
+ } else {
+ setExportError(error.detail)
+ }
+ } finally {
+ if (!controller.signal.aborted) {
+ submitting.current = false
+ setExporting(false)
+ }
+ }
+ }
+
+ const renderFindings = (): React.ReactNode => {
+ if (load.status === 'loading') return
+ if (load.status === 'error') {
+ return (
+
+ Could not load findings: {load.error}{' '}
+
+
+ )
+ }
+ if (findings.length === 0) return This operation has no findings to export.
+ return (
+ <>
+ { setSelected(allSelected ? NO_SELECTION : new Set(findings.map(finding => finding.id))) }} />
+
+ {findings.map(finding => {
+ const createdId = `${rowId}-${finding.id}-created`
+ return (
+ -
+ { toggle(finding.id, data.checked === true) }} />
+
+
+
+
+
+ )
+ })}
+
+ >
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/Operations/OperationDeleteDialog.tsx b/frontend/src/components/Operations/OperationDeleteDialog.tsx
index 76dfe56d41..013dfeb7bf 100644
--- a/frontend/src/components/Operations/OperationDeleteDialog.tsx
+++ b/frontend/src/components/Operations/OperationDeleteDialog.tsx
@@ -3,6 +3,7 @@ import {
Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, DialogTrigger,
MessageBar, MessageBarBody, Text, Tooltip,
} from '@fluentui/react-components'
+import { DeleteRegular } from '@fluentui/react-icons'
import { operationsApi } from '@/services/api'
import { toApiError } from '@/services/errors'
@@ -64,7 +65,7 @@ export default function OperationDeleteDialog({ operation, onInUse, onDeleted }:
}}>
-
+ } disabledFocusable={blockedReason !== undefined}>Delete
diff --git a/frontend/src/components/Operations/OperationDetailPage.test.tsx b/frontend/src/components/Operations/OperationDetailPage.test.tsx
index 5d2ec2cb30..908bde9acc 100644
--- a/frontend/src/components/Operations/OperationDetailPage.test.tsx
+++ b/frontend/src/components/Operations/OperationDetailPage.test.tsx
@@ -12,7 +12,7 @@ jest.mock('@/services/api', () => ({
get: jest.fn(), update: jest.fn(), delete: jest.fn(), listFindings: jest.fn(), createFinding: jest.fn(),
updateFinding: jest.fn(), deleteFinding: jest.fn(),
listFindingEvidence: jest.fn(), detachFindingEvidence: jest.fn(),
- getFindingOptions: jest.fn(),
+ getFindingOptions: jest.fn(), exportFindings: jest.fn(),
},
attacksApi: { listAttacks: jest.fn() },
scenariosApi: { listRuns: jest.fn() },
@@ -477,6 +477,42 @@ it('keeps deletion errors visible and prevents duplicate confirmations', async (
expect(operationsApi.deleteFinding).toHaveBeenCalledTimes(1)
})
+it('opens findings export from the header without changing findings or the dashboard page', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [FINDING] })
+ renderPage('/operations/op-1?offset=20')
+ await screen.findByRole('heading', { level: 2, name: FINDING.title })
+ const trigger = screen.getByRole('button', { name: 'Export findings' })
+ await user.click(trigger)
+ const dialog = screen.getByRole('dialog', { name: 'Export findings' })
+ expect(await within(dialog).findByRole('checkbox', { name: FINDING.title })).toBeChecked()
+ expect(operationsApi.listFindings).toHaveBeenCalledWith('op-1', { limit: 100, offset: 0 })
+
+ await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ await waitFor(() => expect(trigger).toHaveFocus())
+ expect(screen.getByText('Page 2')).toBeInTheDocument()
+ expect(jest.mocked(operationsApi.listFindings).mock.calls.filter(([, params]) => params.limit === 20))
+ .toEqual([['op-1', { limit: 20, offset: 20 }]])
+ expect(operationsApi.exportFindings).not.toHaveBeenCalled()
+ expect(operationsApi.createFinding).not.toHaveBeenCalled()
+ expect(operationsApi.updateFinding).not.toHaveBeenCalled()
+ expect(operationsApi.deleteFinding).not.toHaveBeenCalled()
+})
+
+it('shows decorative icons on the header actions but not on the finding actions', async () => {
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [FINDING] })
+ renderPage()
+ const heading = await screen.findByRole('heading', { level: 1, name: OPERATION.name })
+ await screen.findByRole('heading', { level: 2, name: FINDING.title })
+ const header = within(heading.closest('header') as HTMLElement)
+ const withoutIcon = ['Edit', 'Delete', 'Export findings', 'New finding']
+ .filter(name => header.getByRole('button', { name }).querySelector('svg[aria-hidden="true"]') === null)
+ expect(withoutIcon).toEqual([])
+ expect(screen.getByRole('button', { name: `Edit finding: ${FINDING.title}` }).querySelector('svg')).toBeNull()
+ expect(screen.getByRole('button', { name: `Delete finding: ${FINDING.title}` }).querySelector('svg')).toBeNull()
+})
+
it('pages through findings', async () => {
const user = userEvent.setup()
jest.mocked(operationsApi.listFindings).mockResolvedValueOnce({ items: [FINDING], has_more: true, next_offset: 20 })
diff --git a/frontend/src/components/Operations/OperationDetailPage.tsx b/frontend/src/components/Operations/OperationDetailPage.tsx
index 9f777d6b66..d327beabb8 100644
--- a/frontend/src/components/Operations/OperationDetailPage.tsx
+++ b/frontend/src/components/Operations/OperationDetailPage.tsx
@@ -4,7 +4,7 @@ import {
Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle,
MessageBar, MessageBarBody, Spinner, Text,
} from '@fluentui/react-components'
-import { ArrowLeftRegular } from '@fluentui/react-icons'
+import { AddRegular, ArrowLeftRegular } from '@fluentui/react-icons'
import { DEFAULT_HISTORY_FILTERS, filtersToSearchParams } from '@/components/History/historyFilters'
import { operationsApi } from '@/services/api'
@@ -12,6 +12,7 @@ import { toApiError } from '@/services/errors'
import type { Finding, FindingCreate, FindingListItem, FindingListResponse, OperationDetail } from '@/types'
import FindingDialog, { type FindingSaveAttempt } from './FindingDialog'
import FindingEvidenceList from './FindingEvidenceList'
+import FindingExportDialog from './FindingExportDialog'
import FindingSeverityBadge from './FindingSeverityBadge'
import OperationDeleteDialog from './OperationDeleteDialog'
import OperationEditDialog from './OperationEditDialog'
@@ -202,7 +203,8 @@ function OperationDetailContent({ operationId, onOperationChanged }: { operation
onOperationChanged?.()
void navigate('/operations')
}} />
-
diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts
index b7d5a0e384..c40ce0d65b 100644
--- a/frontend/src/services/api.test.ts
+++ b/frontend/src/services/api.test.ts
@@ -1,6 +1,7 @@
-// Mock axios before importing the module
+// Mock axios before importing the module. The instance is callable, like a
+// real Axios instance, so the 401 handler can replay a request through it.
jest.mock("axios", () => ({
- create: jest.fn(() => ({
+ create: jest.fn(() => Object.assign(jest.fn(), {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
@@ -36,9 +37,12 @@ import {
scenarioSuitesApi,
datasetsApi,
analyticsApi,
+ setMsalInstance,
} from "./api";
import { compatibility, COMPATIBILITY_HEADER } from "./compatibility";
+import { toApiError } from "./errors";
import { makeAnalyticsFacets, makeAnalyticsReport, makeAnalyticsResults } from "@/test-utils/analyticsFixtures";
+import type { FindingExportRequest } from "@/types";
import { DEFAULT_ANALYTICS_VIEW } from "@/utils/attackAnalytics";
describe("api service", () => {
@@ -88,6 +92,22 @@ describe("api service", () => {
expect(await operationsApi.deleteFinding("a/b", "c/d")).toBeUndefined();
expect(apiClient.delete).toHaveBeenCalledWith("/operations/a%2Fb/findings/c%2Fd");
});
+ it("downloads the explicitly selected findings as a cancellable file", async () => {
+ const request: FindingExportRequest = {
+ finding_ids: ["11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222"],
+ format: "markdown",
+ dashboard_origin: "https://pyrit.example.test",
+ };
+ const blob = new Blob(["# Findings"], { type: "text/markdown" });
+ const controller = new AbortController();
+ (apiClient.post as jest.Mock).mockResolvedValueOnce({ data: blob });
+ expect(await operationsApi.exportFindings("a/b", request, controller.signal)).toBe(blob);
+ expect(apiClient.post).toHaveBeenCalledWith(
+ "/operations/a%2Fb/findings/export",
+ request,
+ { responseType: "blob", signal: controller.signal },
+ );
+ });
});
// Interceptor functions are registered at module-load time.
// Capture them before beforeEach's clearAllMocks wipes the call records.
@@ -291,6 +311,117 @@ describe("api service", () => {
consoleSpy.mockRestore();
});
+
+ describe("file download errors", () => {
+ const backendId = `1.2.0.dev0+g${"b".repeat(40)}`;
+
+ function jsonBlob(body: unknown): Blob {
+ return new Blob([JSON.stringify(body)], { type: "application/json" });
+ }
+
+ function fileRequestError(status: number, data: unknown) {
+ const headers: Record = { "X-Request-ID": "export-request" };
+ return {
+ isAxiosError: true,
+ config: {
+ method: "post",
+ url: "/operations/op/findings/export",
+ responseType: "blob",
+ headers: Object.assign(headers, { set: (name: string, value: string) => { headers[name] = value; } }),
+ },
+ response: { status, data },
+ };
+ }
+
+ function replaceCompatibilityStore() {
+ const compatibilityModule = jest.requireMock("./compatibility");
+ const store = new compatibilityModule.CompatibilityStore(compatibility.bundledId);
+ jest.replaceProperty(compatibilityModule, "compatibility", store);
+ return store;
+ }
+
+ let consoleError: jest.SpyInstance;
+
+ beforeEach(() => {
+ consoleError = jest.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ setMsalInstance(null);
+ jest.restoreAllMocks();
+ });
+
+ it.each([
+ ["JSON", 409, "urn:pyrit:compatibility:mismatch", false],
+ ["Blob", 409, "urn:pyrit:compatibility:mismatch", true],
+ ["Blob", 400, "urn:pyrit:compatibility:invalid", true],
+ ])("blocks an incompatible frontend on a %s %i compatibility problem", async (_body, status, type, asBlob) => {
+ const store = replaceCompatibilityStore();
+ const problem = { type, expected: backendId, actual: compatibility.bundledId };
+ const error = fileRequestError(status, asBlob ? jsonBlob(problem) : problem);
+
+ await expect(responseOnError(error)).rejects.toBe(error);
+
+ expect(store.getSnapshot()).toEqual({
+ status: "blocked",
+ reason: "The backend rejected this frontend build identity.",
+ expected: backendId,
+ actual: compatibility.bundledId,
+ });
+ });
+
+ it.each([["JSON", false], ["Blob", true]])(
+ "refreshes the token and replays the file request once after a %s 401",
+ async (_body, asBlob) => {
+ const acquireTokenSilent = jest.fn().mockResolvedValue({ accessToken: "fresh-token" });
+ setMsalInstance({
+ getActiveAccount: () => ({ username: "user@example.com" }),
+ acquireTokenSilent,
+ } as unknown as Parameters[0]);
+ const replayed = { status: 200, data: new Blob(["document"]) };
+ (apiClient as unknown as jest.Mock).mockResolvedValueOnce(replayed);
+ const unauthorized = { detail: "Not authenticated" };
+ const error = fileRequestError(401, asBlob ? jsonBlob(unauthorized) : unauthorized);
+
+ await expect(responseOnError(error)).resolves.toBe(replayed);
+
+ expect(acquireTokenSilent).toHaveBeenCalledWith(expect.objectContaining({ forceRefresh: true }));
+ expect(apiClient).toHaveBeenCalledTimes(1);
+ expect(apiClient).toHaveBeenCalledWith(error.config);
+ expect(error.config).toMatchObject({ _retried: true, responseType: "blob" });
+ expect(error.config.headers.Authorization).toBe("Bearer fresh-token");
+ expect(consoleError).not.toHaveBeenCalled();
+ },
+ );
+
+ it("logs and rejects with a Blob error's decoded detail and original status", async () => {
+ const error = fileRequestError(404, jsonBlob({ detail: "Finding not found" }));
+
+ await expect(responseOnError(error)).rejects.toBe(error);
+
+ expect(toApiError(error)).toMatchObject({ status: 404, detail: "Finding not found" });
+ expect(consoleError).toHaveBeenCalledWith(
+ "[API] POST /operations/op/findings/export failed | status=404 | "
+ + "requestId=export-request | Finding not found",
+ );
+ });
+
+ it("keeps an unreadable Blob error a visible failure with its status", async () => {
+ const read = jest.spyOn(FileReader.prototype, "readAsText").mockImplementation(() => undefined);
+ const body = jsonBlob({ detail: "Unreadable" });
+ const error = fileRequestError(500, body);
+
+ const rejected = expect(responseOnError(error)).rejects.toBe(error);
+ const reader = read.mock.contexts[0];
+ if (!(reader instanceof FileReader)) throw new Error("Blob read did not start");
+ reader.dispatchEvent(new ProgressEvent("error"));
+ await rejected;
+
+ expect(error.response.data).toBe(body);
+ expect(toApiError(error)).toMatchObject({ status: 500, detail: "Server error (500)" });
+ expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("status=500"));
+ });
+ });
});
describe("analyticsApi", () => {
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 0d2ff8f984..a8ddd7dc4c 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -1,7 +1,7 @@
import axios, { type InternalAxiosRequestConfig } from 'axios'
import { InteractionRequiredAuthError, type PublicClientApplication } from '@azure/msal-browser'
import { generateClientId } from '@/utils/clientId'
-import { toApiError } from './errors'
+import { decodeBlobErrorAsync, toApiError } from './errors'
import { compatibility, COMPATIBILITY_HEADER } from './compatibility'
import { getGraphScopes } from '../auth/msalConfig'
import type {
@@ -87,6 +87,7 @@ import type {
FindingEvidenceCreateRequest,
FindingEvidenceAttachResponse,
FindingEvidenceListResponse,
+ FindingExportRequest,
Operation,
OperationCreate,
OperationDetail,
@@ -156,6 +157,13 @@ export const operationsApi = {
`${operationPath(operationId)}/findings/${encodeURIComponent(findingId)}/evidence/${encodeURIComponent(evidenceId)}`,
)
},
+ exportFindings: async (
+ operationId: string, request: FindingExportRequest, signal?: AbortSignal,
+ ): Promise => {
+ return (await apiClient.post(
+ `${operationPath(operationId)}/findings/export`, request, { responseType: 'blob', signal },
+ )).data
+ },
}
const apiClient = axios.create({
@@ -232,6 +240,8 @@ apiClient.interceptors.request.use(async (config) => {
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
+ // File downloads receive error bodies as Blobs; read them before any check below.
+ await decodeBlobErrorAsync(error)
const problem = error?.response?.data
if (
(error?.response?.status === 400 && problem?.type === 'urn:pyrit:compatibility:invalid') ||
diff --git a/frontend/src/services/errors.test.ts b/frontend/src/services/errors.test.ts
index e917f6c35d..a926cf5451 100644
--- a/frontend/src/services/errors.test.ts
+++ b/frontend/src/services/errors.test.ts
@@ -1,4 +1,4 @@
-import { retryAfterMilliseconds, toApiError } from './errors'
+import { decodeBlobErrorAsync, retryAfterMilliseconds, toApiError } from './errors'
import type { ApiError } from './errors'
// ---------------------------------------------------------------------------
@@ -208,3 +208,71 @@ describe('toApiError', () => {
expect(result.type).toBe('validation_error')
})
})
+
+describe('decodeBlobErrorAsync', () => {
+ it('replaces a JSON Blob body with the parsed problem and keeps the status', async () => {
+ const problem = { detail: 'Operation not found' }
+ const err = makeAxiosError({
+ status: 404,
+ data: new Blob([JSON.stringify(problem)], { type: 'application/json' }),
+ })
+
+ await decodeBlobErrorAsync(err)
+
+ expect(err).toMatchObject({ response: { status: 404, data: problem } })
+ expect(toApiError(err)).toMatchObject({ status: 404, detail: 'Operation not found' })
+ })
+
+ it('keeps a non-JSON Blob body, such as a proxy page, as readable text', async () => {
+ const page = 'Bad Gateway'
+ const err = makeAxiosError({ status: 502, data: new Blob([page], { type: 'text/html' }) })
+
+ await decodeBlobErrorAsync(err)
+
+ expect(err).toMatchObject({ response: { status: 502, data: page } })
+ expect(toApiError(err)).toMatchObject({ status: 502, detail: page })
+ })
+
+ it('leaves the error failing with its status when the Blob cannot be read', async () => {
+ const read = jest.spyOn(FileReader.prototype, 'readAsText').mockImplementation(() => undefined)
+ try {
+ const body = new Blob(['{"detail":"Unreadable"}'], { type: 'application/json' })
+ const err = makeAxiosError({ status: 500, data: body })
+
+ const decoding = decodeBlobErrorAsync(err)
+ const reader = read.mock.contexts[0]
+ if (!(reader instanceof FileReader)) throw new Error('Blob read did not start')
+ reader.dispatchEvent(new ProgressEvent('error'))
+
+ await expect(decoding).resolves.toBeUndefined()
+ expect(err).toMatchObject({ response: { status: 500 } })
+ expect((err as { response: { data: unknown } }).response.data).toBe(body)
+ expect(toApiError(err)).toMatchObject({ status: 500, detail: 'Server error (500)' })
+ } finally {
+ read.mockRestore()
+ }
+ })
+
+ it.each([
+ ['a JSON body', { detail: 'Finding not found' }],
+ ['a text body', 'Bad Gateway'],
+ ['no body', null],
+ ])('leaves an Axios error with %s unchanged', async (_name, data) => {
+ const err = makeAxiosError({ status: 404, data })
+
+ await decodeBlobErrorAsync(err)
+
+ expect((err as { response: { data: unknown } }).response.data).toBe(data)
+ })
+
+ it('ignores a Blob that is not an Axios response body', async () => {
+ const body = new Blob(['{"detail":"Not from Axios"}'], { type: 'application/json' })
+ const notAxios = { response: { status: 404, data: body } }
+
+ await decodeBlobErrorAsync(notAxios)
+ await decodeBlobErrorAsync(new Error('Something broke'))
+ await decodeBlobErrorAsync(makeAxiosError({ hasResponse: false }))
+
+ expect(notAxios.response.data).toBe(body)
+ })
+})
diff --git a/frontend/src/services/errors.ts b/frontend/src/services/errors.ts
index 4a197944bf..492d34a5dd 100644
--- a/frontend/src/services/errors.ts
+++ b/frontend/src/services/errors.ts
@@ -132,6 +132,28 @@ export function retryAfterMilliseconds(error: unknown): number {
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0
}
+/**
+ * Replace a file download's Blob error body with its JSON, or its text when it
+ * isn't JSON, so compatibility checks, token refresh and {@link toApiError} can
+ * read it. An unreadable body is left as is; other errors are unchanged.
+ */
+export async function decodeBlobErrorAsync(error: unknown): Promise {
+ const response = isAxiosError(error) ? error.response : undefined
+ const body: unknown = response?.data
+ if (!response || !(body instanceof Blob)) return
+ let text: string
+ try {
+ text = await readBlobText(body)
+ } catch {
+ return
+ }
+ try {
+ response.data = JSON.parse(text)
+ } catch {
+ response.data = text
+ }
+}
+
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
@@ -141,6 +163,15 @@ function isAxiosError(err: unknown): err is AxiosError {
return typeof err === 'object' && err !== null && (err as AxiosError).isAxiosError === true
}
+function readBlobText(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(reader.result as string)
+ reader.onerror = () => reject(reader.error)
+ reader.readAsText(blob)
+ })
+}
+
/**
* Extract `detail` and `type` from an Axios response body.
*
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 058839b200..fa7b55cddf 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -2249,3 +2249,13 @@ export interface FindingEvidenceListResponse {
has_more: boolean
next_offset: number | null
}
+
+export type FindingExportFormat = 'docx' | 'markdown'
+
+/** Selected findings of one Operation to download as a single document. */
+export interface FindingExportRequest {
+ finding_ids: string[]
+ format: FindingExportFormat
+ /** The dashboard origin that evidence links open. */
+ dashboard_origin: string
+}
diff --git a/frontend/src/utils/attackConversationRoute.cases.json b/frontend/src/utils/attackConversationRoute.cases.json
new file mode 100644
index 0000000000..cca481ed6a
--- /dev/null
+++ b/frontend/src/utils/attackConversationRoute.cases.json
@@ -0,0 +1,23 @@
+[
+ {
+ "attackResultId": "11111111-1111-4111-8111-111111111111",
+ "conversationId": "conversation-1",
+ "scenarioResultId": null,
+ "findingEvidenceId": "22222222-2222-4222-8222-222222222222",
+ "path": "/attacks/11111111-1111-4111-8111-111111111111/conversations/conversation-1?findingEvidenceId=22222222-2222-4222-8222-222222222222"
+ },
+ {
+ "attackResultId": "33333333-3333-4333-8333-333333333333",
+ "conversationId": "branch/α (1)?#%!*'~",
+ "scenarioResultId": null,
+ "findingEvidenceId": "44444444-4444-4444-8444-444444444444",
+ "path": "/attacks/33333333-3333-4333-8333-333333333333/conversations/branch%2F%CE%B1%20(1)%3F%23%25!*'~?findingEvidenceId=44444444-4444-4444-8444-444444444444"
+ },
+ {
+ "attackResultId": "55555555-5555-4555-8555-555555555555",
+ "conversationId": "conversation-3",
+ "scenarioResultId": "66666666-6666-4666-8666-666666666666",
+ "findingEvidenceId": "77777777-7777-4777-8777-777777777777",
+ "path": "/attacks/55555555-5555-4555-8555-555555555555/conversations/conversation-3?scenarioResultId=66666666-6666-4666-8666-666666666666&findingEvidenceId=77777777-7777-4777-8777-777777777777"
+ }
+]
diff --git a/frontend/src/utils/conversationExport.ts b/frontend/src/utils/conversationExport.ts
index 154b62b1c6..11df6cf861 100644
--- a/frontend/src/utils/conversationExport.ts
+++ b/frontend/src/utils/conversationExport.ts
@@ -1,4 +1,5 @@
import type { Message, MessageAttachment, MessageError } from '../types'
+import { downloadFile } from './downloadFile'
import { fileToBase64 } from './messageMapper'
export type ExportFormat = 'markdown' | 'json' | 'html'
@@ -275,26 +276,11 @@ export function buildExportFilename(
}
/**
- * Trigger a browser download of `content` as `filename`. Uses the Blob → object
- * URL → anchor-click idiom and always revokes the object URL, even if the click
- * throws.
+ * Trigger a browser download of `content` as `filename`, encoded as a Blob of
+ * `mimeType`. See {@link downloadFile} for the link and object URL cleanup.
*/
export function downloadTextFile(content: string, filename: string, mimeType: string): void {
- const blob = new Blob([content], { type: mimeType })
- const objectUrl = URL.createObjectURL(blob)
- try {
- const link = document.createElement('a')
- link.href = objectUrl
- link.download = filename
- document.body.appendChild(link)
- try {
- link.click()
- } finally {
- link.remove()
- }
- } finally {
- URL.revokeObjectURL(objectUrl)
- }
+ downloadFile(new Blob([content], { type: mimeType }), filename)
}
function withoutLoadingPlaceholders(messages: Message[]): Message[] {
diff --git a/frontend/src/utils/downloadFile.test.ts b/frontend/src/utils/downloadFile.test.ts
new file mode 100644
index 0000000000..998acffbb9
--- /dev/null
+++ b/frontend/src/utils/downloadFile.test.ts
@@ -0,0 +1,66 @@
+import { downloadFile } from "./downloadFile";
+
+const WORD_MIME_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+
+/** Observe the download link the helper creates, replacing its click with `onClick`. */
+function spyOnDownloadLink(onClick: (link: HTMLAnchorElement) => void): () => HTMLAnchorElement | undefined {
+ let link: HTMLAnchorElement | undefined;
+ const createElement = document.createElement.bind(document);
+ jest.spyOn(document, "createElement").mockImplementation((tagName: string) => {
+ const element = createElement(tagName);
+ if (element instanceof HTMLAnchorElement) {
+ link = element;
+ jest.spyOn(element, "click").mockImplementation(() => onClick(element));
+ }
+ return element;
+ });
+ return () => link;
+}
+
+describe("downloadFile", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("should click a link to the Blob under the requested filename, then remove it and revoke the URL", () => {
+ const blob = new Blob(["report"], { type: WORD_MIME_TYPE });
+ const clicks: Array<{ href: string; download: string; attached: boolean; revoked: boolean }> = [];
+ const getLink = spyOnDownloadLink((link) => {
+ clicks.push({
+ href: link.href,
+ download: link.download,
+ attached: link.isConnected,
+ revoked: jest.mocked(URL.revokeObjectURL).mock.calls.length > 0,
+ });
+ });
+
+ downloadFile(blob, "copyrit-findings-operation-20261021T120000Z.docx");
+
+ expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
+ expect(clicks).toEqual([{
+ href: "blob:mock-url",
+ download: "copyrit-findings-operation-20261021T120000Z.docx",
+ attached: true,
+ revoked: false,
+ }]);
+ expect(getLink()?.isConnected).toBe(false);
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
+ });
+
+ it("should remove the link and revoke the URL when starting the download throws", () => {
+ const getLink = spyOnDownloadLink(() => {
+ throw new Error("download blocked");
+ });
+
+ expect(() => downloadFile(new Blob(["# Findings"], { type: "text/markdown" }), "findings.md"))
+ .toThrow("download blocked");
+
+ expect(getLink()?.isConnected).toBe(false);
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
+ });
+});
diff --git a/frontend/src/utils/downloadFile.ts b/frontend/src/utils/downloadFile.ts
new file mode 100644
index 0000000000..859a088f61
--- /dev/null
+++ b/frontend/src/utils/downloadFile.ts
@@ -0,0 +1,21 @@
+/**
+ * Start a browser download of `blob` as `filename` by clicking a temporary link
+ * to an object URL. The link is removed and the URL revoked even if the click
+ * throws.
+ */
+export function downloadFile(blob: Blob, filename: string): void {
+ const objectUrl = URL.createObjectURL(blob)
+ try {
+ const link = document.createElement('a')
+ link.href = objectUrl
+ link.download = filename
+ document.body.appendChild(link)
+ try {
+ link.click()
+ } finally {
+ link.remove()
+ }
+ } finally {
+ URL.revokeObjectURL(objectUrl)
+ }
+}
diff --git a/frontend/src/utils/findingExportFilename.test.ts b/frontend/src/utils/findingExportFilename.test.ts
new file mode 100644
index 0000000000..f50b800158
--- /dev/null
+++ b/frontend/src/utils/findingExportFilename.test.ts
@@ -0,0 +1,31 @@
+import { findingExportFilename } from './findingExportFilename'
+
+const OPERATION_ID = 'c1e5502b-91f6-4d65-b182-4b028c7a75f6'
+// Built from local parts, so the expected time does not depend on the test machine's time zone.
+const EXPORT_TIME = new Date(2026, 9, 22, 12, 58, 33)
+
+describe('findingExportFilename', () => {
+ it.each([
+ ['Synthetic evaluation', 'synthetic-evaluation'],
+ ['Q3 Red Team / Copilot (v2)', 'q3-red-team-copilot-v2'],
+ ['Évaluation: modèle 2', 'évaluation-modèle-2'],
+ ['E\u0301valuation', 'évaluation'],
+ // Devanagari writes vowel signs and viramas as combining marks, which belong to their word.
+ ['हिन्दी परीक्षण', 'हिन्दी-परीक्षण'],
+ ['snake_case name', 'snake-case-name'],
+ [`${'a'.repeat(58)} bcdef`, 'a'.repeat(58)],
+ ['x'.repeat(80), 'x'.repeat(60)],
+ ['𠀀'.repeat(61), '𠀀'.repeat(60)],
+ // The 61st code point is a vowel sign, so the cut moves back before its letter.
+ [`क${'कि'.repeat(30)}`, `क${'कि'.repeat(29)}`],
+ ['???', OPERATION_ID],
+ ])('names %j after the Operation, in local time', (name, expected) => {
+ expect(findingExportFilename({ id: OPERATION_ID, name }, 'docx', EXPORT_TIME))
+ .toBe(`copyrit-findings-${expected}-2026-10-22-1258.docx`)
+ })
+
+ it('pads single-digit months, days, hours and minutes', () => {
+ expect(findingExportFilename({ id: OPERATION_ID, name: 'Synthetic evaluation' }, 'md', new Date(2026, 0, 5, 7, 4)))
+ .toBe('copyrit-findings-synthetic-evaluation-2026-01-05-0704.md')
+ })
+})
diff --git a/frontend/src/utils/findingExportFilename.ts b/frontend/src/utils/findingExportFilename.ts
new file mode 100644
index 0000000000..704d318633
--- /dev/null
+++ b/frontend/src/utils/findingExportFilename.ts
@@ -0,0 +1,26 @@
+import type { Operation } from '@/types'
+
+const NAME_LIMIT = 60
+const MARK = /^\p{M}$/u
+
+/**
+ * Name an exported findings document after its Operation and the user's local time, e.g.
+ * `copyrit-findings-synthetic-evaluation-2026-10-22-1258.docx`. The backend's suggested name, for
+ * other clients, uses the Operation ID and labelled UTC instead.
+ */
+export function findingExportFilename(operation: Pick, extension: string, now: Date): string {
+ const name = Array.from(
+ operation.name.normalize('NFC').toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, '-').replace(/^-+|-+$/g, ''),
+ )
+ let end = name.length
+ if (name.length > NAME_LIMIT) {
+ end = NAME_LIMIT
+ if (name[end] !== '-' && name.slice(0, end).includes('-')) end = name.lastIndexOf('-', end - 1)
+ // A word longer than the limit is cut between letters, never between a letter and its marks.
+ while (end > 0 && MARK.test(name[end])) end -= 1
+ }
+ const slug = name.slice(0, end).join('').replace(/-+$/, '')
+ const pad = (value: number) => String(value).padStart(2, '0')
+ const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
+ return `copyrit-findings-${slug || operation.id}-${date}-${pad(now.getHours())}${pad(now.getMinutes())}.${extension}`
+}
diff --git a/frontend/src/utils/routeParams.test.ts b/frontend/src/utils/routeParams.test.ts
index dc1f2e98ab..38a5db19fb 100644
--- a/frontend/src/utils/routeParams.test.ts
+++ b/frontend/src/utils/routeParams.test.ts
@@ -13,9 +13,19 @@ import {
isDatasetsPath,
isChatPath,
} from './routeParams'
+import attackConversationRouteCases from './attackConversationRoute.cases.json'
const SCENARIO_RESULT_ID = '123e4567-e89b-12d3-a456-426614174000'
+describe('attack conversation route contract', () => {
+ // The backend's findings export builds the same links; its tests check these cases too.
+ it.each(attackConversationRouteCases)('builds $path', (routeCase) => {
+ expect(attackConversationRoutePath(
+ routeCase.attackResultId, routeCase.conversationId, routeCase.scenarioResultId, routeCase.findingEvidenceId,
+ )).toBe(routeCase.path)
+ })
+})
+
describe('routerPathParamValue', () => {
it('returns an empty value for a missing route parameter', () => {
expect(routerPathParamValue(undefined)).toBe('')
diff --git a/frontend/src/utils/routeParams.ts b/frontend/src/utils/routeParams.ts
index a806c314da..82ec7e77f7 100644
--- a/frontend/src/utils/routeParams.ts
+++ b/frontend/src/utils/routeParams.ts
@@ -41,7 +41,13 @@ export function attackRoutePath(
)
}
-/** Builds an attack-conversation route with optional bounded scenario-run provenance. */
+/**
+ * Builds an attack-conversation route with optional bounded scenario-run provenance.
+ *
+ * Exported findings documents link to this route (`_conversation_link` in
+ * `pyrit/backend/services/finding_export.py`), so keep its old shape working if it changes. Both
+ * builders are tested against `attackConversationRoute.cases.json`.
+ */
export function attackConversationRoutePath(
attackResultId: string,
conversationId: string,
diff --git a/pyrit/backend/models/operations.py b/pyrit/backend/models/operations.py
index 1b62d10bc0..727b13f081 100644
--- a/pyrit/backend/models/operations.py
+++ b/pyrit/backend/models/operations.py
@@ -4,7 +4,7 @@
from enum import Enum
from uuid import UUID
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, field_validator
from pyrit.backend.models.attacks import ScoreView
from pyrit.models import (
@@ -134,3 +134,58 @@ class FindingEvidenceListResponse(BaseModel):
items: list[FindingEvidenceItem]
has_more: bool
next_offset: int | None
+
+
+class FindingExportFormat(str, Enum):
+ """A file format for exported findings."""
+
+ DOCX = "docx"
+ MARKDOWN = "markdown"
+
+
+class FindingExportRequest(BaseModel):
+ """The findings to download, the file format, and the dashboard origin their evidence links open."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ finding_ids: list[UUID] = Field(min_length=1, description="Distinct findings of the operation to include")
+ format: FindingExportFormat = FindingExportFormat.DOCX
+ dashboard_origin: AnyHttpUrl = Field(description="The dashboard's origin, which evidence links open")
+
+ @field_validator("finding_ids")
+ @classmethod
+ def _reject_duplicates(cls, finding_ids: list[UUID]) -> list[UUID]:
+ """
+ Require each finding to be selected once.
+
+ Returns:
+ list[UUID]: The selected finding IDs.
+
+ Raises:
+ ValueError: If a finding is selected more than once.
+ """
+ if len(set(finding_ids)) != len(finding_ids):
+ raise ValueError("Each finding can be selected only once.")
+ return finding_ids
+
+ @field_validator("dashboard_origin")
+ @classmethod
+ def _require_origin(cls, url: AnyHttpUrl) -> AnyHttpUrl:
+ """
+ Accept only a scheme, host, and optional port.
+
+ Returns:
+ AnyHttpUrl: The dashboard origin.
+
+ Raises:
+ ValueError: If the address has credentials, a path, a query, or a fragment.
+ """
+ if (
+ url.username
+ or url.password
+ or url.path not in (None, "/")
+ or url.query is not None
+ or url.fragment is not None
+ ):
+ raise ValueError("Must be an origin, without credentials, a path, a query, or a fragment.")
+ return url
diff --git a/pyrit/backend/routes/operations.py b/pyrit/backend/routes/operations.py
index b7719df90d..69894dcab3 100644
--- a/pyrit/backend/routes/operations.py
+++ b/pyrit/backend/routes/operations.py
@@ -12,12 +12,14 @@
FindingEvidenceAttachResponse,
FindingEvidenceCreateRequest,
FindingEvidenceListResponse,
+ FindingExportRequest,
FindingListResponse,
FindingOptionsResponse,
OperationDetail,
OperationListResponse,
OperationUpdateRequest,
)
+from pyrit.backend.services.finding_export import export_findings_async
from pyrit.backend.services.operation_service import (
DuplicateOperationError,
FindingConflictError,
@@ -164,6 +166,24 @@ async def list_operation_findings_async(
)
+@router.post("/{operation_id}/findings/export", response_class=Response)
+async def export_operation_findings_async(*, operation_id: UUID, request: FindingExportRequest) -> Response:
+ """
+ Download selected findings of an operation as one Word or Markdown document, in the dashboard's order.
+
+ Evidence appears as saved metadata and dashboard links, without transcripts.
+
+ Returns:
+ Response: The complete document as an attachment.
+ """
+ exported = await _require_operation_async(export_findings_async(operation_id=operation_id, request=request))
+ return Response(
+ content=exported.content,
+ media_type=exported.media_type,
+ headers={"Content-Disposition": f'attachment; filename="{exported.filename}"'},
+ )
+
+
@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:
"""
diff --git a/pyrit/backend/services/finding_export.py b/pyrit/backend/services/finding_export.py
new file mode 100644
index 0000000000..3679c1d00a
--- /dev/null
+++ b/pyrit/backend/services/finding_export.py
@@ -0,0 +1,322 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Selected findings of an Operation as a Word or Markdown section to copy into a report."""
+
+import asyncio
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from functools import partial
+from io import BytesIO
+from urllib.parse import quote, urlencode
+from uuid import UUID
+
+from docx import Document
+from docx.document import Document as DocxDocument
+from docx.opc.constants import RELATIONSHIP_TYPE
+from docx.oxml import OxmlElement
+from docx.oxml.ns import qn
+from docx.shared import Emu, Pt, RGBColor
+from docx.table import Table, _Cell
+from docx.text.paragraph import Paragraph
+from pydantic import AnyHttpUrl
+
+from pyrit.backend.models.attacks import ScoreView
+from pyrit.backend.models.operations import (
+ EvidenceAvailability,
+ FindingEvidenceItem,
+ FindingExportFormat,
+ 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.output.finding_report.markdown import MarkdownFindingReportPrinter
+from pyrit.output.finding_report.report import (
+ DETAIL_SEPARATOR,
+ EVIDENCE_NOTE,
+ NO_EVIDENCE,
+ UNAVAILABLE_EVIDENCE,
+ FindingReport,
+ FindingReportColumn,
+ FindingReportEntry,
+ FindingReportEvidence,
+ FindingReportField,
+ plain_text,
+)
+
+_MARKDOWN_MEDIA_TYPE = "text/markdown; charset=utf-8"
+_DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+_LINK_COLOR = RGBColor(0x05, 0x63, 0xC1)
+_HUMAN_SCORER = "ManualScorer"
+_BOOLEAN_SCORES = {"true": "True", "false": "False"}
+# Word 2013 and later; the template's older mode opens documents in Compatibility Mode.
+_WORD_COMPATIBILITY_MODE = "15"
+_WORD_TABLE_STYLE = "Table Grid"
+_WORD_COLUMNS = ("Finding", "Assessment")
+# Each column's share of the page's text width: the Finding account needs most of the room.
+_WORD_COLUMN_SHARES = (0.75, 0.25)
+_WORD_BULLET_STYLE = "List Bullet"
+# The template's List Bullet text indent, so an entry's lines align under its bullet text.
+_WORD_BULLET_TEXT_INDENT = Pt(18)
+
+
+@dataclass(frozen=True)
+class FindingExportFile:
+ """A generated findings document and the name to download it as."""
+
+ content: bytes
+ media_type: str
+ filename: str
+
+
+async def export_findings_async(*, operation_id: UUID, request: FindingExportRequest) -> FindingExportFile:
+ """
+ Build one document of an Operation's selected findings, in the dashboard's listing order, with evidence references.
+
+ Evidence appears as saved metadata and dashboard links, without transcripts or media.
+
+ Returns:
+ FindingExportFile: The complete document.
+
+ Raises:
+ OperationNotFoundError: If the Operation does not exist.
+ FindingNotFoundError: If a selected finding is not in the Operation.
+ """
+ report = await _build_report_async(operation_id=operation_id, request=request)
+ now = datetime.now(UTC)
+ if request.format == FindingExportFormat.MARKDOWN:
+ extension, media_type = "md", _MARKDOWN_MEDIA_TYPE
+ content = (await MarkdownFindingReportPrinter().render_async(report)).encode("utf-8")
+ else:
+ extension, media_type = "docx", _DOCX_MEDIA_TYPE
+ content = await asyncio.to_thread(partial(_render_docx, report=report, now=now))
+ filename = _export_filename(operation_id=operation_id, extension=extension, now=now)
+ return FindingExportFile(content=content, media_type=media_type, filename=filename)
+
+
+def _export_filename(*, operation_id: UUID, extension: str, now: datetime) -> str:
+ """
+ Name a download after its Operation ID, with the time in labelled UTC.
+
+ Browsers save under the dashboard's own name, which uses the Operation name and the user's local time instead.
+
+ Returns:
+ str: For example ``copyrit-findings-c1e5502b-91f6-4d65-b182-4b028c7a75f6-2026-10-22-1658-utc.docx``.
+ """
+ return f"copyrit-findings-{operation_id}-{now.astimezone(UTC):%Y-%m-%d-%H%M}-utc.{extension}"
+
+
+async def _build_report_async(*, operation_id: UUID, request: FindingExportRequest) -> FindingReport:
+ service = OperationService()
+ operation = await service.get_async(operation_id)
+ selected = await service.get_findings_with_evidence_async(
+ operation_id=operation_id, finding_ids=request.finding_ids
+ )
+ report_findings = []
+ for finding, evidence in selected:
+ severity, fields = _project_finding_fields(finding)
+ report_findings.append(
+ FindingReportEntry(
+ title=finding.title,
+ severity=severity,
+ fields=fields,
+ evidence=tuple(
+ _project_evidence(item=item, operation_id=operation_id, dashboard_origin=request.dashboard_origin)
+ for item in evidence
+ ),
+ )
+ )
+ return FindingReport(operation_name=operation.name, findings=tuple(report_findings))
+
+
+def _project_finding_fields(finding: Finding) -> tuple[FindingReportField, tuple[FindingReportField, ...]]:
+ """
+ Label a finding's severity and its populated fields, in report order, and place each in a Word column.
+
+ Narrative text goes in the Finding column and classifications in the Assessment column.
+
+ Returns:
+ tuple[FindingReportField, tuple[FindingReportField, ...]]: The severity, then the populated fields.
+ """
+ severity = (
+ finding.severity_other or "Other"
+ if finding.severity == FindingSeverity.OTHER
+ else finding.severity.value.capitalize()
+ )
+ 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)
+
+
+def _project_evidence(
+ *, item: FindingEvidenceItem, operation_id: UUID, dashboard_origin: AnyHttpUrl
+) -> FindingReportEvidence:
+ if item.outcome is None:
+ # The attack result is gone, so its saved ID stands in for its objective.
+ return FindingReportEvidence(title=str(item.item.attack_result_id), summary=(), details=(), link_url=None)
+ objective = item.objective if item.objective and item.objective.strip() else "No objective"
+ summary = (
+ FindingReportField(label="Outcome", value=item.outcome.value.capitalize()),
+ FindingReportField(label="Score", value=_score_text(item.score)),
+ )
+ details = []
+ if item.score is not None and item.score.score_rationale:
+ details.append(FindingReportField(label="Rationale", value=item.score.score_rationale))
+ # As on the dashboard, an attack's Operation is named only when it isn't the document's own.
+ if item.source_operation_id != operation_id:
+ details.append(
+ FindingReportField(label="From operation", value=item.source_operation_name or "Not in an operation")
+ )
+ link_url = None
+ if item.availability == EvidenceAvailability.AVAILABLE and item.conversation_id is not None:
+ link_url = _conversation_link(
+ dashboard_origin=dashboard_origin, item=item, conversation_id=item.conversation_id
+ )
+ return FindingReportEvidence(title=objective, summary=summary, details=tuple(details), link_url=link_url)
+
+
+def _score_text(score: ScoreView | None) -> str:
+ if score is None:
+ return "Not scored"
+ if score.status == ScoreStatus.UNDETERMINED:
+ value = "Undetermined"
+ else:
+ value = _BOOLEAN_SCORES.get(str(score.score_value).lower(), str(score.score_value))
+ scorer = "human review" if score.scorer_type == _HUMAN_SCORER else score.scorer_type
+ return f"{value} ({scorer})"
+
+
+def _conversation_link(*, dashboard_origin: AnyHttpUrl, item: FindingEvidenceItem, conversation_id: str) -> str:
+ """
+ Build the dashboard route that opens evidence's conversation and returns to the evidence.
+
+ Returns:
+ str: The absolute link, matching the dashboard's ``attackConversationRoutePath``; both are tested against
+ ``frontend/src/utils/attackConversationRoute.cases.json``.
+ """
+ path = (
+ f"/attacks/{_encode_segment(str(item.item.attack_result_id))}/conversations/{_encode_segment(conversation_id)}"
+ )
+ query = {"scenarioResultId": str(item.scenario_result_id)} if item.scenario_result_id else {}
+ query["findingEvidenceId"] = str(item.item.id)
+ return f"{str(dashboard_origin).rstrip('/')}{path}?{urlencode(query)}"
+
+
+def _encode_segment(value: str) -> str:
+ # The same characters JavaScript's encodeURIComponent leaves unescaped.
+ return quote(value, safe="!*'()")
+
+
+def _render_docx(*, report: FindingReport, now: datetime) -> bytes:
+ document = Document()
+ title = plain_text(f"Findings - {report.operation_name}")
+ _replace_template_metadata(document=document, title=title, now=now)
+ document.add_heading(title, level=1)
+ document.add_paragraph(EVIDENCE_NOTE)
+ table = document.add_table(rows=1, cols=len(_WORD_COLUMNS))
+ table.style = document.styles[_WORD_TABLE_STYLE]
+ header = table.rows[0]
+ for cell, column in zip(header.cells, _WORD_COLUMNS, strict=True):
+ cell.paragraphs[0].add_run(column).bold = True
+ for finding in report.findings:
+ finding_cell, assessment_cell = table.add_row().cells
+ finding_cell.paragraphs[0].add_run(plain_text(finding.title)).bold = True
+ for field in finding.fields:
+ if field.column == FindingReportColumn.FINDING:
+ _write_word_field(paragraph=finding_cell.add_paragraph(), field=field)
+ _add_word_evidence(cell=finding_cell, evidence=finding.evidence)
+ _write_word_field(paragraph=assessment_cell.paragraphs[0], field=finding.severity)
+ for field in finding.fields:
+ if field.column == FindingReportColumn.ASSESSMENT:
+ _write_word_field(paragraph=assessment_cell.add_paragraph(), field=field)
+ _set_column_widths(document=document, table=table)
+ buffer = BytesIO()
+ document.save(buffer)
+ return buffer.getvalue()
+
+
+def _replace_template_metadata(*, document: DocxDocument, title: str, now: datetime) -> None:
+ """Describe the export rather than python-docx's template, and open it outside Word's Compatibility Mode."""
+ for setting in document.settings.element.iter(qn("w:compatSetting")):
+ if setting.get(qn("w:name")) == "compatibilityMode":
+ setting.set(qn("w:val"), _WORD_COMPATIBILITY_MODE)
+ properties = document.core_properties
+ properties.title = title
+ properties.author = properties.comments = properties.last_modified_by = ""
+ properties.created = properties.modified = now.astimezone(UTC)
+ properties.revision = 1
+ # The template's sample preview image and "Microsoft Macintosh Word" application details don't describe this file.
+ package_relationships = document.part.package.rels
+ for relationship_id, relationship in list(package_relationships.items()):
+ if relationship.reltype in (RELATIONSHIP_TYPE.EXTENDED_PROPERTIES, RELATIONSHIP_TYPE.THUMBNAIL):
+ del package_relationships[relationship_id]
+
+
+def _set_column_widths(*, document: DocxDocument, table: Table) -> None:
+ """Give each column its share of the page's text width, rather than letting Word size columns by content."""
+ section = document.sections[0]
+ page_width, left_margin, right_margin = section.page_width, section.left_margin, section.right_margin
+ if page_width is None or left_margin is None or right_margin is None:
+ # Without a page size and margins there is no text width to divide, so Word sizes the columns.
+ return
+ text_width = page_width - left_margin - right_margin
+ table.autofit = False
+ for column, share in zip(table.columns, _WORD_COLUMN_SHARES, strict=True):
+ width = Emu(int(text_width * share))
+ column.width = width
+ # Word reads each cell's width and LibreOffice the column's, so both are set.
+ for cell in column.cells:
+ cell.width = width
+
+
+def _add_word_evidence(*, cell: _Cell, evidence: tuple[FindingReportEvidence, ...]) -> None:
+ if not evidence:
+ _write_word_field(paragraph=cell.add_paragraph(), field=NO_EVIDENCE)
+ return
+ cell.add_paragraph().add_run(f"{NO_EVIDENCE.label}:").bold = True
+ for entry in evidence:
+ lead = cell.add_paragraph(style=_WORD_BULLET_STYLE)
+ lead.paragraph_format.space_before = Pt(6)
+ lead.paragraph_format.space_after = Pt(2)
+ if entry.link_url is not None:
+ _add_word_hyperlink(paragraph=lead, url=entry.link_url, text=plain_text(entry.title))
+ else:
+ lead.add_run(plain_text(entry.title)).bold = True
+ if entry.summary:
+ line = _indented_paragraph(cell)
+ for index, field in enumerate(entry.summary):
+ if index:
+ line.add_run(DETAIL_SEPARATOR)
+ _write_word_field(paragraph=line, field=field)
+ for field in entry.details:
+ _write_word_field(paragraph=_indented_paragraph(cell), field=field)
+ if entry.unavailable:
+ _indented_paragraph(cell).add_run(UNAVAILABLE_EVIDENCE)
+
+
+def _indented_paragraph(cell: _Cell) -> Paragraph:
+ paragraph: Paragraph = cell.add_paragraph()
+ paragraph.paragraph_format.left_indent = _WORD_BULLET_TEXT_INDENT
+ paragraph.paragraph_format.space_after = Pt(2)
+ return paragraph
+
+
+def _write_word_field(*, paragraph: Paragraph, field: FindingReportField) -> None:
+ paragraph.add_run(f"{plain_text(field.label)}: ").bold = True
+ paragraph.add_run(plain_text(field.value))
+
+
+def _add_word_hyperlink(*, paragraph: Paragraph, url: str, text: str) -> None:
+ hyperlink = OxmlElement("w:hyperlink")
+ hyperlink.set(qn("r:id"), paragraph.part.relate_to(url, RELATIONSHIP_TYPE.HYPERLINK, is_external=True))
+ run = paragraph.add_run(text)
+ run.font.underline = True
+ run.font.color.rgb = _LINK_COLOR
+ hyperlink.append(run._r)
+ paragraph._p.append(hyperlink)
diff --git a/pyrit/backend/services/operation_service.py b/pyrit/backend/services/operation_service.py
index f1d638b3ee..8fa55438f0 100644
--- a/pyrit/backend/services/operation_service.py
+++ b/pyrit/backend/services/operation_service.py
@@ -19,7 +19,18 @@
OperationUpdateRequest,
)
from pyrit.memory import CentralMemory
-from pyrit.models import AttackResult, Finding, FindingCreate, FindingEvidence, Operation, OperationCreate
+from pyrit.models import (
+ AttackResult,
+ AttackResultSelection,
+ Finding,
+ FindingCreate,
+ FindingEvidence,
+ Operation,
+ OperationCreate,
+)
+
+# Keeps every ID list a batch reads within SQL Server's 2100-parameter limit.
+_EVIDENCE_BATCH_SIZE = 500
class OperationNotFoundError(LookupError):
@@ -232,14 +243,64 @@ async def list_finding_evidence_async(
"""
await self.get_async(operation_id)
await self._require_finding_async(operation_id=operation_id, finding_id=finding_id)
- memory = CentralMemory.get_memory_instance()
- evidence = await memory.get_finding_evidence_async(finding_id=finding_id, limit=limit + 1, offset=offset)
+ evidence = await CentralMemory.get_memory_instance().get_finding_evidence_async(
+ finding_id=finding_id, limit=limit + 1, offset=offset
+ )
has_more = len(evidence) > limit
- page = evidence[:limit]
- attacks = (
- await memory.get_attack_results_async(attack_result_ids=list({str(item.attack_result_id) for item in page}))
- if page
- else []
+ return FindingEvidenceListResponse(
+ items=await self._resolve_evidence_async(evidence=evidence[:limit]),
+ has_more=has_more,
+ next_offset=offset + limit if has_more else None,
+ )
+
+ async def get_findings_with_evidence_async(
+ self, *, operation_id: UUID, finding_ids: Sequence[UUID]
+ ) -> list[tuple[Finding, list[FindingEvidenceItem]]]:
+ """
+ Read selected findings of an operation, each with all its evidence as the evidence page shows it.
+
+ Returns:
+ list[tuple[Finding, list[FindingEvidenceItem]]]: Each finding with its evidence, newest evidence first,
+ ordered as ``list_findings_async`` orders findings.
+
+ Raises:
+ OperationNotFoundError: If no operation has this ID.
+ FindingNotFoundError: If a requested finding is not in the operation.
+ """
+ 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)
+ 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:
+ raise FindingNotFoundError(f"Finding '{missing}' not found in operation '{operation_id}'.")
+ evidence = await memory.get_finding_evidence_for_findings_async(
+ finding_ids=[finding.id for finding in findings]
+ )
+ resolved = await self._resolve_evidence_async(
+ evidence=[item for finding in findings for item in evidence.get(finding.id, [])]
+ )
+ by_id = {item.item.id: item for item in resolved}
+ return [(finding, [by_id[item.id] for item in evidence.get(finding.id, [])]) for finding in findings]
+
+ async def _resolve_evidence_async(self, *, evidence: Sequence[FindingEvidence]) -> list[FindingEvidenceItem]:
+ """
+ Show each evidence link with its attack's current outcome, score, and conversation.
+
+ Returns:
+ list[FindingEvidenceItem]: The evidence in input order.
+ """
+ items: list[FindingEvidenceItem] = []
+ for start in range(0, len(evidence), _EVIDENCE_BATCH_SIZE):
+ batch = evidence[start : start + _EVIDENCE_BATCH_SIZE]
+ items.extend(await self._resolve_evidence_batch_async(evidence=batch))
+ return items
+
+ async def _resolve_evidence_batch_async(self, *, evidence: Sequence[FindingEvidence]) -> list[FindingEvidenceItem]:
+ memory = CentralMemory.get_memory_instance()
+ attacks = await memory.get_attack_results_async(
+ attack_result_ids=list({str(item.attack_result_id) for item in evidence}),
+ result_selection=AttackResultSelection.ALL_RESULTS,
)
owners = {attack.attack_result_id: attack for attack in attacks}
linked_ids = {attack.operation_id for attack in attacks if attack.operation_id is not None}
@@ -251,7 +312,7 @@ async def list_finding_evidence_async(
conversations = await self._score_conversations_async(attacks=attacks)
stats = await memory.get_conversation_stats_async(conversation_ids=list(set(conversations.values())))
items = []
- for item in page:
+ for item in evidence:
owner = owners.get(str(item.attack_result_id))
if owner is None:
items.append(
@@ -279,9 +340,7 @@ async def list_finding_evidence_async(
conversation_id=conversation_id,
)
)
- return FindingEvidenceListResponse(
- items=items, has_more=has_more, next_offset=offset + limit if has_more else None
- )
+ return items
@staticmethod
async def _score_conversations_async(*, attacks: Sequence[AttackResult]) -> dict[str, str]:
diff --git a/pyrit/memory/memory_interface.py b/pyrit/memory/memory_interface.py
index 075afb9c05..3bcf1671ec 100644
--- a/pyrit/memory/memory_interface.py
+++ b/pyrit/memory/memory_interface.py
@@ -971,10 +971,6 @@ async def get_findings_async(
"""
if limit < 1 or offset < 0:
raise ValueError("Finding limit must be positive and offset must be nonnegative.")
- severity_order = case(
- {severity.value: position for position, severity in enumerate(FindingSeverity)},
- value=FindingEntry.severity,
- )
statement = select(FindingEntry)
if operation_id is not None:
statement = statement.where(FindingEntry.operation_id == operation_id)
@@ -982,14 +978,42 @@ 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(severity_order, FindingEntry.created_at.desc(), FindingEntry.id.desc())
- .limit(limit)
- .offset(offset)
- )
+ statement = statement.order_by(*self._finding_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]
+ ) -> list[Finding]:
+ """
+ Read selected findings of one operation, highest severity first, then newest first.
+
+ IDs that are not in the operation are omitted.
+
+ Returns:
+ list[Finding]: The selected findings, in the same order as ``get_findings_async``.
+ """
+ if not finding_ids:
+ return []
+ requested = set(finding_ids)
+ # The operation filter takes one of the statement's binds.
+ chunk_size = max(1, self._MAX_BIND_VARS - 1)
+ async with await self.get_session_async() as session:
+ ordered_ids = await session.scalars(
+ select(FindingEntry.id)
+ .where(FindingEntry.operation_id == operation_id)
+ .order_by(*self._finding_order())
+ )
+ selected_ids = [finding_id for finding_id in ordered_ids if finding_id in requested]
+ findings: dict[uuid.UUID, Finding] = {}
+ for start in range(0, len(selected_ids), chunk_size):
+ statement = select(FindingEntry).where(
+ FindingEntry.id.in_(selected_ids[start : start + chunk_size]),
+ FindingEntry.operation_id == operation_id,
+ )
+ findings.update({entry.id: entry.get_finding() for entry in await session.scalars(statement)})
+ return [findings[finding_id] for finding_id in selected_ids if finding_id in findings]
+
async def get_finding_async(self, *, operation_id: uuid.UUID, finding_id: uuid.UUID) -> Finding | None:
"""
Read a finding only within its owning operation.
@@ -1060,6 +1084,34 @@ async def get_finding_evidence_async(
async with await self.get_session_async() as session:
return [entry.get_evidence() for entry in (await session.scalars(statement)).all()]
+ async def get_finding_evidence_for_findings_async(
+ self, *, finding_ids: Sequence[uuid.UUID]
+ ) -> dict[uuid.UUID, list[FindingEvidence]]:
+ """
+ Read all evidence of several findings, most recently linked first.
+
+ Returns:
+ dict[uuid.UUID, list[FindingEvidence]]: Evidence for findings that have evidence.
+ """
+ if not finding_ids:
+ return {}
+ unique_ids = list(dict.fromkeys(finding_ids))
+ evidence: dict[uuid.UUID, list[FindingEvidence]] = {}
+ async with await self.get_session_async() as session:
+ for start in range(0, len(unique_ids), self._MAX_BIND_VARS):
+ statement = (
+ select(FindingEvidenceEntry)
+ .where(FindingEvidenceEntry.finding_id.in_(unique_ids[start : start + self._MAX_BIND_VARS]))
+ .order_by(
+ FindingEvidenceEntry.finding_id,
+ FindingEvidenceEntry.attached_at.desc(),
+ FindingEvidenceEntry.id.desc(),
+ )
+ )
+ for entry in await session.scalars(statement):
+ evidence.setdefault(entry.finding_id, []).append(entry.get_evidence())
+ return evidence
+
async def get_finding_evidence_by_source_async(
self, *, finding_id: uuid.UUID, attack_result_id: uuid.UUID
) -> FindingEvidence | None:
@@ -1130,6 +1182,14 @@ async def get_finding_severity_counts_async(self) -> dict[uuid.UUID, dict[Findin
counts.setdefault(operation_id, {})[FindingSeverity(severity)] = count
return counts
+ @staticmethod
+ def _finding_order() -> "tuple[ColumnElement[Any], ...]":
+ severity_order = case(
+ {severity.value: position for position, severity in enumerate(FindingSeverity)},
+ value=FindingEntry.severity,
+ )
+ return severity_order, FindingEntry.created_at.desc(), FindingEntry.id.desc()
+
def _uses_legacy_memory_override(self) -> bool:
return any(
not name.startswith("_")
diff --git a/pyrit/output/__init__.py b/pyrit/output/__init__.py
index f8935fd4d5..31a806e7fa 100644
--- a/pyrit/output/__init__.py
+++ b/pyrit/output/__init__.py
@@ -24,6 +24,7 @@
from pyrit.output.helpers import (
output_attack_async,
output_conversation_async,
+ output_finding_report_async,
output_scenario_async,
output_scenario_attacks_async,
output_scenario_conversations_async,
@@ -40,6 +41,7 @@
"OutputFormat": "pyrit.output.sink",
"output_attack_async": "pyrit.output.helpers",
"output_conversation_async": "pyrit.output.helpers",
+ "output_finding_report_async": "pyrit.output.helpers",
"output_scenario_async": "pyrit.output.helpers",
"output_scenario_attacks_async": "pyrit.output.helpers",
"output_scenario_conversations_async": "pyrit.output.helpers",
diff --git a/pyrit/output/finding_report/__init__.py b/pyrit/output/finding_report/__init__.py
new file mode 100644
index 0000000000..612ba9a100
--- /dev/null
+++ b/pyrit/output/finding_report/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Finding report printer classes."""
diff --git a/pyrit/output/finding_report/markdown.py b/pyrit/output/finding_report/markdown.py
new file mode 100644
index 0000000000..271393b0ac
--- /dev/null
+++ b/pyrit/output/finding_report/markdown.py
@@ -0,0 +1,123 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import re
+from urllib.parse import quote
+
+from pyrit.output.base import PrinterBase
+from pyrit.output.finding_report.report import (
+ DETAIL_SEPARATOR,
+ EVIDENCE_NOTE,
+ NO_EVIDENCE,
+ UNAVAILABLE_EVIDENCE,
+ FindingReport,
+ FindingReportColumn,
+ FindingReportEvidence,
+ FindingReportField,
+ plain_text,
+)
+from pyrit.output.sink import Sink
+
+_MARKDOWN_SPECIAL = re.compile(r"[\\`*_\[\]<>|~$]")
+# List item markers and heading underlines, which only take effect at the start of a line.
+_MARKDOWN_BLOCK_MARKER = re.compile(r"^([0-9]+(?=[.)])|(?=[-+=]))")
+_MARKDOWN_DESTINATION_UNSAFE = re.compile(r"[<>\s]")
+
+
+class MarkdownFindingReportPrinter(PrinterBase):
+ """
+ Markdown printer for a report of an Operation's findings.
+
+ Lays the report out like a two-column table read top to bottom, and keeps authored text as escaped prose.
+ """
+
+ def __init__(self, *, sink: Sink | None = None) -> None:
+ """
+ Initialize the markdown finding report printer.
+
+ Args:
+ sink (Sink | None): Output sink. Defaults to StdoutSink().
+ """
+ super().__init__(sink=sink)
+
+ async def render_async(self, report: FindingReport) -> str:
+ """
+ Render the report as a Markdown document.
+
+ Each finding lists its severity and Assessment fields, then its Finding fields, then its evidence as bullets.
+
+ Args:
+ report (FindingReport): The findings to render.
+
+ Returns:
+ str: The Markdown document.
+ """
+ blocks = [f"# Findings - {_markdown_inline(report.operation_name)}", EVIDENCE_NOTE]
+ for finding in report.findings:
+ blocks.append(f"## {_markdown_inline(finding.title)}")
+ assessment = [field for field in finding.fields if field.column == FindingReportColumn.ASSESSMENT]
+ account = [field for field in finding.fields if field.column == FindingReportColumn.FINDING]
+ blocks.extend(_markdown_field(field) for field in (finding.severity, *assessment, *account))
+ blocks.append(_markdown_evidence(finding.evidence))
+ return "\n\n".join(blocks) + "\n"
+
+
+def _markdown_evidence(evidence: tuple[FindingReportEvidence, ...]) -> str:
+ if not evidence:
+ return _markdown_field(NO_EVIDENCE)
+ lines = [f"**{NO_EVIDENCE.label}:**", ""]
+ for entry in evidence:
+ title = _markdown_inline(entry.title)
+ if entry.link_url is not None:
+ lines.append(f"- [{title}](<{_MARKDOWN_DESTINATION_UNSAFE.sub(_percent_encode, entry.link_url)}>)")
+ else:
+ lines.append(f"- **{title}**")
+ items = [DETAIL_SEPARATOR.join(_markdown_field(field) for field in entry.summary)] if entry.summary else []
+ items.extend(_markdown_field(field) for field in entry.details)
+ if entry.unavailable:
+ items.append(UNAVAILABLE_EVIDENCE)
+ lines.extend(_markdown_sub_item(item) for item in items)
+ return "\n".join(lines)
+
+
+def _markdown_sub_item(text: str) -> str:
+ # Continuation lines are indented to the sub-item's content so they stay inside it.
+ first, *rest = text.split("\n")
+ return "\n".join([f" - {first}", *(f" {line}" if line else "" for line in rest)])
+
+
+def _markdown_field(field: FindingReportField) -> str:
+ return f"**{_markdown_inline(field.label)}:** {_markdown_prose(field.value)}"
+
+
+def _markdown_inline(value: str) -> str:
+ """
+ Make text safe to place after other text on a Markdown line.
+
+ Returns:
+ str: The text on one line, with Markdown and HTML syntax characters escaped.
+ """
+ return _MARKDOWN_SPECIAL.sub(r"\\\g<0>", re.sub(r"\n+", " ", plain_text(value)))
+
+
+def _markdown_prose(value: str) -> str:
+ """
+ Keep a labelled value's lines and paragraphs without letting any line start Markdown or HTML syntax.
+
+ Returns:
+ str: Escaped lines, joined by hard line breaks, with one blank line between paragraphs.
+ """
+ paragraphs: list[list[str]] = [[]]
+ for line in plain_text(value).split("\n"):
+ text = line.lstrip(" \t")
+ if text:
+ paragraphs[-1].append(_MARKDOWN_SPECIAL.sub(r"\\\g<0>", text))
+ elif paragraphs[-1]:
+ paragraphs.append([])
+ first, *rest = "\n\n".join("\\\n".join(lines) for lines in paragraphs if lines).split("\n")
+ # The first line follows the label, so only later lines can start a list or heading.
+ return "\n".join([first, *(_MARKDOWN_BLOCK_MARKER.sub(r"\1\\", line) for line in rest)])
+
+
+def _percent_encode(match: re.Match[str]) -> str:
+ return quote(match.group(0), safe="")
diff --git a/pyrit/output/finding_report/report.py b/pyrit/output/finding_report/report.py
new file mode 100644
index 0000000000..56a793b95d
--- /dev/null
+++ b/pyrit/output/finding_report/report.py
@@ -0,0 +1,84 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""An Operation's findings laid out for a report, and the text every report format shares."""
+
+import re
+from dataclasses import dataclass
+from enum import Enum
+
+EVIDENCE_NOTE = "Evidence is listed as references with links to the PyRIT dashboard; transcripts are not included."
+UNAVAILABLE_EVIDENCE = "Evidence unavailable"
+DETAIL_SEPARATOR = " · "
+
+_LINE_BREAK = re.compile("\r\n|[\r\n\v\f\x1c-\x1e\x85\u2028\u2029]")
+_UNPRINTABLE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f\ud800-\udfff\ufffe\uffff]")
+
+
+class FindingReportColumn(str, Enum):
+ """The column of a two-column report that a field belongs in."""
+
+ FINDING = "finding"
+ ASSESSMENT = "assessment"
+
+
+@dataclass(frozen=True)
+class FindingReportField:
+ """A labelled value, such as a finding's severity or an evidence item's score."""
+
+ label: str
+ value: str
+ column: FindingReportColumn = FindingReportColumn.FINDING
+
+
+NO_EVIDENCE = FindingReportField(label="Evidence", value="none attached")
+
+
+@dataclass(frozen=True)
+class FindingReportEvidence:
+ """
+ An attack result attached to a finding, as a reference rather than a transcript.
+
+ The ``summary`` fields share one line, and each of the ``details`` has its own.
+ """
+
+ title: str
+ summary: tuple[FindingReportField, ...]
+ details: tuple[FindingReportField, ...]
+ link_url: str | None
+
+ @property
+ def unavailable(self) -> bool:
+ """Whether the evidence has no conversation to link to."""
+ return self.link_url is None
+
+
+@dataclass(frozen=True)
+class FindingReportEntry:
+ """A finding's title, severity, other populated fields in report order, and evidence."""
+
+ title: str
+ severity: FindingReportField
+ fields: tuple[FindingReportField, ...]
+ evidence: tuple[FindingReportEvidence, ...]
+
+
+@dataclass(frozen=True)
+class FindingReport:
+ """An Operation's selected findings, in report order."""
+
+ operation_name: str
+ findings: tuple[FindingReportEntry, ...]
+
+
+def plain_text(value: str) -> str:
+ """
+ Break lines at the same characters in every format, and mark each control character visibly rather than drop it.
+
+ Args:
+ value (str): Authored text.
+
+ Returns:
+ str: The text with ``\\n`` line breaks and U+FFFD in place of each control character.
+ """
+ return _UNPRINTABLE.sub("\ufffd", _LINE_BREAK.sub("\n", value))
diff --git a/pyrit/output/helpers.py b/pyrit/output/helpers.py
index 66f040c8b2..a6e582d5ac 100644
--- a/pyrit/output/helpers.py
+++ b/pyrit/output/helpers.py
@@ -19,6 +19,8 @@
from pyrit.output.conversation.json import JsonConversationMemoryPrinter, JsonConversationPrinter
from pyrit.output.conversation.pretty import PrettyConversationMemoryPrinter
from pyrit.output.conversation.source import MemoryConversationSource
+from pyrit.output.finding_report.markdown import MarkdownFindingReportPrinter
+from pyrit.output.finding_report.report import FindingReport
from pyrit.output.scenario_result.html import HtmlScenarioReportPrinter
from pyrit.output.scenario_result.json import (
ConversationEntry,
@@ -417,3 +419,27 @@ async def output_score_async(
printer = PrettyScorePrinter(sink=sink or get_default_sink(StdoutSink))
await printer.write_async(scores)
+
+
+async def output_finding_report_async(
+ report: FindingReport,
+ *,
+ format: OutputFormat = "markdown", # noqa: A002
+ sink: Sink | None = None,
+) -> None:
+ """
+ Print a report of an Operation's findings in the specified format.
+
+ Args:
+ report (FindingReport): The findings to print.
+ format (OutputFormat): Output format. Only "markdown" is supported, so it is the default.
+ sink (Sink | None): Output sink. Defaults to IPythonMarkdownSink in notebooks, StdoutSink otherwise.
+
+ Raises:
+ ValueError: If ``format`` is not "markdown".
+ """
+ if format != "markdown":
+ raise ValueError(f"Unsupported format for finding reports: {format!r}. Only 'markdown' is available.")
+
+ printer = MarkdownFindingReportPrinter(sink=sink or get_default_sink())
+ await printer.write_async(report)
diff --git a/tests/unit/backend/test_finding_export.py b/tests/unit/backend/test_finding_export.py
new file mode 100644
index 0000000000..e78411741c
--- /dev/null
+++ b/tests/unit/backend/test_finding_export.py
@@ -0,0 +1,995 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import json
+import re
+import xml.etree.ElementTree as ET
+import zipfile
+from collections.abc import Mapping, Sequence
+from datetime import datetime, timedelta, timezone
+from io import BytesIO
+from pathlib import Path
+from unittest.mock import patch
+from uuid import UUID, uuid4
+
+import pytest
+from docx import Document
+from docx.table import Table
+from fastapi.testclient import TestClient
+
+from pyrit.backend.main import app
+from pyrit.backend.models.attacks import ScoreView
+from pyrit.backend.models.operations import (
+ EvidenceAvailability,
+ FindingEvidenceItem,
+ FindingExportRequest,
+)
+from pyrit.backend.services import finding_export
+from pyrit.backend.services.finding_export import _render_docx
+from pyrit.backend.services.operation_service import OperationService
+from pyrit.memory import SQLiteMemory
+from pyrit.models import (
+ AttackOutcome,
+ AttackResult,
+ ComponentIdentifier,
+ ConversationReference,
+ ConversationType,
+ Finding,
+ FindingEvidence,
+ 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,
+ FindingReportColumn,
+ FindingReportEntry,
+ FindingReportEvidence,
+ FindingReportField,
+)
+
+pytestmark = pytest.mark.usefixtures("patch_central_database")
+
+FORMATS = ["docx", "markdown"]
+DASHBOARD_ORIGIN = "https://pyrit.example.test"
+# The dashboard's route builder is checked against the same cases.
+ROUTE_CASES = Path(__file__).resolve().parents[3] / "frontend" / "src" / "utils" / "attackConversationRoute.cases.json"
+TRANSCRIPT_MARKER = "SYNTHETIC_TRANSCRIPT_NOT_FOR_EXPORT"
+MISSING_ID = UUID(int=404)
+DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+MEDIA_TYPES = {"docx": DOCX_MEDIA_TYPE, "markdown": "text/markdown; charset=utf-8"}
+EXTENSIONS = {"docx": "docx", "markdown": "md"}
+UTC_STAMP = r"\d{4}-\d{2}-\d{2}-\d{4}-utc"
+FILENAME_OPERATION_ID = UUID("c1e5502b-91f6-4d65-b182-4b028c7a75f6")
+# 12:58 in UTC-4 is 16:58 UTC.
+FILENAME_TIME = datetime(2026, 10, 22, 12, 58, 33, tzinfo=timezone(timedelta(hours=-4)))
+HYPERLINK_RELATIONSHIP = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
+WORD_NAMESPACE = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
+RELATIONSHIP_ID = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"
+EVIDENCE_NOTE = "Evidence is listed as references with links to the PyRIT dashboard; transcripts are not included."
+# A saved conversation ID with path, query, and fragment characters, and its encodeURIComponent form.
+ODD_CONVERSATION_ID = "branch/α (1)?#%"
+ENCODED_CONVERSATION_ID = "branch%2F%CE%B1%20(1)%3F%23%25"
+HOSTILE_BODY = (
+ "Literal ```` run\n````\n# Forged finding\n
\n"
+ "\n[link](https://evil.example)\nÜnïcødé 😀 中文\n"
+)
+NOT_SCORED = FindingReportField(label="Score", value="Not scored")
+NOT_IN_AN_OPERATION = FindingReportField(label="From operation", value="Not in an operation")
+
+
+def _client() -> TestClient:
+ return TestClient(app, raise_server_exceptions=False)
+
+
+def _export(
+ *,
+ headers: dict[str, str],
+ operation_id: UUID,
+ finding_ids: Sequence[UUID | str],
+ export_format: str | None = "docx",
+ dashboard_origin: str = DASHBOARD_ORIGIN,
+):
+ body: dict[str, object] = {
+ "finding_ids": [str(finding_id) for finding_id in finding_ids],
+ "dashboard_origin": dashboard_origin,
+ }
+ if export_format is not None:
+ body["format"] = export_format
+ return _client().post(f"/api/operations/{operation_id}/findings/export", json=body, headers=headers)
+
+
+async def _report_async(*, operation_id: UUID, finding_ids: Sequence[UUID]) -> FindingReport:
+ request = FindingExportRequest.model_validate(
+ {"finding_ids": list(finding_ids), "dashboard_origin": DASHBOARD_ORIGIN}
+ )
+ return await finding_export._build_report_async(operation_id=operation_id, request=request)
+
+
+def _dashboard_link(*, evidence: FindingEvidence, conversation_id: str) -> str:
+ return (
+ f"{DASHBOARD_ORIGIN}/attacks/{evidence.attack_result_id}/conversations/{conversation_id}"
+ f"?findingEvidenceId={evidence.id}"
+ )
+
+
+def _paragraphs(content: bytes) -> list[str]:
+ """Every paragraph's text in document order, including table cells (each row's left cell, then its right)."""
+ texts: list[str] = []
+ for block in Document(BytesIO(content)).iter_inner_content():
+ if isinstance(block, Table):
+ texts.extend(paragraph.text for row in block.rows for cell in row.cells for paragraph in cell.paragraphs)
+ else:
+ texts.append(block.text)
+ return texts
+
+
+def _finding_cells(content: bytes) -> list[tuple[list[str], list[str]]]:
+ """Each finding's Finding and Assessment cell paragraphs, below the table's header row."""
+ table = Document(BytesIO(content)).tables[0]
+ rows = [[[paragraph.text for paragraph in cell.paragraphs] for cell in row.cells] for row in table.rows[1:]]
+ return [(finding, assessment) for finding, assessment in rows]
+
+
+def _finding_titles(*, content: bytes, export_format: str) -> list[str]:
+ """Finding titles in document order: Markdown headings, or the first line of each Word row."""
+ if export_format == "markdown":
+ return [line[3:] for line in content.decode("utf-8").split("\n") if line.startswith("## ")]
+ return [finding[0] for finding, _ in _finding_cells(content)]
+
+
+def _links(*, content: bytes, export_format: str) -> list[tuple[str, str]]:
+ """Read each hyperlink's text and target from the document's own structure."""
+ if export_format == "markdown":
+ pattern = r"^- \[(.+)\]\(<([^>\n]+)>\)$"
+ return [(match[1], match[2]) for match in re.finditer(pattern, content.decode("utf-8"), flags=re.MULTILINE)]
+ with zipfile.ZipFile(BytesIO(content)) as package:
+ relationships = ET.fromstring(package.read("word/_rels/document.xml.rels"))
+ document = ET.fromstring(package.read("word/document.xml"))
+ targets = {
+ relationship.attrib["Id"]: relationship.attrib["Target"]
+ for relationship in relationships
+ if relationship.get("Type") == HYPERLINK_RELATIONSHIP and relationship.get("TargetMode") == "External"
+ }
+ return [
+ ("".join(text.text or "" for text in link.iter(f"{WORD_NAMESPACE}t")), targets[link.attrib[RELATIONSHIP_ID]])
+ for link in document.iter(f"{WORD_NAMESPACE}hyperlink")
+ ]
+
+
+def _assert_attachment(*, status_code: int, headers: Mapping[str, str], operation_id: UUID, export_format: str) -> None:
+ assert status_code == 200
+ assert headers["content-type"] == MEDIA_TYPES[export_format]
+ assert re.fullmatch(
+ rf'attachment; filename="copyrit-findings-{operation_id}-{UTC_STAMP}\.{EXTENSIONS[export_format]}"',
+ headers["content-disposition"],
+ )
+
+
+async def _add_finding_async(*, memory: SQLiteMemory, operation: Operation, title: str, **fields: object) -> Finding:
+ fields.setdefault("severity", "moderate")
+ return await memory.add_finding_async(finding=Finding(operation_id=operation.id, title=title, **fields))
+
+
+async def _add_attack_async(*, memory: SQLiteMemory, conversation_id: str, **fields: object) -> AttackResult:
+ attack = AttackResult(conversation_id=conversation_id, **fields)
+ await memory.add_attack_results_to_memory_async(attack_results=[attack])
+ await memory.add_message_to_memory_async(
+ request=Message(
+ message_pieces=[
+ MessagePiece(role="user", original_value=TRANSCRIPT_MARKER, conversation_id=conversation_id)
+ ]
+ )
+ )
+ return attack
+
+
+async def _link_async(*, memory: SQLiteMemory, finding: Finding, attack_result_id: str | UUID) -> FindingEvidence:
+ return await memory.add_finding_evidence_async(
+ evidence=FindingEvidence(finding_id=finding.id, attack_result_id=UUID(str(attack_result_id)))
+ )
+
+
+async def _saved_state_async(*, memory: SQLiteMemory, operation: Operation) -> tuple[object, ...]:
+ findings = await memory.get_findings_async(operation_id=operation.id, limit=1000)
+ evidence = [await memory.get_finding_evidence_async(finding_id=f.id, limit=1000, offset=0) for f in findings]
+ attack_ids = sorted({str(item.attack_result_id) for page in evidence for item in page})
+ attacks = await memory.get_attack_results_async(attack_result_ids=attack_ids) if attack_ids else []
+ return findings, evidence, sorted(attacks, key=lambda attack: attack.attack_result_id)
+
+
+async def _save_named_findings_async(
+ *, memory: SQLiteMemory, filler_count: int
+) -> tuple[Operation, dict[str, Finding]]:
+ operation = await memory.add_operation_async(Operation(name="Synthetic evaluation"))
+ named = {
+ "Critical finding": await _add_finding_async(
+ memory=memory,
+ operation=operation,
+ title="Critical finding",
+ severity="critical",
+ harm_type=HarmCategory.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"),
+ }
+ for index in range(filler_count):
+ await _add_finding_async(memory=memory, operation=operation, title=f"Filler {index:02d}")
+ named["Low finding"] = await _add_finding_async(
+ memory=memory, operation=operation, title="Low finding", severity="low"
+ )
+ attack = await _add_attack_async(
+ memory=memory, conversation_id="main", objective="Restricted-marker test", operation_id=operation.id
+ )
+ await _link_async(memory=memory, finding=named["Critical finding"], attack_result_id=attack.attack_result_id)
+ await _link_async(memory=memory, finding=named["Excluded finding"], attack_result_id=attack.attack_result_id)
+ return operation, named
+
+
+async def _critical_evidence_async(*, memory: SQLiteMemory, named: dict[str, Finding]) -> FindingEvidence:
+ (evidence,) = await memory.get_finding_evidence_async(finding_id=named["Critical finding"].id, limit=10, offset=0)
+ return evidence
+
+
+def _single_finding_report(
+ *, fields: tuple[FindingReportField, ...], evidence: tuple[FindingReportEvidence, ...]
+) -> FindingReport:
+ finding = FindingReportEntry(
+ title="Synthetic finding",
+ severity=FindingReportField(label="Severity", value="Low"),
+ fields=fields,
+ evidence=evidence,
+ )
+ return FindingReport(operation_name="Synthetic evaluation", findings=(finding,))
+
+
+@pytest.fixture
+async def named_findings_async(sqlite_instance: SQLiteMemory) -> tuple[Operation, dict[str, Finding]]:
+ return await _save_named_findings_async(memory=sqlite_instance, filler_count=0)
+
+
+@pytest.fixture
+async def paged_findings_async(sqlite_instance: SQLiteMemory) -> tuple[Operation, dict[str, Finding]]:
+ """Save 101 findings, so the lowest severity one is past the dashboard's first 100-row page."""
+ return await _save_named_findings_async(memory=sqlite_instance, filler_count=97)
+
+
+@pytest.mark.parametrize("export_format", FORMATS)
+async def test_export_downloads_selected_findings_in_dashboard_order(
+ sqlite_instance: SQLiteMemory,
+ compatibility_headers: dict[str, str],
+ named_findings_async: tuple[Operation, dict[str, Finding]],
+ export_format: str,
+) -> None:
+ operation, named = named_findings_async
+ evidence = await _critical_evidence_async(memory=sqlite_instance, named=named)
+ before = await _saved_state_async(memory=sqlite_instance, operation=operation)
+ selected = [named[title].id for title in ["Low finding", "Included finding", "Critical finding"]]
+
+ response = _export(
+ headers=compatibility_headers, operation_id=operation.id, finding_ids=selected, export_format=export_format
+ )
+
+ _assert_attachment(
+ status_code=response.status_code,
+ headers=response.headers,
+ operation_id=operation.id,
+ export_format=export_format,
+ )
+ assert _finding_titles(content=response.content, export_format=export_format) == [
+ "Critical finding",
+ "Included finding",
+ "Low finding",
+ ]
+ assert _links(content=response.content, export_format=export_format) == [
+ ("Restricted-marker test", _dashboard_link(evidence=evidence, conversation_id="main"))
+ ]
+ assert await _saved_state_async(memory=sqlite_instance, operation=operation) == before
+
+
+async def test_export_defaults_to_docx(
+ compatibility_headers: dict[str, str], named_findings_async: tuple[Operation, dict[str, Finding]]
+) -> None:
+ operation, named = named_findings_async
+
+ response = _export(
+ headers=compatibility_headers,
+ operation_id=operation.id,
+ finding_ids=[named["Included finding"].id],
+ export_format=None,
+ )
+
+ _assert_attachment(
+ status_code=response.status_code,
+ headers=response.headers,
+ operation_id=operation.id,
+ export_format="docx",
+ )
+ assert _paragraphs(response.content)[:5] == [
+ "Findings - Synthetic evaluation",
+ EVIDENCE_NOTE,
+ "Finding",
+ "Assessment",
+ "Included finding",
+ ]
+
+
+def test_export_filename_uses_the_operation_id_and_utc() -> None:
+ filename = finding_export._export_filename(operation_id=FILENAME_OPERATION_ID, extension="docx", now=FILENAME_TIME)
+
+ assert filename == "copyrit-findings-c1e5502b-91f6-4d65-b182-4b028c7a75f6-2026-10-22-1658-utc.docx"
+
+
+async def test_export_header_leaves_the_operation_name_out(
+ sqlite_instance: SQLiteMemory, compatibility_headers: dict[str, str]
+) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Évaluation 中文"))
+ finding = await _add_finding_async(memory=sqlite_instance, operation=operation, title="Unicode finding")
+
+ response = _export(
+ headers=compatibility_headers, operation_id=operation.id, finding_ids=[finding.id], export_format="markdown"
+ )
+
+ _assert_attachment(
+ status_code=response.status_code,
+ headers=response.headers,
+ operation_id=operation.id,
+ export_format="markdown",
+ )
+
+
+@pytest.mark.parametrize(
+ ("case", "status_code"),
+ [
+ ("empty", 422),
+ ("duplicate", 422),
+ ("malformed", 422),
+ ("missing_field", 422),
+ ("unknown_field", 422),
+ ("unknown_format", 422),
+ ("foreign", 404),
+ ("missing_finding", 404),
+ ("missing_operation", 404),
+ ],
+)
+async def test_export_rejects_invalid_selection(
+ sqlite_instance: SQLiteMemory,
+ compatibility_headers: dict[str, str],
+ named_findings_async: tuple[Operation, dict[str, Finding]],
+ case: str,
+ status_code: int,
+) -> None:
+ operation, named = named_findings_async
+ other = await sqlite_instance.add_operation_async(Operation(name="Other operation"))
+ foreign = await _add_finding_async(memory=sqlite_instance, operation=other, title="Foreign finding")
+ before = await _saved_state_async(memory=sqlite_instance, operation=operation)
+ finding_id = str(named["Included finding"].id)
+ body: dict[str, object] = {"finding_ids": [finding_id], "dashboard_origin": DASHBOARD_ORIGIN}
+ path_operation_id: UUID = operation.id
+ if case == "empty":
+ body["finding_ids"] = []
+ elif case == "duplicate":
+ body["finding_ids"] = [finding_id, finding_id.upper()]
+ elif case == "malformed":
+ body["finding_ids"] = [finding_id, "not-a-uuid"]
+ elif case == "missing_field":
+ del body["finding_ids"]
+ elif case == "unknown_field":
+ body["all"] = True
+ elif case == "unknown_format":
+ body["format"] = "pdf"
+ elif case == "foreign":
+ body["finding_ids"] = [finding_id, str(foreign.id)]
+ elif case == "missing_finding":
+ body["finding_ids"] = [finding_id, str(MISSING_ID)]
+ else:
+ path_operation_id = MISSING_ID
+
+ response = _client().post(
+ f"/api/operations/{path_operation_id}/findings/export", json=body, headers=compatibility_headers
+ )
+
+ assert response.status_code == status_code
+ assert "content-disposition" not in response.headers
+ assert await _saved_state_async(memory=sqlite_instance, operation=operation) == before
+
+
+@pytest.mark.parametrize(
+ "dashboard_origin",
+ [
+ None,
+ "",
+ "/team/",
+ "pyrit.example.test",
+ "ftp://pyrit.example.test/",
+ "javascript:alert(1)",
+ "https://user@pyrit.example.test/",
+ "https://" + "user:pass" + "@pyrit.example.test/",
+ "https://" + ":pass" + "@pyrit.example.test/",
+ "https://pyrit.example.test/team/",
+ "https://pyrit.example.test/?next=/",
+ "https://pyrit.example.test/?",
+ "https://pyrit.example.test/#top",
+ "https://pyrit.example.test/#",
+ ],
+)
+def test_export_rejects_invalid_dashboard_origin(
+ compatibility_headers: dict[str, str], dashboard_origin: str | None
+) -> None:
+ body: dict[str, object] = {"finding_ids": [str(uuid4())]}
+ if dashboard_origin is not None:
+ body["dashboard_origin"] = dashboard_origin
+
+ response = _client().post(f"/api/operations/{uuid4()}/findings/export", json=body, headers=compatibility_headers)
+
+ assert response.status_code == 422
+ assert "content-disposition" not in response.headers
+
+
+@pytest.mark.parametrize("dashboard_origin", [DASHBOARD_ORIGIN, f"{DASHBOARD_ORIGIN}/"])
+async def test_export_accepts_an_origin_with_or_without_trailing_slash(
+ sqlite_instance: SQLiteMemory,
+ compatibility_headers: dict[str, str],
+ named_findings_async: tuple[Operation, dict[str, Finding]],
+ dashboard_origin: str,
+) -> None:
+ operation, named = named_findings_async
+ evidence = await _critical_evidence_async(memory=sqlite_instance, named=named)
+
+ response = _export(
+ headers=compatibility_headers,
+ operation_id=operation.id,
+ finding_ids=[named["Critical finding"].id],
+ export_format="markdown",
+ dashboard_origin=dashboard_origin,
+ )
+
+ assert response.status_code == 200
+ assert _links(content=response.content, export_format="markdown") == [
+ ("Restricted-marker test", _dashboard_link(evidence=evidence, conversation_id="main"))
+ ]
+
+
+@pytest.mark.parametrize(
+ ("export_format", "failing"),
+ [
+ ("docx", "get_findings_by_ids_async"),
+ ("markdown", "get_finding_evidence_for_findings_async"),
+ ("markdown", "get_attack_results_async"),
+ ("docx", "_render_docx"),
+ ("markdown", "render_async"),
+ ],
+)
+async def test_export_fails_instead_of_returning_partial_content(
+ sqlite_instance: SQLiteMemory,
+ compatibility_headers: dict[str, str],
+ named_findings_async: tuple[Operation, dict[str, Finding]],
+ export_format: str,
+ failing: str,
+) -> None:
+ operation, named = named_findings_async
+ renderers = {"_render_docx": finding_export, "render_async": MarkdownFindingReportPrinter}
+ owner = renderers.get(failing, sqlite_instance)
+
+ with patch.object(owner, failing, side_effect=RuntimeError("synthetic failure")):
+ response = _export(
+ headers=compatibility_headers,
+ operation_id=operation.id,
+ finding_ids=[named["Critical finding"].id],
+ export_format=export_format,
+ )
+
+ assert response.status_code == 500
+ assert "content-disposition" not in response.headers
+ assert response.json()["type"] == "/errors/internal-error"
+
+
+async def test_report_selects_findings_beyond_the_first_dashboard_page_in_dashboard_order(
+ sqlite_instance: SQLiteMemory, paged_findings_async: tuple[Operation, dict[str, Finding]]
+) -> None:
+ operation, named = paged_findings_async
+ evidence = await _critical_evidence_async(memory=sqlite_instance, named=named)
+ # The request order does not override the dashboard's severity-first order.
+ selected = [named[title].id for title in ["Low finding", "Included finding", "Critical finding"]]
+
+ report = await _report_async(operation_id=operation.id, finding_ids=selected)
+
+ # The attack belongs to the report's own Operation, so its evidence does not name it again.
+ assert report == FindingReport(
+ operation_name="Synthetic evaluation",
+ findings=(
+ FindingReportEntry(
+ title="Critical finding",
+ severity=FindingReportField(label="Severity", value="Critical"),
+ fields=(
+ FindingReportField(label="Harm-type", value="Profanity", column=FindingReportColumn.ASSESSMENT),
+ FindingReportField(label="Description", value="Synthetic description\nsecond line"),
+ ),
+ evidence=(
+ FindingReportEvidence(
+ title="Restricted-marker test",
+ summary=(FindingReportField(label="Outcome", value="Undetermined"), NOT_SCORED),
+ details=(),
+ link_url=_dashboard_link(evidence=evidence, conversation_id="main"),
+ ),
+ ),
+ ),
+ FindingReportEntry(
+ title="Included finding",
+ severity=FindingReportField(label="Severity", value="Moderate"),
+ fields=(),
+ evidence=(),
+ ),
+ FindingReportEntry(
+ title="Low finding", severity=FindingReportField(label="Severity", value="Low"), fields=(), evidence=()
+ ),
+ ),
+ )
+
+
+async def test_report_matches_the_dashboard_listing_order(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Listing order"))
+ findings = [
+ await _add_finding_async(
+ memory=sqlite_instance,
+ operation=operation,
+ title=f"{severity.capitalize()} finding {index}",
+ severity=severity,
+ )
+ for index, severity in enumerate(["low", "critical", "moderate", "critical"])
+ ]
+ dashboard = await sqlite_instance.get_findings_async(operation_id=operation.id, limit=10)
+
+ report = await _report_async(operation_id=operation.id, finding_ids=[finding.id for finding in findings])
+
+ assert [finding.title for finding in report.findings] == [finding.title for finding in dashboard]
+
+
+async def test_report_reads_findings_and_evidence_in_one_service_call(
+ named_findings_async: tuple[Operation, dict[str, Finding]],
+) -> None:
+ operation, named = named_findings_async
+ selected = [named["Low finding"].id, named["Critical finding"].id]
+ batched_read = OperationService.get_findings_with_evidence_async
+
+ with (
+ patch.object(
+ OperationService, "get_findings_with_evidence_async", autospec=True, side_effect=batched_read
+ ) as batched,
+ patch.object(
+ OperationService, "list_findings_async", autospec=True, side_effect=AssertionError("paged findings read")
+ ) as findings_page,
+ patch.object(
+ OperationService,
+ "list_finding_evidence_async",
+ autospec=True,
+ side_effect=AssertionError("paged evidence read"),
+ ) as evidence_page,
+ ):
+ report = await _report_async(operation_id=operation.id, finding_ids=selected)
+
+ batched.assert_awaited_once()
+ assert batched.await_args is not None and batched.await_args.kwargs["finding_ids"] == selected
+ findings_page.assert_not_called()
+ evidence_page.assert_not_called()
+ assert [finding.title for finding in report.findings] == ["Critical finding", "Low finding"]
+
+
+async def test_report_includes_every_evidence_link_of_a_finding(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Evidence links"))
+ finding = await _add_finding_async(memory=sqlite_instance, operation=operation, title="Many references")
+ attacks = [
+ AttackResult(conversation_id=f"conversation-{index:03d}", objective=f"Objective {index:03d}")
+ for index in range(101)
+ ]
+ await sqlite_instance.add_attack_results_to_memory_async(attack_results=attacks)
+ await sqlite_instance.add_message_pieces_to_memory_async(
+ message_pieces=[
+ MessagePiece(role="user", original_value=TRANSCRIPT_MARKER, conversation_id=attack.conversation_id)
+ for attack in attacks
+ ]
+ )
+ evidence = [
+ await _link_async(memory=sqlite_instance, finding=finding, attack_result_id=a.attack_result_id) for a in attacks
+ ]
+
+ report = await _report_async(operation_id=operation.id, finding_ids=[finding.id])
+
+ (exported,) = report.findings
+ assert len(exported.evidence) == 101
+ assert set(exported.evidence) == {
+ FindingReportEvidence(
+ title=f"Objective {index:03d}",
+ summary=(FindingReportField(label="Outcome", value="Undetermined"), NOT_SCORED),
+ details=(NOT_IN_AN_OPERATION,),
+ link_url=_dashboard_link(evidence=item, conversation_id=f"conversation-{index:03d}"),
+ )
+ for index, item in enumerate(evidence)
+ }
+
+
+async def test_report_uses_effective_score_and_conversation(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Scores"))
+ source = await sqlite_instance.add_operation_async(Operation(name="Source operation"))
+ finding = await _add_finding_async(memory=sqlite_instance, operation=operation, title="Scored finding")
+ pieces = {
+ conversation_id: MessagePiece(
+ role="assistant", original_value=TRANSCRIPT_MARKER, conversation_id=conversation_id
+ )
+ for conversation_id in ["main", ODD_CONVERSATION_ID]
+ }
+ await sqlite_instance.add_message_pieces_to_memory_async(message_pieces=list(pieces.values()))
+
+ def make_score(*, conversation_id: str, scorer: str, value: str) -> Score:
+ return Score(
+ score_value=value,
+ score_type="true_false",
+ score_rationale=f"{scorer} rationale",
+ scorer_class_identifier=ComponentIdentifier(class_name=scorer, class_module="pyrit.score"),
+ message_piece_id=pieces[conversation_id].id,
+ )
+
+ automated = make_score(conversation_id="main", scorer="SelfAskTrueFalseScorer", value="true")
+ human = make_score(conversation_id=ODD_CONVERSATION_ID, scorer="ManualScorer", value="false")
+ await sqlite_instance.add_scores_to_memory_async(scores=[automated, human])
+ attack = AttackResult(
+ conversation_id="main",
+ objective="Scored objective",
+ operation_id=source.id,
+ automated_score=automated,
+ human_score=human,
+ outcome=AttackOutcome.FAILURE,
+ related_conversations={
+ ConversationReference(conversation_id=ODD_CONVERSATION_ID, conversation_type=ConversationType.PRUNED)
+ },
+ )
+ await sqlite_instance.add_attack_results_to_memory_async(attack_results=[attack])
+ evidence = await _link_async(memory=sqlite_instance, finding=finding, attack_result_id=attack.attack_result_id)
+
+ report = await _report_async(operation_id=operation.id, finding_ids=[finding.id])
+
+ # The human score is the attack's current score, so the branch it was given in is the one to open.
+ assert report.findings == (
+ FindingReportEntry(
+ title="Scored finding",
+ severity=FindingReportField(label="Severity", value="Moderate"),
+ fields=(),
+ evidence=(
+ FindingReportEvidence(
+ title="Scored objective",
+ summary=(
+ FindingReportField(label="Outcome", value="Failure"),
+ FindingReportField(label="Score", value="False (human review)"),
+ ),
+ details=(
+ FindingReportField(label="Rationale", value="ManualScorer rationale"),
+ FindingReportField(label="From operation", value="Source operation"),
+ ),
+ link_url=_dashboard_link(evidence=evidence, conversation_id=ENCODED_CONVERSATION_ID),
+ ),
+ ),
+ ),
+ )
+
+
+async def test_report_retains_undetermined_score(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Undetermined"))
+ finding = await _add_finding_async(memory=sqlite_instance, operation=operation, title="Undecided finding")
+ attack = await _add_attack_async(memory=sqlite_instance, conversation_id="main", objective="Undecided objective")
+ piece = (await sqlite_instance.get_message_pieces_async(conversation_id="main"))[0]
+ score = Score(
+ score_value=None,
+ status=ScoreStatus.UNDETERMINED,
+ score_type="true_false",
+ score_rationale="The scorer could not decide.",
+ scorer_class_identifier=ComponentIdentifier(class_name="SelfAskTrueFalseScorer", class_module="pyrit.score"),
+ message_piece_id=piece.id,
+ )
+ await sqlite_instance.add_scores_to_memory_async(scores=[score])
+ await sqlite_instance.update_attack_result_by_id_async(
+ attack_result_id=attack.attack_result_id, update_fields={"automated_score_id": str(score.id)}
+ )
+ evidence = await _link_async(memory=sqlite_instance, finding=finding, attack_result_id=attack.attack_result_id)
+
+ report = await _report_async(operation_id=operation.id, finding_ids=[finding.id])
+
+ (exported,) = report.findings
+ assert exported.evidence == (
+ FindingReportEvidence(
+ title="Undecided objective",
+ summary=(
+ FindingReportField(label="Outcome", value="Undetermined"),
+ FindingReportField(label="Score", value="Undetermined (SelfAskTrueFalseScorer)"),
+ ),
+ details=(FindingReportField(label="Rationale", value="The scorer could not decide."), NOT_IN_AN_OPERATION),
+ link_url=_dashboard_link(evidence=evidence, conversation_id="main"),
+ ),
+ )
+
+
+async def test_report_retains_unavailable_evidence(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Unavailable"))
+ finding = await _add_finding_async(memory=sqlite_instance, operation=operation, title="Unavailable finding")
+ missing_attack_id = uuid4()
+ await _link_async(memory=sqlite_instance, finding=finding, attack_result_id=missing_attack_id)
+ messageless = AttackResult(conversation_id="nothing-saved", objective="Messageless objective")
+ await sqlite_instance.add_attack_results_to_memory_async(attack_results=[messageless])
+ await _link_async(memory=sqlite_instance, finding=finding, attack_result_id=messageless.attack_result_id)
+
+ report = await _report_async(operation_id=operation.id, finding_ids=[finding.id])
+
+ (exported,) = report.findings
+ assert len(exported.evidence) == 2
+ # Without an objective, the saved attack ID leads the entry, as on the dashboard.
+ assert set(exported.evidence) == {
+ FindingReportEvidence(title=str(missing_attack_id), summary=(), details=(), link_url=None),
+ FindingReportEvidence(
+ title="Messageless objective",
+ summary=(FindingReportField(label="Outcome", value="Undetermined"), NOT_SCORED),
+ details=(NOT_IN_AN_OPERATION,),
+ link_url=None,
+ ),
+ }
+
+
+async def test_report_projects_optional_and_custom_fields(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Classifications"))
+ custom = await _add_finding_async(
+ memory=sqlite_instance,
+ operation=operation,
+ title="Custom finding",
+ severity="other",
+ severity_other="Team severity",
+ harm_type=HarmCategory.OTHER,
+ harm_type_other="Team harm",
+ )
+ plain = await _add_finding_async(
+ memory=sqlite_instance,
+ operation=operation,
+ title="Plain finding",
+ severity="informational",
+ 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)
+
+ report = await _report_async(operation_id=operation.id, finding_ids=[custom.id, plain.id])
+
+ # Other sorts after every named severity, as on the dashboard.
+ assert report.findings == (
+ FindingReportEntry(
+ title="Plain finding",
+ severity=FindingReportField(label="Severity", value="Informational"),
+ fields=(FindingReportField(label="Description", value="Only text"),),
+ evidence=(),
+ ),
+ FindingReportEntry(
+ title="Custom finding",
+ severity=FindingReportField(label="Severity", value="Team severity"),
+ fields=(FindingReportField(label="Harm-type", value="Team harm", column=FindingReportColumn.ASSESSMENT),),
+ evidence=(
+ FindingReportEvidence(
+ title="No objective",
+ summary=(FindingReportField(label="Outcome", value="Undetermined"), NOT_SCORED),
+ details=(NOT_IN_AN_OPERATION,),
+ link_url=_dashboard_link(evidence=evidence, conversation_id="main"),
+ ),
+ ),
+ ),
+ )
+
+
+@pytest.mark.parametrize(
+ ("score_value", "scorer", "expected"),
+ [
+ ("true", "SubStringScorer", "True (SubStringScorer)"),
+ ("false", "ManualScorer", "False (human review)"),
+ ("0.75", "SelfAskLikertScorer", "0.75 (SelfAskLikertScorer)"),
+ ],
+)
+def test_score_text_capitalizes_booleans_and_names_human_review(score_value: str, scorer: str, expected: str) -> None:
+ score = Score(
+ score_value=score_value,
+ score_type="float_scale" if score_value == "0.75" else "true_false",
+ score_rationale="Synthetic rationale",
+ scorer_class_identifier=ComponentIdentifier(class_name=scorer, class_module="pyrit.score"),
+ message_piece_id=uuid4(),
+ )
+
+ assert finding_export._score_text(ScoreView.from_domain(score)) == expected
+
+
+LINK = "https://pyrit.example.test/attacks/a/conversations/b?findingEvidenceId=c"
+TABLE_REPORT = FindingReport(
+ operation_name="Synthetic evaluation",
+ findings=(
+ FindingReportEntry(
+ title="First finding",
+ severity=FindingReportField(label="Severity", value="Critical"),
+ fields=(
+ FindingReportField(label="Harm-type", value="Synthetic harm", column=FindingReportColumn.ASSESSMENT),
+ FindingReportField(label="Description", value="Synthetic description"),
+ ),
+ evidence=(
+ FindingReportEvidence(
+ title="Linked objective",
+ summary=(
+ FindingReportField(label="Outcome", value="Success"),
+ FindingReportField(label="Score", value="True (SubStringScorer)"),
+ ),
+ details=(FindingReportField(label="Rationale", value="Synthetic rationale"),),
+ link_url=LINK,
+ ),
+ FindingReportEvidence(
+ title="Unscored objective",
+ summary=(FindingReportField(label="Outcome", value="Failure"), NOT_SCORED),
+ details=(FindingReportField(label="From operation", value="Synthetic source"),),
+ link_url=LINK,
+ ),
+ FindingReportEvidence(title="attack-404", summary=(), details=(), link_url=None),
+ ),
+ ),
+ FindingReportEntry(
+ title="Second finding", severity=FindingReportField(label="Severity", value="Low"), fields=(), evidence=()
+ ),
+ ),
+)
+
+
+def test_docx_opens_as_a_current_word_document() -> None:
+ content = _render_docx(report=_single_finding_report(fields=(), evidence=()), now=FILENAME_TIME)
+
+ document = Document(BytesIO(content))
+ with zipfile.ZipFile(BytesIO(content)) as package:
+ names = package.namelist()
+ settings = package.read("word/settings.xml").decode("utf-8")
+ # Word opens mode 14 files in Compatibility Mode.
+ assert (
+ re.search(r'w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"', settings)
+ is not None
+ )
+ properties = document.core_properties
+ assert properties.title == "Findings - Synthetic evaluation"
+ assert (properties.author, properties.comments, properties.last_modified_by) == ("", "", "")
+ # The moment the server's filename names, stored in UTC.
+ assert properties.created is not None and f"{properties.created:%Y-%m-%d %H:%M:%S}" == "2026-10-22 16:58:33"
+ assert properties.modified == properties.created
+ assert not [name for name in names if name in {"docProps/app.xml", "docProps/thumbnail.jpeg"}]
+
+
+def test_docx_lists_every_finding_in_one_finding_and_assessment_table() -> None:
+ content = _render_docx(report=TABLE_REPORT, now=FILENAME_TIME)
+
+ document = Document(BytesIO(content))
+ assert [paragraph.text for paragraph in document.paragraphs] == ["Findings - Synthetic evaluation", EVIDENCE_NOTE]
+ assert len(document.tables) == 1
+ assert [cell.text for cell in document.tables[0].rows[0].cells] == ["Finding", "Assessment"]
+ assert _finding_cells(content) == [
+ (
+ [
+ "First finding",
+ "Description: Synthetic description",
+ "Evidence:",
+ "Linked objective",
+ "Outcome: Success · Score: True (SubStringScorer)",
+ "Rationale: Synthetic rationale",
+ "Unscored objective",
+ "Outcome: Failure · Score: Not scored",
+ # Each entry keeps the same shape: the summary line, then one line per detail.
+ "From operation: Synthetic source",
+ "attack-404",
+ "Evidence unavailable",
+ ],
+ ["Severity: Critical", "Harm-type: Synthetic harm"],
+ ),
+ (["Second finding", "Evidence: none attached"], ["Severity: Low"]),
+ ]
+ assert _links(content=content, export_format="docx") == [("Linked objective", LINK), ("Unscored objective", LINK)]
+
+
+def test_docx_keeps_arbitrary_projected_fields() -> None:
+ evidence = FindingReportEvidence(
+ title="Source text",
+ summary=(FindingReportField(label="Synthetic evidence field", value="evidence value"),),
+ details=(FindingReportField(label="Synthetic evidence notes", value="note one\nnote two"),),
+ link_url=None,
+ )
+ report = FindingReport(
+ operation_name="Synthetic evaluation",
+ findings=(
+ FindingReportEntry(
+ title="Configured finding",
+ severity=FindingReportField(label="Synthetic severity", value="Level 3"),
+ fields=(
+ FindingReportField(label="Synthetic custom field", value="line one\nline two"),
+ FindingReportField(label="Synthetic narrative", value="part one\npart two"),
+ FindingReportField(label="Synthetic class", value="Class B", column=FindingReportColumn.ASSESSMENT),
+ ),
+ evidence=(evidence,),
+ ),
+ ),
+ )
+
+ content = _render_docx(report=report, now=FILENAME_TIME)
+
+ # The projection, not the renderer, decides each field's column; severity always leads the Assessment.
+ assert _finding_cells(content) == [
+ (
+ [
+ "Configured finding",
+ "Synthetic custom field: line one\nline two",
+ "Synthetic narrative: part one\npart two",
+ "Evidence:",
+ "Source text",
+ "Synthetic evidence field: evidence value",
+ "Synthetic evidence notes: note one\nnote two",
+ "Evidence unavailable",
+ ],
+ ["Synthetic severity: Level 3", "Synthetic class: Class B"],
+ )
+ ]
+
+
+def test_conversation_links_match_the_dashboard_route_contract() -> None:
+ origin = FindingExportRequest.model_validate(
+ {"finding_ids": [uuid4()], "dashboard_origin": DASHBOARD_ORIGIN}
+ ).dashboard_origin
+ cases = json.loads(ROUTE_CASES.read_text(encoding="utf-8"))
+
+ links = [
+ finding_export._conversation_link(
+ dashboard_origin=origin,
+ item=FindingEvidenceItem(
+ item=FindingEvidence(
+ id=UUID(case["findingEvidenceId"]),
+ finding_id=uuid4(),
+ attack_result_id=UUID(case["attackResultId"]),
+ ),
+ availability=EvidenceAvailability.AVAILABLE,
+ scenario_result_id=UUID(case["scenarioResultId"]) if case["scenarioResultId"] else None,
+ objective="Linked objective",
+ outcome=AttackOutcome.SUCCESS,
+ conversation_id=case["conversationId"],
+ ),
+ conversation_id=case["conversationId"],
+ )
+ for case in cases
+ ]
+
+ assert links == [f"{DASHBOARD_ORIGIN}{case['path']}" for case in cases]
+
+
+def test_docx_keeps_literal_text_and_marks_characters_word_cannot_store() -> None:
+ report = _single_finding_report(
+ fields=(
+ FindingReportField(label="Description", value=HOSTILE_BODY),
+ FindingReportField(label="Synthetic control text", value="bell\x07 escape\x1b\r\nnext line"),
+ ),
+ evidence=(),
+ )
+
+ paragraphs = _paragraphs(_render_docx(report=report, now=FILENAME_TIME))
+
+ assert f"Description: {HOSTILE_BODY}" in paragraphs
+ assert "Synthetic control text: bell\ufffd escape\ufffd\nnext line" in paragraphs
+ assert "Evidence: none attached" in paragraphs
+
+
+def test_docx_breaks_lines_and_marks_control_characters() -> None:
+ report = _single_finding_report(
+ fields=(
+ FindingReportField(label="Breaks", value="crlf\r\nvt\vff\ffs\x1cnel\x85ls\u2028ps\u2029end"),
+ FindingReportField(label="Controls", value="c1\x80 c1\x9f del\x7f nul\x00 tab\tend"),
+ ),
+ evidence=(),
+ )
+
+ paragraphs = _paragraphs(_render_docx(report=report, now=FILENAME_TIME))
+
+ assert "Breaks: crlf\nvt\nff\nfs\nnel\nls\nps\nend" in paragraphs
+ assert "Controls: c1\ufffd c1\ufffd del\ufffd nul\ufffd tab\tend" in paragraphs
diff --git a/tests/unit/backend/test_operations.py b/tests/unit/backend/test_operations.py
index c633211fde..2a907c9431 100644
--- a/tests/unit/backend/test_operations.py
+++ b/tests/unit/backend/test_operations.py
@@ -2,6 +2,7 @@
# Licensed under the MIT license.
import asyncio
+from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
@@ -12,8 +13,8 @@
from sqlalchemy.exc import IntegrityError
from pyrit.backend.main import app
-from pyrit.backend.models.operations import FindingEvidenceCreateRequest
-from pyrit.backend.services.operation_service import FindingNotFoundError, OperationService
+from pyrit.backend.models.operations import EvidenceAvailability, FindingEvidenceCreateRequest
+from pyrit.backend.services.operation_service import FindingNotFoundError, OperationNotFoundError, OperationService
from pyrit.memory import CentralMemory, SQLiteMemory
from pyrit.memory.memory_models import AttackResultEntry, Base, PromptMemoryEntry
from pyrit.models import (
@@ -748,3 +749,173 @@ async def test_evidence_page_batches_sources_and_counts_async(
await service.list_findings_async(operation_id=operation.id, limit=20, offset=0)
counts.assert_called_once()
assert counts.call_args.kwargs["finding_ids"] == [finding.id]
+
+
+@pytest.fixture
+async def selected_findings_async(sqlite_instance: SQLiteMemory) -> tuple[Operation, list[Finding]]:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Selected"))
+ findings = []
+ for severity in ["low", "critical", "moderate"]:
+ finding = await sqlite_instance.add_finding_async(
+ finding=Finding(operation_id=operation.id, title=severity.title(), severity=severity)
+ )
+ for index in range(2):
+ conversation_id = f"{severity}-{index}"
+ attack = AttackResult(conversation_id=conversation_id, objective=conversation_id, operation_id=operation.id)
+ await sqlite_instance.add_attack_results_to_memory_async(attack_results=[attack])
+ await sqlite_instance.add_message_to_memory_async(
+ request=Message(
+ message_pieces=[
+ MessagePiece(role="user", original_value=conversation_id, conversation_id=conversation_id)
+ ]
+ )
+ )
+ await sqlite_instance.add_finding_evidence_async(
+ evidence=FindingEvidence(finding_id=finding.id, attack_result_id=UUID(attack.attack_result_id))
+ )
+ findings.append(finding)
+ return operation, findings
+
+
+async def test_findings_with_evidence_read_in_constant_queries_async(
+ sqlite_instance: SQLiteMemory,
+ selected_findings_async: tuple[Operation, list[Finding]],
+) -> None:
+ operation, findings = selected_findings_async
+ service = OperationService()
+ with (
+ patch.object(
+ sqlite_instance, "get_attack_results_async", wraps=sqlite_instance.get_attack_results_async
+ ) as owners,
+ patch.object(
+ sqlite_instance, "get_conversation_stats_async", wraps=sqlite_instance.get_conversation_stats_async
+ ) as stats,
+ patch.object(
+ sqlite_instance, "get_finding_evidence_async", wraps=sqlite_instance.get_finding_evidence_async
+ ) as evidence_pages,
+ patch.object(sqlite_instance, "get_findings_async", wraps=sqlite_instance.get_findings_async) as finding_pages,
+ ):
+ selected = await service.get_findings_with_evidence_async(
+ operation_id=operation.id, finding_ids=[finding.id for finding in findings]
+ )
+ owners.assert_called_once()
+ stats.assert_called_once()
+ evidence_pages.assert_not_called()
+ finding_pages.assert_not_called()
+ assert [finding.title for finding, _ in selected] == ["Critical", "Moderate", "Low"]
+ for finding, items in selected:
+ page = await service.list_finding_evidence_async(
+ operation_id=operation.id, finding_id=finding.id, limit=10, offset=0
+ )
+ assert len(items) == 2
+ assert items == page.items
+
+
+async def _store_shared_conversation_attacks_async(
+ sqlite_instance: SQLiteMemory,
+) -> tuple[Operation, list[AttackResult]]:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Shared conversation"))
+ attacks = [
+ AttackResult(
+ conversation_id="shared",
+ objective=objective,
+ operation_id=operation.id,
+ timestamp=datetime(2026, 1, day, tzinfo=UTC),
+ )
+ for day, objective in [(1, "First run"), (2, "Second run")]
+ ]
+ await sqlite_instance.add_attack_results_to_memory_async(attack_results=attacks)
+ await sqlite_instance.add_message_to_memory_async(
+ request=Message(message_pieces=[MessagePiece(role="user", original_value="Shared", conversation_id="shared")])
+ )
+ return operation, attacks
+
+
+async def test_findings_with_evidence_keep_an_older_attack_sharing_a_conversation_async(
+ sqlite_instance: SQLiteMemory,
+) -> None:
+ operation, attacks = await _store_shared_conversation_attacks_async(sqlite_instance)
+ findings = []
+ for attack in attacks:
+ finding = await sqlite_instance.add_finding_async(
+ finding=Finding(operation_id=operation.id, title=attack.objective, severity="low")
+ )
+ await sqlite_instance.add_finding_evidence_async(
+ evidence=FindingEvidence(finding_id=finding.id, attack_result_id=UUID(attack.attack_result_id))
+ )
+ findings.append(finding)
+ service = OperationService()
+
+ selected = await service.get_findings_with_evidence_async(
+ operation_id=operation.id, finding_ids=[finding.id for finding in findings]
+ )
+
+ assert {finding.title: [(item.objective, item.availability) for item in items] for finding, items in selected} == {
+ "First run": [("First run", EvidenceAvailability.AVAILABLE)],
+ "Second run": [("Second run", EvidenceAvailability.AVAILABLE)],
+ }
+ for finding, items in selected:
+ page = await service.list_finding_evidence_async(
+ operation_id=operation.id, finding_id=finding.id, limit=10, offset=0
+ )
+ assert items == page.items
+
+
+async def test_finding_evidence_page_keeps_attacks_sharing_a_conversation_async(
+ sqlite_instance: SQLiteMemory,
+) -> None:
+ operation, attacks = await _store_shared_conversation_attacks_async(sqlite_instance)
+ finding = await sqlite_instance.add_finding_async(
+ finding=Finding(operation_id=operation.id, title="Shared", severity="low")
+ )
+ for attack in attacks:
+ await sqlite_instance.add_finding_evidence_async(
+ evidence=FindingEvidence(finding_id=finding.id, attack_result_id=UUID(attack.attack_result_id))
+ )
+
+ page = await OperationService().list_finding_evidence_async(
+ operation_id=operation.id, finding_id=finding.id, limit=10, offset=0
+ )
+
+ assert len(page.items) == 2
+ assert {(item.objective, item.availability) for item in page.items} == {
+ ("First run", EvidenceAvailability.AVAILABLE),
+ ("Second run", EvidenceAvailability.AVAILABLE),
+ }
+
+
+async def test_findings_with_evidence_resolve_in_batches_async(
+ sqlite_instance: SQLiteMemory,
+ selected_findings_async: tuple[Operation, list[Finding]],
+) -> None:
+ operation, findings = selected_findings_async
+ service = OperationService()
+ finding_ids = [finding.id for finding in findings]
+ unbatched = await service.get_findings_with_evidence_async(operation_id=operation.id, finding_ids=finding_ids)
+ with (
+ patch("pyrit.backend.services.operation_service._EVIDENCE_BATCH_SIZE", 2),
+ patch.object(
+ sqlite_instance, "get_attack_results_async", wraps=sqlite_instance.get_attack_results_async
+ ) as owners,
+ ):
+ batched = await service.get_findings_with_evidence_async(operation_id=operation.id, finding_ids=finding_ids)
+ assert batched == unbatched
+ assert owners.call_count == 3
+
+
+async def test_findings_with_evidence_reject_missing_and_foreign_findings_async(
+ sqlite_instance: SQLiteMemory,
+ selected_findings_async: tuple[Operation, list[Finding]],
+) -> None:
+ operation, findings = selected_findings_async
+ other = await sqlite_instance.add_operation_async(Operation(name="Other"))
+ foreign = await sqlite_instance.add_finding_async(
+ finding=Finding(operation_id=other.id, title="Foreign", severity="low")
+ )
+ unknown = uuid4()
+ service = OperationService()
+ for finding_ids, named in [([findings[0].id, foreign.id, unknown], foreign.id), ([unknown, foreign.id], unknown)]:
+ with pytest.raises(FindingNotFoundError, match=f"Finding '{named}' not found in operation '{operation.id}'"):
+ await service.get_findings_with_evidence_async(operation_id=operation.id, finding_ids=finding_ids)
+ with pytest.raises(OperationNotFoundError):
+ await service.get_findings_with_evidence_async(operation_id=MISSING_ID, finding_ids=[findings[0].id])
diff --git a/tests/unit/memory/test_findings.py b/tests/unit/memory/test_findings.py
index 9a2d095c28..94d45c0e7a 100644
--- a/tests/unit/memory/test_findings.py
+++ b/tests/unit/memory/test_findings.py
@@ -411,6 +411,80 @@ async def test_one_attack_result_can_support_multiple_findings_and_counts(sqlite
assert await sqlite_instance.get_finding_evidence_counts_async(finding_ids=[]) == {}
+async def test_get_findings_by_ids_follows_dashboard_order_and_scope(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Selected"))
+ other = await sqlite_instance.add_operation_async(Operation(name="Other"))
+ stamp = datetime(2026, 10, 6, tzinfo=UTC)
+ saved: dict[str, Finding] = {}
+ for title, severity, created_at in [
+ ("Low", "low", stamp),
+ ("Older critical", "critical", stamp),
+ ("Moderate", "moderate", stamp),
+ ("Newer critical", "critical", datetime(2026, 10, 7, tzinfo=UTC)),
+ ]:
+ saved[title] = await sqlite_instance.add_finding_async(
+ finding=Finding(operation_id=operation.id, title=title, severity=severity, created_at=created_at)
+ )
+ foreign = await sqlite_instance.add_finding_async(
+ finding=Finding(operation_id=other.id, title="Foreign", severity="critical")
+ )
+ 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)
+ assert [finding.title for finding in findings] == ["Newer critical", "Older critical", "Moderate", "Low"]
+
+
+async def test_get_findings_by_ids_reads_in_batches(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Batched"))
+ for number, severity in enumerate(["low", "critical", "moderate", "critical", "informational"]):
+ await sqlite_instance.add_finding_async(
+ finding=Finding(operation_id=operation.id, title=str(number), severity=severity)
+ )
+ expected = await sqlite_instance.get_findings_async(operation_id=operation.id, limit=10)
+ with patch.object(sqlite_instance, "_MAX_BIND_VARS", 2):
+ findings = await sqlite_instance.get_findings_by_ids_async(
+ operation_id=operation.id, finding_ids=[finding.id for finding in reversed(expected)]
+ )
+ assert len(expected) == 5
+ assert findings == expected
+
+
+async def test_get_finding_evidence_for_findings_groups_newest_first(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Evidence"))
+ findings = [
+ await sqlite_instance.add_finding_async(finding=Finding(operation_id=operation.id, title=title, severity="low"))
+ for title in ["Two links", "One link", "No links"]
+ ]
+ older = FindingEvidence(
+ finding_id=findings[0].id, attack_result_id=uuid4(), attached_at=datetime(2026, 10, 8, tzinfo=UTC)
+ )
+ newer = FindingEvidence(
+ finding_id=findings[0].id, attack_result_id=uuid4(), attached_at=datetime(2026, 10, 9, tzinfo=UTC)
+ )
+ only = FindingEvidence(finding_id=findings[1].id, attack_result_id=uuid4())
+ for evidence in [older, newer, only]:
+ await sqlite_instance.add_finding_evidence_async(evidence=evidence)
+ expected = {
+ finding.id: await sqlite_instance.get_finding_evidence_async(finding_id=finding.id, limit=10, offset=0)
+ for finding in findings[:2]
+ }
+ assert expected[findings[0].id] == [newer, older]
+ finding_ids = [finding.id for finding in findings]
+ assert await sqlite_instance.get_finding_evidence_for_findings_async(finding_ids=finding_ids) == expected
+ with patch.object(sqlite_instance, "_MAX_BIND_VARS", 1):
+ batched = await sqlite_instance.get_finding_evidence_for_findings_async(
+ finding_ids=[*finding_ids, findings[0].id]
+ )
+ assert batched == expected
+
+
+async def test_batched_finding_reads_accept_empty_input(sqlite_instance: SQLiteMemory) -> None:
+ with patch.object(sqlite_instance, "get_session_async", new_callable=AsyncMock) as get_session:
+ assert await sqlite_instance.get_findings_by_ids_async(operation_id=uuid4(), finding_ids=[]) == []
+ assert await sqlite_instance.get_finding_evidence_for_findings_async(finding_ids=[]) == {}
+ get_session.assert_not_called()
+
+
async def test_finding_severity_counts_group_by_operation(sqlite_instance: SQLiteMemory) -> None:
operation = await sqlite_instance.add_operation_async(Operation(name="Counted"))
other = await sqlite_instance.add_operation_async(Operation(name="Other"))
diff --git a/tests/unit/output/finding_report/test_markdown.py b/tests/unit/output/finding_report/test_markdown.py
new file mode 100644
index 0000000000..bd3f6d3c3d
--- /dev/null
+++ b/tests/unit/output/finding_report/test_markdown.py
@@ -0,0 +1,338 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import re
+
+import pytest
+
+from pyrit.output.finding_report.markdown import MarkdownFindingReportPrinter
+from pyrit.output.finding_report.report import (
+ EVIDENCE_NOTE,
+ FindingReport,
+ FindingReportColumn,
+ FindingReportEntry,
+ FindingReportEvidence,
+ FindingReportField,
+)
+
+LINK = "https://pyrit.example.test/attacks/a/conversations/b?findingEvidenceId=c"
+NOT_SCORED = FindingReportField(label="Score", value="Not scored")
+HOSTILE_BODY = (
+ "Literal ```` run\n````\n# Forged finding\n
\n"
+ "\n[link](https://evil.example)\nÜnïcødé 😀 中文\n"
+)
+TABLE_REPORT = FindingReport(
+ operation_name="Synthetic evaluation",
+ findings=(
+ FindingReportEntry(
+ title="First finding",
+ severity=FindingReportField(label="Severity", value="Critical"),
+ fields=(
+ FindingReportField(label="Harm-type", value="Synthetic harm", column=FindingReportColumn.ASSESSMENT),
+ FindingReportField(label="Description", value="Synthetic description"),
+ ),
+ evidence=(
+ FindingReportEvidence(
+ title="Linked objective",
+ summary=(
+ FindingReportField(label="Outcome", value="Success"),
+ FindingReportField(label="Score", value="True (SubStringScorer)"),
+ ),
+ details=(FindingReportField(label="Rationale", value="Synthetic rationale"),),
+ link_url=LINK,
+ ),
+ FindingReportEvidence(
+ title="Unscored objective",
+ summary=(FindingReportField(label="Outcome", value="Failure"), NOT_SCORED),
+ details=(FindingReportField(label="From operation", value="Synthetic source"),),
+ link_url=LINK,
+ ),
+ FindingReportEvidence(title="attack-404", summary=(), details=(), link_url=None),
+ ),
+ ),
+ FindingReportEntry(
+ title="Second finding",
+ severity=FindingReportField(label="Severity", value="Low"),
+ fields=(),
+ evidence=(),
+ ),
+ ),
+)
+
+
+def _single_finding_report(
+ *, fields: tuple[FindingReportField, ...], evidence: tuple[FindingReportEvidence, ...]
+) -> FindingReport:
+ finding = FindingReportEntry(
+ title="Synthetic finding",
+ severity=FindingReportField(label="Severity", value="Low"),
+ fields=fields,
+ evidence=evidence,
+ )
+ return FindingReport(operation_name="Synthetic evaluation", findings=(finding,))
+
+
+def _consecutive_lines(*, markdown: str, expected: list[str]) -> list[str]:
+ """The rendered lines from the first expected line on, as many as are expected."""
+ lines = markdown.split("\n")
+ start = lines.index(expected[0])
+ return lines[start : start + len(expected)]
+
+
+async def test_renders_the_report_top_to_bottom() -> None:
+ markdown = await MarkdownFindingReportPrinter().render_async(TABLE_REPORT)
+
+ # Assessment fields lead under the title, as the Assessment column sits beside it in Word.
+ assert markdown.split("\n") == [
+ "# Findings - Synthetic evaluation",
+ "",
+ EVIDENCE_NOTE,
+ "",
+ "## First finding",
+ "",
+ "**Severity:** Critical",
+ "",
+ "**Harm-type:** Synthetic harm",
+ "",
+ "**Description:** Synthetic description",
+ "",
+ "**Evidence:**",
+ "",
+ f"- [Linked objective](<{LINK}>)",
+ " - **Outcome:** Success · **Score:** True (SubStringScorer)",
+ " - **Rationale:** Synthetic rationale",
+ f"- [Unscored objective](<{LINK}>)",
+ " - **Outcome:** Failure · **Score:** Not scored",
+ " - **From operation:** Synthetic source",
+ "- **attack-404**",
+ " - Evidence unavailable",
+ "",
+ "## Second finding",
+ "",
+ "**Severity:** Low",
+ "",
+ "**Evidence:** none attached",
+ "",
+ ]
+
+
+async def test_multi_line_values_stay_prose() -> None:
+ evidence = FindingReportEvidence(
+ title="Source text",
+ summary=(FindingReportField(label="Outcome", value="Success"), NOT_SCORED),
+ details=(FindingReportField(label="Rationale", value="one\n\ntwo"),),
+ link_url=None,
+ )
+ report = _single_finding_report(
+ fields=(
+ FindingReportField(label="Description", value="First line\nsecond line"),
+ FindingReportField(label="Synthetic class", value="Class B", column=FindingReportColumn.ASSESSMENT),
+ ),
+ evidence=(evidence,),
+ )
+
+ markdown = await MarkdownFindingReportPrinter().render_async(report)
+
+ # Lines end in hard breaks and blank lines separate paragraphs, inside an evidence bullet too.
+ assert markdown.split("\n") == [
+ "# Findings - Synthetic evaluation",
+ "",
+ EVIDENCE_NOTE,
+ "",
+ "## Synthetic finding",
+ "",
+ "**Severity:** Low",
+ "",
+ "**Synthetic class:** Class B",
+ "",
+ "**Description:** First line\\",
+ "second line",
+ "",
+ "**Evidence:**",
+ "",
+ "- **Source text**",
+ " - **Outcome:** Success · **Score:** Not scored",
+ " - **Rationale:** one",
+ "",
+ " two",
+ " - Evidence unavailable",
+ "",
+ ]
+
+
+async def test_block_syntax_at_line_starts_is_escaped() -> None:
+ description = "- item\n+ item\n1. item\n2) item\n===\n indented\n# heading\n> quote\n```"
+ rationale = FindingReportField(label="Rationale", value="one\n- item\n1. item\n\n===\n indented")
+ evidence = FindingReportEvidence(title="Linked objective", summary=(), details=(rationale,), link_url=LINK)
+ report = _single_finding_report(
+ fields=(FindingReportField(label="Description", value=description),),
+ evidence=(evidence,),
+ )
+
+ markdown = await MarkdownFindingReportPrinter().render_async(report)
+
+ # The first line follows the label, so only the lines after it can start a block.
+ expected = [
+ "**Description:** - item\\",
+ "\\+ item\\",
+ "1\\. item\\",
+ "2\\) item\\",
+ "\\===\\",
+ "indented\\",
+ "\\# heading\\",
+ "\\> quote\\",
+ "\\`\\`\\`",
+ "",
+ ]
+ assert _consecutive_lines(markdown=markdown, expected=expected) == expected
+ # Every paragraph of a multi-paragraph value stays inside its evidence bullet.
+ in_bullet = [
+ f"- [Linked objective](<{LINK}>)",
+ " - **Rationale:** one\\",
+ " \\- item\\",
+ " 1\\. item",
+ "",
+ " \\===\\",
+ " indented",
+ "",
+ ]
+ assert _consecutive_lines(markdown=markdown, expected=in_bullet) == in_bullet
+
+
+async def test_single_line_value_keeps_its_text_after_the_label() -> None:
+ report = _single_finding_report(
+ fields=(FindingReportField(label="Score", value="0.75 (SyntheticScorer)"),),
+ evidence=(),
+ )
+
+ markdown = await MarkdownFindingReportPrinter().render_async(report)
+
+ assert "**Score:** 0.75 (SyntheticScorer)" in markdown.split("\n")
+
+
+async def test_hostile_text_cannot_form_markup() -> None:
+ link = "https://pyrit.example.test/attacks/a(1)/conversations/b ?findingEvidenceId=d"
+ destination = "https://pyrit.example.test/attacks/a(1)/conversations/b%20%3Cc%3E?findingEvidenceId=d"
+ evidence = FindingReportEvidence(
+ title=HOSTILE_BODY,
+ summary=(
+ FindingReportField(label="Outcome", value="Success"),
+ FindingReportField(label="Score", value="True
[l](x)"),
+ ),
+ details=(FindingReportField(label="Rationale", value="`````\n
"),),
+ link_url=link,
+ )
+ report = FindingReport(
+ operation_name="Ops bold\n# Forged operation",
+ findings=(
+ FindingReportEntry(
+ title="First `code`\n## 9. Forged finding
 [l](x)",
+ severity=FindingReportField(label="Severity", value="Custom & ~~struck~~ | $x$ # Forged severity"),
+ fields=(FindingReportField(label="Description", value=HOSTILE_BODY),),
+ evidence=(evidence,),
+ ),
+ FindingReportEntry(
+ title="Second Ünïcødé 😀 中文 finding",
+ severity=FindingReportField(label="Severity", value="Low"),
+ fields=(),
+ evidence=(),
+ ),
+ ),
+ )
+
+ markdown = await MarkdownFindingReportPrinter().render_async(report)
+
+ lines = markdown.split("\n")
+ assert markdown.endswith("\n")
+ assert [line for line in lines if line.startswith("#")] == [
+ r"# Findings - Ops \bold\ \# Forged operation",
+ r"## First \`code\` \#\# 9. Forged finding \
!\[i\](https://evil.example/i.png) \[l\](x)",
+ "## Second Ünïcødé 😀 中文 finding",
+ ]
+ assert r"**Severity:** Custom \& \~\~struck\~\~ \| \$x\$ \# Forged severity" in lines
+ assert r" - **Outcome:** Success · **Score:** True \
\[l\](x)" in lines
+ assert "**Evidence:** none attached" in lines
+ # The objective is the link's text, escaped onto one line so it cannot close the link or open another.
+ link_lines = [line for line in lines if line.startswith("- [") and line.endswith(f"](<{destination}>)")]
+ assert len(link_lines) == 1
+ link_text = link_lines[0][len("- [") : -len(f"](<{destination}>)")]
+ assert re.search(r"(?\[\]`]", link_text) is None
+ # Authored text never opens HTML, a link, an image, or code.
+ assert [line for line in lines if line not in link_lines and re.search(r"(?\[\]`]", line)] == []
+
+
+async def test_commonmark_parses_hostile_paragraphs_as_prose() -> None:
+ markdown_it = pytest.importorskip("markdown_it")
+ paragraphs = "```\nfenced\n```\n\n indented code\n\nhtml
\n\n# Forged heading\n\n- Forged item"
+ evidence = FindingReportEvidence(
+ title=HOSTILE_BODY,
+ summary=(FindingReportField(label="Outcome", value="Success"), NOT_SCORED),
+ details=(FindingReportField(label="Rationale", value=f"Rationale start\n\n{paragraphs}\n\nRationale end"),),
+ link_url=LINK,
+ )
+ report = FindingReport(
+ operation_name="Ops bold\n# Forged operation",
+ findings=(
+ FindingReportEntry(
+ title="First finding\n## Forged finding",
+ severity=FindingReportField(label="Severity", value="Critical"),
+ fields=(FindingReportField(label="Description", value=f"{HOSTILE_BODY}\n{paragraphs}"),),
+ evidence=(evidence,),
+ ),
+ FindingReportEntry(
+ title="Second finding",
+ severity=FindingReportField(label="Severity", value="Low"),
+ fields=(),
+ evidence=(),
+ ),
+ ),
+ )
+
+ markdown = await MarkdownFindingReportPrinter().render_async(report)
+ tokens = markdown_it.MarkdownIt("commonmark").parse(markdown)
+
+ children = [child for token in tokens for child in token.children or []]
+ assert {token.type for token in tokens + children}.isdisjoint(
+ {"fence", "code_block", "html_block", "html_inline", "image"}
+ )
+ assert [child.attrGet("href") for child in children if child.type == "link_open"] == [LINK]
+ # Only the evidence bullet and its summary and rationale items are lists.
+ assert sum(token.type == "list_item_open" for token in tokens) == 3
+ headings = [
+ (token.tag, "".join(child.content for child in tokens[index + 1].children or []))
+ for index, token in enumerate(tokens)
+ if token.type == "heading_open"
+ ]
+ assert headings == [
+ ("h1", "Findings - Ops bold # Forged operation"),
+ ("h2", "First finding ## Forged finding"),
+ ("h2", "Second finding"),
+ ]
+ # The rationale's last paragraph must sit in the same nested list item as its label.
+ open_blocks = []
+ list_items = {}
+ for token in tokens:
+ if token.nesting == 1:
+ open_blocks.append(token)
+ elif token.nesting == -1:
+ open_blocks.pop()
+ elif token.type == "inline":
+ list_items[token.content] = [block for block in open_blocks if block.type == "list_item_open"]
+ assert len(list_items["Rationale end"]) == 2
+ assert list_items["Rationale end"][-1] is list_items["**Rationale:** Rationale start"][-1]
+
+
+async def test_breaks_lines_and_marks_control_characters() -> None:
+ report = _single_finding_report(
+ fields=(
+ FindingReportField(label="Breaks", value="crlf\r\nvt\vff\ffs\x1cnel\x85ls\u2028ps\u2029end"),
+ FindingReportField(label="Controls", value="c1\x80 c1\x9f del\x7f nul\x00 tab\tend"),
+ ),
+ evidence=(),
+ )
+
+ markdown = await MarkdownFindingReportPrinter().render_async(report)
+
+ breaks = ["**Breaks:** crlf\\", "vt\\", "ff\\", "fs\\", "nel\\", "ls\\", "ps\\", "end", ""]
+ assert _consecutive_lines(markdown=markdown, expected=breaks) == breaks
+ assert "**Controls:** c1\ufffd c1\ufffd del\ufffd nul\ufffd tab\tend" in markdown.split("\n")
diff --git a/tests/unit/output/test_helpers.py b/tests/unit/output/test_helpers.py
index 11d9d81d34..a506a6f402 100644
--- a/tests/unit/output/test_helpers.py
+++ b/tests/unit/output/test_helpers.py
@@ -19,9 +19,12 @@
Score,
)
from pyrit.output.conversation.source import MemoryConversationSource
+from pyrit.output.finding_report.markdown import MarkdownFindingReportPrinter
+from pyrit.output.finding_report.report import FindingReport, FindingReportEntry, FindingReportField
from pyrit.output.helpers import (
output_attack_async,
output_conversation_async,
+ output_finding_report_async,
output_scenario_async,
output_scenario_attacks_async,
output_scenario_conversations_async,
@@ -30,7 +33,7 @@
output_scorer_async,
)
from pyrit.output.scenario_result.json import JsonScenarioResultMemoryPrinter
-from pyrit.output.sink import FileSink, IPythonMarkdownSink, StdoutSink, get_default_sink
+from pyrit.output.sink import FileSink, IPythonMarkdownSink, Sink, StdoutSink, get_default_sink
# --- get_default_sink tests ---
@@ -241,6 +244,42 @@ async def test_output_score_async_unsupported_format():
await output_score_async([AsyncMock()], format="markdown")
+# --- output_finding_report_async tests ---
+
+FINDING_REPORT = FindingReport(
+ operation_name="Synthetic evaluation",
+ findings=(
+ FindingReportEntry(
+ title="Synthetic finding",
+ severity=FindingReportField(label="Severity", value="Low"),
+ fields=(FindingReportField(label="Description", value="First line\nsecond line"),),
+ evidence=(),
+ ),
+ ),
+)
+
+
+class _RecordingSink(Sink):
+ def __init__(self) -> None:
+ self.written: list[str] = []
+
+ async def write_async(self, data: str) -> None:
+ self.written.append(data)
+
+
+async def test_output_finding_report_async_writes_markdown_to_the_sink():
+ sink = _RecordingSink()
+
+ await output_finding_report_async(FINDING_REPORT, sink=sink)
+
+ assert sink.written == [await MarkdownFindingReportPrinter().render_async(FINDING_REPORT)]
+
+
+async def test_output_finding_report_async_rejects_other_formats():
+ with pytest.raises(ValueError, match="Unsupported format for finding reports: 'json'"):
+ await output_finding_report_async(FINDING_REPORT, format="json")
+
+
# --- json-branch tests (each routes to the Json* memory printer) ---