Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/code/framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ See [message normalizers](./targets/11_message_normalizer) for capability behavi

**Responsibility**: The Output module is responsible for writing different components in different formats to different places.

- It renders the core result types — attack results, scenario results, conversations, and scores — without those components needing to know how they are displayed.
- It renders the core result types — attack results, scenario results, conversations, scores, and finding reports — without those components needing to know how they are displayed.
- Format and destination are decoupled: a **format** (e.g. pretty ANSI, Markdown, JSON) is separate from a **sink** (stdout, file, Jupyter), so any result can be rendered any way to anywhere.
- **Does not own**: deciding *what* to render or *when*. Components hand results to output; format classes only turn data into strings and never fetch data, touch `CentralMemory`, or call `print()` directly (that's isolated to leaf printer classes).

Expand Down
1 change: 1 addition & 0 deletions doc/code/output/0_output.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,7 @@
"| `output_scorer_async` | Scorer info/metrics | `pretty`, `json` |\n",
"| `output_conversation_async` | Conversation history | `pretty`, `json` |\n",
"| `output_score_async` | Score list | `pretty`, `json` |\n",
"| `output_finding_report_async` | Finding report | `markdown` |\n",
"\n",
"All accept `format=` and `sink=` keyword arguments with sensible defaults."
]
Expand Down
1 change: 1 addition & 0 deletions doc/code/output/0_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@
# | `output_scorer_async` | Scorer info/metrics | `pretty`, `json` |
# | `output_conversation_async` | Conversation history | `pretty`, `json` |
# | `output_score_async` | Score list | `pretty`, `json` |
# | `output_finding_report_async` | Finding report | `markdown` |
#
# All accept `format=` and `sink=` keyword arguments with sensible defaults.

Expand Down
5 changes: 5 additions & 0 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ Select an operation in the labels bar to associate new chats and scans with it.
The operation page brings its findings, linked evidence, and execution history
together.

**Export findings** on the operation page downloads the findings you select as
one Word or Markdown document to paste into a report. Evidence appears as links
to the attacks in PyRIT, without transcripts or media. Nothing is redacted, so
store and share exported files with care.

![Operations page with a test operation](images/operations.png)

## Configuration
Expand Down
160 changes: 159 additions & 1 deletion frontend/e2e/operations.spec.ts
Original file line number Diff line number Diff line change
@@ -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]) {
Expand Down Expand Up @@ -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\n<b>kept literal</b>\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<string, Finding>()
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<void> => {
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<void> => {
await selectAll.click()
await expect(list.getByRole('checkbox', { checked: true })).toHaveCount(0)
for (const title of selectedTitles) {
await list.getByRole('checkbox', { name: title, exact: true }).check()
}
await expect(selectAll).toBeChecked({ indeterminate: true })
}
const download = async (
button: string, expectedFormat: FindingExportFormat,
): Promise<{ filename: string; content: Buffer }> => {
const exportRequest = page.waitForRequest(sent =>
sent.url().endsWith(`/api/operations/${operation.id}/findings/export`) && sent.method() === 'POST')
const downloaded = page.waitForEvent('download')
await dialog.getByRole('button', { name: button, exact: true }).click()
expect((await exportRequest).postDataJSON()).toEqual({
finding_ids: selectedTitles.map(findingId), format: expectedFormat, dashboard_origin: origin,
})
const file = await downloaded
await expect(dialog).toHaveCount(0)
await expect(trigger).toBeFocused()
return { filename: file.suggestedFilename(), content: readFileSync(await file.path()) }
}

await openWithEverySelected()
await expect(list.getByRole('checkbox').first()).toHaveAccessibleName('Export critical')
await expect(list.getByRole('checkbox').last()).toHaveAccessibleName(longTitle)
await selectAll.click()
await expect(list.getByRole('checkbox', { checked: true })).toHaveCount(0)
await expect(selectAll).not.toBeChecked()
await expect(dialog.getByRole('button', { name: 'Download Word', exact: true })).toBeDisabled()
await selectAll.click()
await expect(list.getByRole('checkbox', { checked: true })).toHaveCount(seeds.length)
await selectSubset()
await expect(format).toHaveValue('docx')
await expect(format.locator('option:checked')).toHaveText('Word (.docx)')
const word = await download('Download Word', 'docx')
// Named after the Operation and the browser's local time.
const filenameStart = `^copyrit-findings-${operation.name.toLowerCase().replace(/ /g, '-')}-\\d{4}-\\d{2}-\\d{2}-\\d{4}`
expect(word.filename).toMatch(new RegExp(`${filenameStart}\\.docx$`))
expect(word.content.subarray(0, 4).toString('latin1')).toBe('PK\x03\x04')
expect(word.content.includes('word/document.xml')).toBeTruthy()

// Reopening starts again from the whole Operation.
await openWithEverySelected()
await selectSubset()
await format.selectOption({ label: 'Markdown (.md)' })
await expect(list.getByRole('checkbox', { checked: true })).toHaveCount(selectedTitles.length)
const markdownFile = await download('Download Markdown', 'markdown')
expect(markdownFile.filename).toMatch(new RegExp(`${filenameStart}\\.md$`))
const markdown = markdownFile.content.toString('utf8')
expect(markdown.startsWith(`# Findings - ${operation.name}\n\n`)).toBeTruthy()
expect(markdown.match(/^## .*$/gm)).toEqual(selectedTitles.map(title => `## ${title}`))
for (const title of excludedTitles) {
expect(markdown).not.toContain(title)
}
expect(markdown).toContain(`**Description:** ${description.split('\n')[0]}\\\n`)
const link = `${origin}/attacks/${attack.attack_result_id}/conversations/${attack.conversation_id}` +
`?findingEvidenceId=${evidence.item.id}`
// As in Word, the evidence bullet's objective is the link text.
expect(markdown.split('\n').filter(line => line.startsWith('- [') && line.endsWith(`](<${link}>)`))).toHaveLength(1)

await page.setViewportSize({ width: 390, height: 844 })
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBeTruthy()
await openWithEverySelected()
const content = dialog.locator('.fui-DialogContent')
expect(await content.evaluate(element => element.scrollHeight > element.clientHeight
&& ['auto', 'scroll'].includes(getComputedStyle(element).overflowY))).toBeTruthy()
expect(await content.evaluate(element => element.scrollWidth <= element.clientWidth)).toBeTruthy()
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBeTruthy()
const longLabel = dialog.locator('label').filter({ hasText: longTitle })
expect(await longLabel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBeTruthy()
const longBox = await longLabel.boundingBox()
const shortBox = await dialog.locator('label').filter({ hasText: /^Export low$/ }).boundingBox()
if (!longBox || !shortBox) throw new Error('Expected visible finding labels.')
expect(longBox.height).toBeGreaterThan(2 * shortBox.height)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBeTruthy()
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(dialog).toHaveCount(0)
await expect(trigger).toBeFocused()

await page.goto(link)
await expect(page.getByText('Saved offline evidence.', { exact: true })).toBeVisible()
})
22 changes: 22 additions & 0 deletions frontend/src/components/Operations/FindingExportDialog.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { makeStyles, tokens } from '@fluentui/react-components'

import { mobileTouchTargetHeight, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets'

// Fluent's medium Checkbox indicator. With its inline margins and the label's padding, a row's title starts after it.
const CHECKBOX_INDICATOR_SIZE = '16px'
const TITLE_INDENT = `calc(${tokens.spacingHorizontalS} * 2 + ${CHECKBOX_INDICATOR_SIZE} + ${tokens.spacingHorizontalXS})`

export const useFindingExportDialogStyles = makeStyles({
content: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM },
messageBar: { flexShrink: 0 },
list: { margin: 0, padding: 0, listStyleType: 'none' },
row: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', columnGap: tokens.spacingHorizontalS, minWidth: 0 },
checkbox: { flex: '1 1 12rem', minWidth: 0 },
label: { minWidth: 0, overflowWrap: 'anywhere' },
created: {
color: tokens.colorNeutralForeground3,
// On narrow screens the time gets its own line, lined up under the title.
[NARROW_VIEWPORT_QUERY]: { flexBasis: '100%', paddingInlineStart: TITLE_INDENT },
},
button: { ...mobileTouchTargetHeight },
})
Loading
Loading