diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index affe0cf71b..eb9e007f79 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -722,6 +722,63 @@ If an attack no longer exists, its entry stays, shows the attack's ID and attachment time, and reads **Evidence unavailable**. **Remove link** removes only the link, never the attack. Deleting a finding removes its links the same way. +#### Exporting findings + +**Export findings** on an operation page downloads the findings you select as +one document to copy into a larger report. The dialog lists every finding in the +operation, not only the 20 on the current page, and starts with all of them +selected. Clear the findings to leave out, or use **Select all** to select or +clear the whole list; it shows a mixed state when only some are selected. A +finding added after you open the dialog appears the next time you open it. + +Under **Format**, choose **Word (.docx)**, the default, or **Markdown (.md)**, then +**Download Word** or **Download Markdown**. Both formats hold the same findings, +fields, and evidence. Word puts every finding in one table with **Finding** and +**Assessment** columns, so you can copy rows into your report. Markdown lists +the same information from top to bottom. +PyRIT doesn't use report templates and never uploads or reads your report. +Files are named after the operation and your computer's local time, for example +`copyrit-findings-synthetic-evaluation-2026-10-22-1258.docx`. + +The document is headed with the operation's name, and findings follow the +dashboard's order: most severe first, newest first within each level. The +**Finding** column holds each title, description, and evidence; **Assessment** +holds severity and harm-type. Markdown puts severity and harm-type first under +each title. A Word row with synthetic content reads: + +```text +Finding | Assessment +--------------------------------------------------+------------------------------------ +Restricted test marker disclosed | Severity: Important +Description: The assistant returned a synthetic | Harm-type: Proprietary, Confidential +marker that the test policy instructed it to | & Classified Information +withhold. | +Evidence: | +• Restricted-marker test (link) | + Outcome: Success · Score: True (SubStringScorer)| + Rationale: The response contained the | + restricted marker. | +``` + +Each attached attack is a bullet led by its objective, which links to the same +conversation as **Open attack**. Under it come the outcome, the same score as +the evidence table (with **human review** as the scorer when a person scored it, +or **Not scored**), the score's rationale, and the attack's operation when it +isn't this one. An attack that no longer exists keeps its entry under its attack +ID and reads **Evidence unavailable**; a finding without evidence reads +**Evidence: none attached**. Messages, attachments, and media are never +included. The document reads fine offline, but its links open only for someone +with access to this PyRIT deployment. + +Each download reads the findings and evidence as saved at that moment, and its +links open the live attacks, which can gain messages and scores later. If a +selected finding is deleted before you download, the download fails. + +> **Note:** Findings, attack objectives, and score rationales can contain +> sensitive content, and nothing is redacted automatically. Your PyRIT server +> generates the document and sends it to no other service. Store and share +> exported files responsibly. + #### Upgrading an existing database One migration adds Operations, findings, evidence, and optional Operation IDs on diff --git a/frontend/e2e/operations.spec.ts b/frontend/e2e/operations.spec.ts index ff32c066f3..1c53b64b4d 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_base_url: `${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**\n\n\`\`\`\`text\n${description}\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..0093513e51 --- /dev/null +++ b/frontend/src/components/Operations/FindingExportDialog.styles.ts @@ -0,0 +1,13 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { mobileTouchTargetHeight } from '@/styles/touchTargets' + +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' }, + 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..30cef36a60 --- /dev/null +++ b/frontend/src/components/Operations/FindingExportDialog.test.tsx @@ -0,0 +1,407 @@ +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}' + +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) +}) + +afterEach(() => { + delete process.env.BASE_URL +}) + +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_base_url: '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_base_url: '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('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('sends the dashboard address with its application path prefix', async () => { + const user = userEvent.setup() + process.env.BASE_URL = '/team/pyrit/' + renderDialog() + const dialog = await openDialog(user) + await within(dialog).findByRole('checkbox', { name: 'Select all' }) + 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].dashboard_base_url) + .toBe('http://localhost/team/pyrit/') + }) + + 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..7417d84606 --- /dev/null +++ b/frontend/src/components/Operations/FindingExportDialog.tsx @@ -0,0 +1,209 @@ +import { useEffect, 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() + +type FindingsLoad = + | { status: 'loading' } + | { status: 'error'; error: string } + | { status: 'loaded'; findings: FindingListItem[] } + + +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 [exporting, setExporting] = useState(false) + const submitting = useRef(false) + const triggerRef = useRef(null) + // 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' + + 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()] }) + setSelected(new Set(loaded.keys())) + } + loadAllPages().catch((cause: unknown) => { + if (!ignore) setLoad({ status: 'error', error: toApiError(cause).detail }) + }) + return () => { ignore = true } + }, [open, loadRevision, operation.id]) + + const start = (): void => { + submitting.current = false + setExporting(false) + setExportError('') + 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('') + try { + const file = await operationsApi.exportFindings(operation.id, { + finding_ids: selectedIds, + format, + dashboard_base_url: new URL(import.meta.env.BASE_URL || '/', window.location.origin).href, + }, 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) setExportError(toApiError(cause).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 => ( +
  • + { toggle(finding.id, data.checked === true) }} /> + +
  • + ))} +
+ + ) + } + + return ( + { + if (data.open) start() + else setOpen(false) + }}> + + + + + + Export findings + + + Findings, attack objectives and score rationales can contain sensitive content. Nothing is redacted + automatically. + + {exportError && + Could not export findings: {exportError} + } + + + + {renderFindings()} + + + + + + + + + + + ) +} 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 }: }}> - + 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 e12d0c483b..9aba3acf74 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_base_url: "https://pyrit.example.test/red-team/", + }; + 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. @@ -268,6 +288,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 1037dde32f..60220a1da6 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 { @@ -83,6 +83,7 @@ import type { FindingEvidenceCreateRequest, FindingEvidenceAttachResponse, FindingEvidenceListResponse, + FindingExportRequest, Operation, OperationCreate, OperationDetail, @@ -152,6 +153,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({ @@ -228,6 +236,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 bf65c1844d..f735d181b3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -2019,3 +2019,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 address, including any path prefix, that evidence links open. */ + dashboard_base_url: string +} 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..3d8d784353 --- /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 applies the same + * rule in labelled UTC for clients that use its suggested name. + */ +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.ts b/frontend/src/utils/routeParams.ts index d0ca938e3e..c679c32760 100644 --- a/frontend/src/utils/routeParams.ts +++ b/frontend/src/utils/routeParams.ts @@ -41,7 +41,12 @@ 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. + */ export function attackConversationRoutePath( attackResultId: string, conversationId: string, diff --git a/pyrit/backend/models/operations.py b/pyrit/backend/models/operations.py index 1b62d10bc0..2b8ace2283 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,54 @@ 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 address 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_base_url: AnyHttpUrl = Field( + description="The dashboard's address, including any path prefix, that 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_base_url") + @classmethod + def _reject_credentials_query_and_fragment(cls, url: AnyHttpUrl) -> AnyHttpUrl: + """ + Allow an application path prefix, but not credentials, a query, or a fragment. + + Returns: + AnyHttpUrl: The dashboard address. + + Raises: + ValueError: If the address has credentials, a query, or a fragment. + """ + if url.username or url.password or url.query is not None or url.fragment is not None: + raise ValueError("Must not include credentials, a query, or a fragment.") + return url diff --git a/pyrit/backend/routes/operations.py b/pyrit/backend/routes/operations.py index b7719df90d..1cfa13f7bc 100644 --- a/pyrit/backend/routes/operations.py +++ b/pyrit/backend/routes/operations.py @@ -3,6 +3,7 @@ from collections.abc import Awaitable from typing import Annotated, TypeVar +from urllib.parse import quote from uuid import UUID from fastapi import APIRouter, HTTPException, Query, Response, status @@ -12,12 +13,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 +167,29 @@ 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)) + # Headers can't carry raw non-ASCII text, so such names use RFC 5987 encoding, as Starlette's FileResponse does. + quoted = quote(exported.filename) + disposition = ( + f'attachment; filename="{exported.filename}"' + if quoted == exported.filename + else f"attachment; filename*=utf-8''{quoted}" + ) + return Response( + content=exported.content, media_type=exported.media_type, headers={"Content-Disposition": disposition} + ) + + @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..5a8d0c40fb --- /dev/null +++ b/pyrit/backend/services/finding_export.py @@ -0,0 +1,515 @@ +# 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 +import re +import unicodedata +from dataclasses import dataclass +from datetime import UTC, datetime +from io import BytesIO +from typing import Literal +from urllib.parse import quote, urlencode +from uuid import UUID + +import regex +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 Pt, RGBColor +from docx.table import _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 FindingNotFoundError, OperationService +from pyrit.models import Finding, FindingSeverity, ScoreStatus +from pyrit.models.harm_category import HarmCategory + +_PAGE_SIZE = 100 +_MARKDOWN_MEDIA_TYPE = "text/markdown; charset=utf-8" +_DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" +_EVIDENCE_NOTE = "Evidence is listed as references with links to the PyRIT dashboard; transcripts are not included." +_UNAVAILABLE = "Evidence unavailable" +_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_BODY_FONT = "Calibri" +_WORD_HEADING_FONT = "Calibri Light" +_WORD_TITLE_COLOR = RGBColor(0x2F, 0x54, 0x96) +_WORD_TITLE_SIZE = Pt(16) +_WORD_TABLE_STYLE = "Table Grid" +_WORD_COLUMNS = ("Finding", "Assessment") +_DETAIL_SEPARATOR = " · " +_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) +_THEME_COLOR_ATTRIBUTES = ("w:themeColor", "w:themeShade", "w:themeTint") +_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]") +_MARKDOWN_SPECIAL = re.compile(r"[\\`*_\[\]<>&#|~$]") +_MARKDOWN_DESTINATION_UNSAFE = re.compile(r"[<>\s]") +_FILENAME_SEPARATORS = regex.compile(r"[^\p{L}\p{M}\p{N}]+") +_COMBINING_MARK = regex.compile(r"\p{M}") +_FILENAME_NAME_LIMIT = 60 + + +@dataclass(frozen=True) +class FindingExportFile: + """A generated findings document and the name to download it as.""" + + content: bytes + media_type: str + filename: str + + +@dataclass(frozen=True) +class _ReportField: + label: str + value: str + column: Literal["finding", "assessment"] = "finding" + + +_NO_EVIDENCE = _ReportField(label="Evidence", value="none attached") + + +@dataclass(frozen=True) +class _ReportEvidence: + title: str + summary: tuple[_ReportField, ...] + details: tuple[_ReportField, ...] + link_url: str | None + + @property + def unavailable(self) -> bool: + return self.link_url is None + + +@dataclass(frozen=True) +class _ReportFinding: + title: str + severity: _ReportField + fields: tuple[_ReportField, ...] + evidence: tuple[_ReportEvidence, ...] + + +@dataclass(frozen=True) +class _FindingReport: + operation_name: str + findings: tuple[_ReportFinding, ...] + + +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 = _render_markdown(report).encode("utf-8") + else: + extension, media_type = "docx", _DOCX_MEDIA_TYPE + content = await asyncio.to_thread(_render_docx, report, now=now) + filename = _export_filename( + operation_id=operation_id, operation_name=report.operation_name, extension=extension, now=now + ) + return FindingExportFile(content=content, media_type=media_type, filename=filename) + + +def _export_filename(*, operation_id: UUID, operation_name: str, extension: str, now: datetime) -> str: + """ + Name a download after its Operation, with the time in labelled UTC. + + Browsers save under the dashboard's own name, which uses the user's local time instead. + + Returns: + str: For example ``copyrit-findings-synthetic-evaluation-2026-10-22-1658-utc.docx``. + """ + name = _FILENAME_SEPARATORS.sub("-", unicodedata.normalize("NFC", operation_name).lower()).strip("-") + if len(name) > _FILENAME_NAME_LIMIT: + end = _FILENAME_NAME_LIMIT + if name[end] != "-" and "-" in name[:end]: + end = name.rindex("-", 0, end) + # A word longer than the limit is cut between letters, never between a letter and its marks. + while end > 0 and _COMBINING_MARK.match(name[end]): + end -= 1 + name = name[:end].rstrip("-") + return f"copyrit-findings-{name or 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) + findings = await _load_selected_findings_async( + service=service, operation_id=operation_id, finding_ids=request.finding_ids + ) + report_findings = [] + for finding in findings: + evidence = await _load_evidence_async(service=service, finding=finding) + severity, fields = _project_finding_fields(finding) + report_findings.append( + _ReportFinding( + title=finding.title, + severity=severity, + fields=fields, + evidence=tuple( + _project_evidence( + item=item, operation_id=operation_id, dashboard_base_url=request.dashboard_base_url + ) + for item in evidence + ), + ) + ) + return _FindingReport(operation_name=operation.name, findings=tuple(report_findings)) + + +async def _load_selected_findings_async( + *, service: OperationService, operation_id: UUID, finding_ids: list[UUID] +) -> list[Finding]: + """ + Read the selected findings in the order ``OperationService.list_findings_async`` lists them for the dashboard. + + Returns: + list[Finding]: Every selected finding. + + Raises: + OperationNotFoundError: If the Operation does not exist. + FindingNotFoundError: If a selected finding is not in the Operation. + """ + wanted = set(finding_ids) + selected: dict[UUID, Finding] = {} + offset: int | None = 0 + while offset is not None and len(selected) < len(wanted): + page = await service.list_findings_async(operation_id=operation_id, limit=_PAGE_SIZE, offset=offset) + for finding in page.items: + # A concurrent change can shift a row onto the next page, so keep its first read. + if finding.id in wanted: + selected.setdefault(finding.id, finding) + offset = page.next_offset + missing = next((finding_id for finding_id in finding_ids if finding_id not in selected), None) + if missing is not None: + raise FindingNotFoundError(f"Finding '{missing}' not found in operation '{operation_id}'.") + return list(selected.values()) + + +async def _load_evidence_async(*, service: OperationService, finding: Finding) -> list[FindingEvidenceItem]: + items: dict[UUID, FindingEvidenceItem] = {} + offset: int | None = 0 + while offset is not None: + page = await service.list_finding_evidence_async( + operation_id=finding.operation_id, finding_id=finding.id, limit=_PAGE_SIZE, offset=offset + ) + for item in page.items: + items.setdefault(item.item.id, item) + offset = page.next_offset + return list(items.values()) + + +def _project_finding_fields(finding: Finding) -> tuple[_ReportField, tuple[_ReportField, ...]]: + """ + 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[_ReportField, tuple[_ReportField, ...]]: 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 = [ + _ReportField(label="Harm-type", value=str(harm_type), column="assessment") if harm_type else None, + _ReportField(label="Description", value=finding.description) if finding.description else None, + ] + return _ReportField(label="Severity", value=severity), tuple(field for field in fields if field) + + +def _project_evidence( + *, item: FindingEvidenceItem, operation_id: UUID, dashboard_base_url: AnyHttpUrl +) -> _ReportEvidence: + if item.outcome is None: + # The attack result is gone, so its saved ID stands in for its objective. + return _ReportEvidence(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 = ( + _ReportField(label="Outcome", value=item.outcome.value.capitalize()), + _ReportField(label="Score", value=_score_text(item.score)), + ) + details = [] + if item.score is not None and item.score.score_rationale: + details.append(_ReportField(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(_ReportField(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_base_url=dashboard_base_url, item=item, conversation_id=item.conversation_id + ) + return _ReportEvidence(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_base_url: 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``. + """ + 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_base_url).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) + _replace_theme_formatting(document) + _use_current_word_spacing(document) + 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 == "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 == "assessment": + _write_word_field(paragraph=assessment_cell.add_paragraph(), field=field) + 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 _replace_theme_formatting(document: DocxDocument) -> None: + """Name Latin fonts and text colors explicitly rather than through the template's 2007-era theme.""" + styles = document.styles.element + for fonts in styles.iter(qn("w:rFonts")): + theme = fonts.get(qn("w:asciiTheme")) + if theme is None: + continue + family = _WORD_HEADING_FONT if theme.startswith("major") else _WORD_BODY_FONT + for attribute in ("w:asciiTheme", "w:hAnsiTheme"): + fonts.attrib.pop(qn(attribute), None) + fonts.set(qn("w:ascii"), family) + fonts.set(qn("w:hAnsi"), family) + for color in styles.iter(qn("w:color")): + for attribute in _THEME_COLOR_ATTRIBUTES: + color.attrib.pop(qn(attribute), None) + + +def _use_current_word_spacing(document: DocxDocument) -> None: + """Give body text and the title the spacing and look of a new Word document.""" + normal = document.styles["Normal"].paragraph_format + normal.space_after = Pt(8) + normal.line_spacing = 1.08 + title = document.styles["Heading 1"] + title.font.color.rgb = _WORD_TITLE_COLOR + title.font.size = _WORD_TITLE_SIZE + title.font.bold = False + title.paragraph_format.space_before = Pt(0) + title.paragraph_format.space_after = Pt(4) + + +def _add_word_evidence(*, cell: _Cell, evidence: tuple[_ReportEvidence, ...]) -> 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) + + +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: _ReportField) -> 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) + + +def _plain_text(value: str) -> str: + # Both formats break lines at the same characters, and mark each control character visibly rather than drop it. + return _UNPRINTABLE.sub("\ufffd", _LINE_BREAK.sub("\n", value)) + + +def _render_markdown(report: _FindingReport) -> str: + """ + Lay the report out like the Word table, read top to bottom. + + Each finding lists its Assessment fields, then its Finding fields, then its evidence as bullets. + + 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 == "assessment"] + account = [field for field in finding.fields if field.column == "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[_ReportEvidence, ...]) -> 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 = [] + if entry.summary and all(_is_short(field) for field in entry.summary): + items.append(_DETAIL_SEPARATOR.join(_markdown_field(field) for field in entry.summary)) + else: + items.extend(_markdown_field(field) for field in entry.summary) + items.extend(_markdown_field(field) for field in entry.details) + if entry.unavailable: + items.append(_UNAVAILABLE) + lines.extend(_markdown_sub_item(item) for item in items) + return "\n".join(lines) + + +def _markdown_sub_item(text: str) -> str: + # Continuation lines, such as a fenced block's, 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 _is_short(field: _ReportField) -> bool: + return "\n" not in _plain_text(field.value) + + +def _markdown_field(field: _ReportField) -> str: + if _is_short(field): + return f"**{_markdown_inline(field.label)}:** {_markdown_inline(field.value)}" + return f"**{_markdown_inline(field.label)}**\n\n{_markdown_literal(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_literal(value: str) -> str: + """ + Fence text as a literal block that no line of the text can close. + + Returns: + str: The fenced block. + """ + text = _plain_text(value) + fence = "`" * max(3, max((len(run) for run in re.findall(r"`+", text)), default=0) + 1) + return f"{fence}text\n{text}\n{fence}" + + +def _percent_encode(match: re.Match[str]) -> str: + return quote(match.group(0), safe="") diff --git a/tests/unit/backend/test_finding_export.py b/tests/unit/backend/test_finding_export.py new file mode 100644 index 0000000000..00fa661d72 --- /dev/null +++ b/tests/unit/backend/test_finding_export.py @@ -0,0 +1,1152 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +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 unittest.mock import patch +from uuid import UUID, uuid4 + +import pytest +from docx import Document +from docx.oxml.ns import qn +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, + FindingListResponse, +) +from pyrit.backend.services import finding_export +from pyrit.backend.services.finding_export import ( + _FindingReport, + _project_evidence, + _render_docx, + _render_markdown, + _ReportEvidence, + _ReportField, + _ReportFinding, +) +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 + +pytestmark = pytest.mark.usefixtures("patch_central_database") + +FORMATS = ["docx", "markdown"] +DASHBOARD_BASE = "https://pyrit.example.test/team(a)/" +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" + "![image](https://evil.example/i.png)\n[link](https://evil.example)\nÜnïcødé 😀 中文\n" +) +NOT_SCORED = _ReportField(label="Score", value="Not scored") +NOT_IN_AN_OPERATION = _ReportField(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", +): + body: dict[str, object] = { + "finding_ids": [str(finding_id) for finding_id in finding_ids], + "dashboard_base_url": DASHBOARD_BASE, + } + 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_base_url": DASHBOARD_BASE} + ) + return await finding_export._build_report_async(operation_id=operation_id, request=request) + + +def _dashboard_link(*, evidence: FindingEvidence, conversation_id: str) -> str: + return ( + f"https://pyrit.example.test/team(a)/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 _bold_paragraph(content: bytes, text: str) -> bool: + """Whether the Word paragraph with exactly ``text``, in the body or a table cell, is bold throughout.""" + document = Document(BytesIO(content)) + cells = [cell for table in document.tables for row in table.rows for cell in row.cells] + paragraphs = [*document.paragraphs, *(paragraph for cell in cells for paragraph in cell.paragraphs)] + paragraph = next(paragraph for paragraph in paragraphs if paragraph.text == text) + return all(run.bold for run in paragraph.runs) + + +def _assert_attachment(*, status_code: int, headers: Mapping[str, str], filename_slug: str, export_format: str) -> None: + assert status_code == 200 + assert headers["content-type"] == MEDIA_TYPES[export_format] + assert re.fullmatch( + rf'attachment; filename="copyrit-findings-{filename_slug}-{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, + operation=operation, + title="Critical finding", + severity="critical", + harm_type=HarmCategory.PROFANITY, + description="Synthetic description\nsecond line", + ), + "Included finding": await _add_finding_async(memory, operation=operation, title="Included finding"), + "Excluded finding": await _add_finding_async(memory, operation=operation, title="Excluded finding"), + } + for index in range(filler_count): + await _add_finding_async(memory, operation=operation, title=f"Filler {index:02d}") + named["Low finding"] = await _add_finding_async(memory, operation=operation, title="Low finding", severity="low") + attack = await _add_attack_async( + memory, conversation_id="main", objective="Restricted-marker test", operation_id=operation.id + ) + await _link_async(memory, finding=named["Critical finding"], attack_result_id=attack.attack_result_id) + await _link_async(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[_ReportField, ...], evidence: tuple[_ReportEvidence, ...] +) -> _FindingReport: + finding = _ReportFinding( + title="Synthetic finding", + severity=_ReportField(label="Severity", value="Low"), + fields=fields, + evidence=evidence, + ) + return _FindingReport(operation_name="Synthetic evaluation", findings=(finding,)) + + +def _split_fenced_blocks(markdown: str) -> tuple[list[str], list[str]]: + """ + Separate lines outside fenced blocks from each block's literal body, following CommonMark's fence rules. + + A fence nested in a list item is indented with the item's content, so that indentation is removed from its body. + """ + outside: list[str] = [] + bodies: list[str] = [] + fence: str | None = None + indent = 0 + body: list[str] = [] + for line in markdown.split("\n"): + if fence is None: + opening = re.match(r"( *)(`{3,}|~{3,})", line) + if opening: + indent, fence, body = len(opening.group(1)), opening.group(2), [] + else: + outside.append(line) + elif re.fullmatch(rf" *{re.escape(fence[0])}{{{len(fence)},}}[ \t]*", line): + bodies.append("\n".join(body)) + fence = None + else: + body.append(line[indent:] if line.startswith(" " * indent) else line.lstrip(" ")) + assert fence is None, "A fenced block was left open" + return outside, bodies + + +@pytest.fixture +async def named_findings_async(sqlite_instance: SQLiteMemory) -> tuple[Operation, dict[str, Finding]]: + return await _save_named_findings_async(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 first 100-row page.""" + return await _save_named_findings_async(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(sqlite_instance, named=named) + before = await _saved_state_async(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, + filename_slug="synthetic-evaluation", + export_format=export_format, + ) + assert _finding_titles(response.content, export_format) == ["Critical finding", "Included finding", "Low finding"] + assert _links(response.content, export_format) == [ + ("Restricted-marker test", _dashboard_link(evidence=evidence, conversation_id="main")) + ] + assert await _saved_state_async(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, + filename_slug="synthetic-evaluation", + export_format="docx", + ) + assert _paragraphs(response.content)[:5] == [ + "Findings - Synthetic evaluation", + EVIDENCE_NOTE, + "Finding", + "Assessment", + "Included finding", + ] + + +@pytest.mark.parametrize( + ("operation_name", "expected_name"), + [ + ("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" * 58 + " bcdef", "a" * 58), + ("x" * 80, "x" * 60), + # The 61st code point is a vowel sign, so the cut moves back before its letter. + ("क" + "कि" * 30, "क" + "कि" * 29), + ("???", str(FILENAME_OPERATION_ID)), + ], +) +def test_export_filename_names_the_operation_in_labelled_utc(operation_name: str, expected_name: str) -> None: + filename = finding_export._export_filename( + operation_id=FILENAME_OPERATION_ID, operation_name=operation_name, extension="docx", now=FILENAME_TIME + ) + + assert filename == f"copyrit-findings-{expected_name}-2026-10-22-1658-utc.docx" + + +async def test_export_encodes_a_non_ascii_filename_in_the_header( + sqlite_instance: SQLiteMemory, compatibility_headers: dict[str, str] +) -> None: + operation = await sqlite_instance.add_operation_async(Operation(name="Évaluation 中文")) + finding = await _add_finding_async(sqlite_instance, operation=operation, title="Unicode finding") + + response = _export( + headers=compatibility_headers, operation_id=operation.id, finding_ids=[finding.id], export_format="markdown" + ) + + assert response.status_code == 200 + assert re.fullmatch( + rf"attachment; filename\*=utf-8''copyrit-findings-%C3%A9valuation-%E4%B8%AD%E6%96%87-{UTC_STAMP}\.md", + response.headers["content-disposition"], + ) + + +@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(sqlite_instance, operation=other, title="Foreign finding") + before = await _saved_state_async(sqlite_instance, operation=operation) + finding_id = str(named["Included finding"].id) + body: dict[str, object] = {"finding_ids": [finding_id], "dashboard_base_url": DASHBOARD_BASE} + 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(sqlite_instance, operation=operation) == before + + +@pytest.mark.parametrize( + "dashboard_base_url", + [ + None, + "", + "/team/", + "pyrit.example.test", + "ftp://pyrit.example.test/", + "javascript:alert(1)", + "******pyrit.example.test/", + "https://pyrit.example.test/?next=/", + "https://pyrit.example.test/?", + "https://pyrit.example.test/#top", + "https://pyrit.example.test/#", + ], +) +def test_export_rejects_invalid_dashboard_base( + compatibility_headers: dict[str, str], dashboard_base_url: str | None +) -> None: + body: dict[str, object] = {"finding_ids": [str(uuid4())]} + if dashboard_base_url is not None: + body["dashboard_base_url"] = dashboard_base_url + + 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( + ("export_format", "failing"), + [ + ("docx", "get_findings_async"), + ("markdown", "get_attack_results_async"), + ("docx", "_render_docx"), + ("markdown", "_render_markdown"), + ], +) +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 + owner = finding_export if failing.startswith("_render") else 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_across_pages_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(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=( + _ReportFinding( + title="Critical finding", + severity=_ReportField(label="Severity", value="Critical"), + fields=( + _ReportField(label="Harm-type", value="Profanity", column="assessment"), + _ReportField(label="Description", value="Synthetic description\nsecond line"), + ), + evidence=( + _ReportEvidence( + title="Restricted-marker test", + summary=(_ReportField(label="Outcome", value="Undetermined"), NOT_SCORED), + details=(), + link_url=_dashboard_link(evidence=evidence, conversation_id="main"), + ), + ), + ), + _ReportFinding( + title="Included finding", + severity=_ReportField(label="Severity", value="Moderate"), + fields=(), + evidence=(), + ), + _ReportFinding( + title="Low finding", severity=_ReportField(label="Severity", value="Low"), fields=(), evidence=() + ), + ), + ) + + +async def test_report_follows_dashboard_listing_order(sqlite_instance: SQLiteMemory) -> None: + operation = await sqlite_instance.add_operation_async(Operation(name="Listing order")) + findings = [ + await _add_finding_async( + sqlite_instance, operation=operation, title=f"{severity.capitalize()} finding", severity=severity + ) + for severity in ["critical", "moderate", "low"] + ] + list_findings_async = OperationService.list_findings_async + + async def _reversed_listing_async( + service: OperationService, *, operation_id: UUID, limit: int, offset: int + ) -> FindingListResponse: + page = await list_findings_async(service, operation_id=operation_id, limit=limit, offset=offset) + return page.model_copy(update={"items": page.items[::-1]}) + + with patch.object(OperationService, "list_findings_async", autospec=True, side_effect=_reversed_listing_async): + report = await _report_async(operation_id=operation.id, finding_ids=[finding.id for finding in findings]) + + assert [finding.title for finding in report.findings] == ["Low finding", "Moderate finding", "Critical finding"] + + +async def test_report_includes_all_evidence_pages(sqlite_instance: SQLiteMemory) -> None: + operation = await sqlite_instance.add_operation_async(Operation(name="Evidence pages")) + finding = await _add_finding_async(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(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) == { + _ReportEvidence( + title=f"Objective {index:03d}", + summary=(_ReportField(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(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("main", "SelfAskTrueFalseScorer", "true") + human = make_score(ODD_CONVERSATION_ID, "ManualScorer", "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(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 == ( + _ReportFinding( + title="Scored finding", + severity=_ReportField(label="Severity", value="Moderate"), + fields=(), + evidence=( + _ReportEvidence( + title="Scored objective", + summary=( + _ReportField(label="Outcome", value="Failure"), + _ReportField(label="Score", value="False (human review)"), + ), + details=( + _ReportField(label="Rationale", value="ManualScorer rationale"), + _ReportField(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(sqlite_instance, operation=operation, title="Undecided finding") + attack = await _add_attack_async(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(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 == ( + _ReportEvidence( + title="Undecided objective", + summary=( + _ReportField(label="Outcome", value="Undetermined"), + _ReportField(label="Score", value="Undetermined (SelfAskTrueFalseScorer)"), + ), + details=(_ReportField(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(sqlite_instance, operation=operation, title="Unavailable finding") + missing_attack_id = uuid4() + await _link_async(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(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) == { + _ReportEvidence(title=str(missing_attack_id), summary=(), details=(), link_url=None), + _ReportEvidence( + title="Messageless objective", + summary=(_ReportField(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( + 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( + sqlite_instance, operation=operation, title="Plain finding", severity="informational", description="Only text" + ) + attack = await _add_attack_async(sqlite_instance, conversation_id="main", objective=" ") + evidence = await _link_async(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 == ( + _ReportFinding( + title="Plain finding", + severity=_ReportField(label="Severity", value="Informational"), + fields=(_ReportField(label="Description", value="Only text"),), + evidence=(), + ), + _ReportFinding( + title="Custom finding", + severity=_ReportField(label="Severity", value="Team severity"), + fields=(_ReportField(label="Harm-type", value="Team harm", column="assessment"),), + evidence=( + _ReportEvidence( + title="No objective", + summary=(_ReportField(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=( + _ReportFinding( + title="First finding", + severity=_ReportField(label="Severity", value="Critical"), + fields=( + _ReportField(label="Harm-type", value="Synthetic harm", column="assessment"), + _ReportField(label="Description", value="Synthetic description"), + ), + evidence=( + _ReportEvidence( + title="Linked objective", + summary=( + _ReportField(label="Outcome", value="Success"), + _ReportField(label="Score", value="True (SubStringScorer)"), + ), + details=(_ReportField(label="Rationale", value="Synthetic rationale"),), + link_url=LINK, + ), + _ReportEvidence( + title="Unscored objective", + summary=(_ReportField(label="Outcome", value="Failure"), NOT_SCORED), + details=(_ReportField(label="From operation", value="Synthetic source"),), + link_url=LINK, + ), + _ReportEvidence(title="attack-404", summary=(), details=(), link_url=None), + ), + ), + _ReportFinding( + title="Second finding", severity=_ReportField(label="Severity", value="Low"), fields=(), evidence=() + ), + ), +) + + +def test_docx_opens_as_a_current_word_document() -> None: + content = _render_docx(_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") + styles = package.read("word/styles.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"}] + # Latin fonts and text colors are explicit rather than taken from the template's 2007-era theme. + assert re.search(r"w:(ascii|hAnsi)Theme=", styles) is None + assert re.search(r'w:ascii="Calibri"', styles) is not None + title_style = document.styles["Heading 1"].font + assert (str(title_style.color.rgb), title_style.size.pt if title_style.size else None, title_style.bold) == ( + "2F5496", + 16, + False, + ) + assert re.search(r"]*w:themeColor=", styles) is None + + +def test_docx_lists_every_finding_in_one_finding_and_assessment_table() -> None: + content = _render_docx(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 + table = document.tables[0] + assert table.style is not None and table.style.name == "Table Grid" + header = table.rows[0] + assert [cell.text for cell in header.cells] == ["Finding", "Assessment"] + assert all(run.bold for cell in header.cells for run in cell.paragraphs[0].runs) + # The header appears once, at the top of the table, rather than repeating on later pages. + assert header._tr.trPr is None or header._tr.trPr.find(qn("w:tblHeader")) is None + 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 _bold_paragraph(content, "First finding") + assert _bold_paragraph(content, "attack-404") + assert _links(content, "docx") == [("Linked objective", LINK), ("Unscored objective", LINK)] + # Each entry starts with a bullet, and its lines sit indented under the bullet's text. + evidence_paragraphs = table.rows[1].cells[0].paragraphs[3:] + styles = [paragraph.style.name if paragraph.style else None for paragraph in evidence_paragraphs] + assert styles == ["List Bullet", "Normal", "Normal", "List Bullet", "Normal", "Normal", "List Bullet", "Normal"] + indents = [paragraph.paragraph_format.left_indent for paragraph in evidence_paragraphs] + assert [indent.pt if indent is not None else None for indent in indents] == [ + None, + 18, + 18, + None, + 18, + 18, + None, + 18, + ] + + +def test_markdown_reads_like_the_word_table_top_to_bottom() -> None: + markdown = _render_markdown(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", + "", + ] + + +def test_renderers_keep_arbitrary_projected_fields() -> None: + evidence = _ReportEvidence( + title="Source text", + summary=(_ReportField(label="Synthetic evidence field", value="evidence value"),), + details=(_ReportField(label="Synthetic evidence notes", value="note one\nnote two"),), + link_url=None, + ) + report = _FindingReport( + operation_name="Synthetic evaluation", + findings=( + _ReportFinding( + title="Configured finding", + severity=_ReportField(label="Synthetic severity", value="Level 3"), + fields=( + _ReportField(label="Synthetic custom field", value="line one\nline two"), + _ReportField(label="Synthetic narrative", value="part one\npart two"), + _ReportField(label="Synthetic class", value="Class B", column="assessment"), + ), + evidence=(evidence,), + ), + ), + ) + + content = _render_docx(report, now=FILENAME_TIME) + markdown = _render_markdown(report) + + # 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"], + ) + ] + assert _bold_paragraph(content, "Source text") + # A value that spans lines is fenced rather than flattened, inside an evidence bullet too. + assert markdown.split("\n") == [ + "# Findings - Synthetic evaluation", + "", + EVIDENCE_NOTE, + "", + "## Configured finding", + "", + "**Synthetic severity:** Level 3", + "", + "**Synthetic class:** Class B", + "", + "**Synthetic custom field**", + "", + "```text", + "line one", + "line two", + "```", + "", + "**Synthetic narrative**", + "", + "```text", + "part one", + "part two", + "```", + "", + "**Evidence:**", + "", + "- **Source text**", + " - **Synthetic evidence field:** evidence value", + " - **Synthetic evidence notes**", + "", + " ```text", + " note one", + " note two", + " ```", + " - Evidence unavailable", + "", + ] + + +def test_markdown_fences_literal_body_and_escapes_inline_values() -> None: + link = "https://pyrit.example.test/team(a)/attacks/a/conversations/b?findingEvidenceId=c" + evidence = _ReportEvidence( + title=HOSTILE_BODY, + summary=( + _ReportField(label="Outcome", value="Success\n"), + _ReportField(label="Score", value="True [l](x)"), + ), + details=(_ReportField(label="Rationale", value="`````\n"),), + link_url=link, + ) + report = _FindingReport( + operation_name="Ops bold\n# Forged operation", + findings=( + _ReportFinding( + title="First `code`\n## 9. Forged finding ![i](https://evil.example/i.png) [l](x)", + severity=_ReportField(label="Severity", value="Custom & ~~struck~~ | $x$ # Forged severity"), + fields=(_ReportField(label="Description", value=HOSTILE_BODY),), + evidence=(evidence,), + ), + _ReportFinding( + title="Second Ünïcødé 😀 中文 finding", + severity=_ReportField(label="Severity", value="Low"), + fields=(), + evidence=(), + ), + ), + ) + + markdown = _render_markdown(report) + + outside, bodies = _split_fenced_blocks(markdown) + assert markdown.endswith("\n") + # Values that span lines are fenced, even inside an evidence bullet. + assert bodies == [HOSTILE_BODY, "Success\n", "`````\n"] + headings = [line for line in outside if line.startswith("#")] + assert headings == [ + 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 outside + assert r" - **Score:** True \ \[l\](x)" in outside + assert "**Evidence:** none attached" in outside + # 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 outside if line.startswith("- [") and line.endswith(f"](<{link}>)")] + assert len(link_lines) == 1 + link_text = link_lines[0][len("- [") : -len(f"](<{link}>)")] + assert "\n" not in link_text and re.search(r"(?\[\]`]", link_text) is None + # Authored text never opens HTML, a link, an image, or code outside a literal block. + assert [line for line in outside if line not in link_lines and re.search(r"(?\[\]`]", line)] == [] + + +@pytest.mark.parametrize( + ("dashboard_base_url", "expected_base"), + [ + (DASHBOARD_BASE, "https://pyrit.example.test/team(a)"), + ("https://pyrit.example.test/team(a)", "https://pyrit.example.test/team(a)"), + ("http://localhost:3073", "http://localhost:3073"), + ], +) +def test_renderers_preserve_prefixed_evidence_links(dashboard_base_url: str, expected_base: str) -> None: + request = FindingExportRequest.model_validate({"finding_ids": [uuid4()], "dashboard_base_url": dashboard_base_url}) + scenario_result_id = uuid4() + item = FindingEvidenceItem( + item=FindingEvidence(finding_id=uuid4(), attack_result_id=uuid4()), + availability=EvidenceAvailability.AVAILABLE, + scenario_result_id=scenario_result_id, + objective="Linked objective", + outcome=AttackOutcome.SUCCESS, + conversation_id=ODD_CONVERSATION_ID, + ) + expected = ( + f"{expected_base}/attacks/{item.item.attack_result_id}/conversations/{ENCODED_CONVERSATION_ID}" + f"?scenarioResultId={scenario_result_id}&findingEvidenceId={item.item.id}" + ) + + evidence = _project_evidence(item=item, operation_id=uuid4(), dashboard_base_url=request.dashboard_base_url) + report = _single_finding_report(fields=(), evidence=(evidence,)) + + assert evidence.link_url == expected + assert _links(_render_docx(report, now=FILENAME_TIME), "docx") == [("Linked objective", expected)] + assert _links(_render_markdown(report).encode("utf-8"), "markdown") == [("Linked objective", expected)] + + +def test_docx_keeps_literal_text_and_marks_characters_word_cannot_store() -> None: + report = _single_finding_report( + fields=( + _ReportField(label="Description", value=HOSTILE_BODY), + _ReportField(label="Synthetic control text", value="bell\x07 escape\x1b\r\nnext line"), + ), + evidence=(), + ) + + paragraphs = _paragraphs(_render_docx(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_renderers_break_lines_and_mark_control_characters_alike() -> None: + report = _single_finding_report( + fields=( + _ReportField(label="Breaks", value="crlf\r\nvt\vff\ffs\x1cnel\x85ls\u2028ps\u2029end"), + _ReportField(label="Controls", value="c1\x80 c1\x9f del\x7f nul\x00 tab\tend"), + ), + evidence=(), + ) + + paragraphs = _paragraphs(_render_docx(report, now=FILENAME_TIME)) + markdown = _render_markdown(report) + + 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 + assert "\n**Breaks**\n\n```text\ncrlf\nvt\nff\nfs\nnel\nls\nps\nend\n```\n" in markdown + assert "\n**Controls:** c1\ufffd c1\ufffd del\ufffd nul\ufffd tab\tend\n" in markdown