Skip to content

FEAT: Export Operation findings to Word and Markdown - #3493

Open
Adrian Gavrila (adrian-gavrila) wants to merge 3 commits into
microsoft:mainfrom
adrian-gavrila:adrian-gavrila-findings-export
Open

Adrian Gavrila (adrian-gavrila) wants to merge 3 commits into
microsoft:mainfrom
adrian-gavrila:adrian-gavrila-findings-export

Conversation

@adrian-gavrila

Copy link
Copy Markdown
Contributor

Description

Follow-up to #3066 and #3275. An Operation can hold findings with linked attack evidence, but putting them into a report meant copying each one by hand. Export findings on the Operation page downloads the findings you select as one Word or Markdown document, ready to paste into a larger report.

What changed

  • Export dialog. The Operation page gets an Export findings button. Its dialog loads every finding in the Operation, not only the 20 on the current page, and starts with all of them selected. Select all selects or clears the list and shows a mixed state for a subset. Word is the default format and Markdown the alternative. A warning says that findings, objectives and rationales can contain sensitive content. The file is named after the Operation and the user's local time, for example copyrit-findings-synthetic-evaluation-2026-10-22-1258.docx.
  • REST. POST /api/operations/{id}/findings/export takes finding_ids, format (docx by default, or markdown) and dashboard_base_url, and returns the document as an attachment. Empty or duplicate IDs, unknown fields, and a base URL that isn't plain HTTP(S) or has credentials, a query or a fragment return 422. A missing Operation, or a finding that is missing or belongs to another Operation, returns 404. If rendering fails, the response is a 500 with no partial file. The export only reads; there's no schema change or migration.
  • Word. One table with Finding and Assessment columns, so rows can be copied into an existing report table. Finding holds the title, description and evidence; Assessment holds severity and harm type. The header row appears once, at the top. The file opens outside Word's Compatibility Mode, uses explicit Latin fonts instead of the template's 2007 theme, and carries no template author or thumbnail.
  • Markdown. The same content from top to bottom, without a table. One-line values are escaped and values that span lines are fenced as literal text, so pasted text can't form headings, links, images or HTML. Control characters show as � in both formats, and every line-break convention becomes a line break.
  • Evidence. 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 score (human review stands in for ManualScorer), the rationale, and From operation when the attack belongs to a different Operation. A deleted attack keeps its entry under its attack ID and reads Evidence unavailable. Messages, attachments and media are never included. Links are built from the dashboard's own address, so they point at the deployment the export came from.
  • Order. Findings follow the dashboard's order (most severe first, newest first within a level) because the export pages through OperationService.list_findings_async. The order of IDs in the request doesn't change it.
  • Smaller changes. The chat's download code moved into a shared downloadFile helper. The Axios response interceptor reads error bodies from blob responses before its compatibility and 401 checks, so a failed download shows the server's message. The Operation header buttons (Edit, Delete, Export findings, New finding) have icons.
Markdown export of the same seven synthetic findings
# Findings - Synthetic evaluation

Evidence is listed as references with links to the PyRIT dashboard; transcripts are not included.

## Synthetic guardrail bypass confirmed by review

**Severity:** Critical

**Harm-type:** Inaccurate, Incomplete, False \& Misleading Information

**Description:** A reviewer confirmed what the automated scorer missed.

**Evidence:**

- [Synthetic ambiguous probe](<https://pyrit.example.test/attacks/ef75aaad-7ea5-4255-acf8-4d037f996bef/conversations/trial-undetermined?findingEvidenceId=f110c9bc-8ebb-4716-a1b2-b016be5d3d8d>)
  - **Outcome:** Undetermined · **Score:** Undetermined (SelfAskTrueFalseScorer)
  - **Rationale:** The scorer could not decide.
- [Synthetic guardrail test](<https://pyrit.example.test/attacks/1acee209-7fe0-4fb9-9951-bbbd33c2ae08/conversations/trial-reviewed?findingEvidenceId=5b364c3f-3800-4081-a601-f7e9537828b2>)
  - **Outcome:** Success · **Score:** True (human review)
  - **Rationale:** A reviewer confirmed the synthetic bypass.
  - **From operation:** Synthetic source

## Restricted test marker disclosed

**Severity:** Important

**Harm-type:** Proprietary, Confidential \& Classified Information

**Description**

```text
The assistant returned a synthetic marker that the test policy instructed
it to withhold.
```

**Evidence:**

- [Restricted-marker test](<https://pyrit.example.test/attacks/1a5c4c6d-78d8-4fb2-bb62-37d6553af471/conversations/trial-marker?findingEvidenceId=c419af1d-c294-427e-abfb-5d1f1862edd1>)
  - **Outcome:** Success · **Score:** True (SubStringScorer)
  - **Rationale:** The response contained the restricted marker.

## Literal text example sharing the marker evidence

**Severity:** Moderate

**Description**

````text
Ünïcødé 😀 中文 stays as written.
```
# Not a heading
<b>Not bold</b> [not a link](https://example.test) ![not an image](x.png)
Last line.
````

**Evidence:**

- [Restricted-marker test](<https://pyrit.example.test/attacks/1a5c4c6d-78d8-4fb2-bb62-37d6553af471/conversations/trial-marker?findingEvidenceId=4c3c1cbe-b276-4a2b-8d63-242ea572ca75>)
  - **Outcome:** Success · **Score:** True (SubStringScorer)
  - **Rationale:** The response contained the restricted marker.

## Unscored synthetic response

**Severity:** Moderate

**Evidence:**

- [Synthetic unscored probe](<https://pyrit.example.test/attacks/194c0cbb-8f04-4e51-b3f7-6580f7b74da3/conversations/trial-unscored?findingEvidenceId=bfb6b7e9-cd96-412e-8118-b19acc19561f>)
  - **Outcome:** Undetermined · **Score:** Not scored
  - **From operation:** Not in an operation

## Synthetic attack later removed

**Severity:** Low

**Evidence:**

- **e6e8c371-1473-445c-952d-76b05ed3bd9e**
  - Evidence unavailable

## Finding without evidence

**Severity:** Informational

**Evidence:** none attached

## Team-defined severity example

**Severity:** Needs triage

**Harm-type:** Synthetic custom harm

**Evidence:** none attached

How it fits together

flowchart LR
    A["Operation page:<br/>Export findings"] --> B["Dialog pages every finding;<br/>user picks findings and format"]
    B -->|"POST finding_ids, format,<br/>dashboard_base_url"| C["Export route"]
    C --> D["finding_export: selected findings and<br/>evidence through OperationService"]
    D --> E["Small report model"]
    E --> F["Word renderer"]
    E --> G["Markdown renderer"]
    F --> H["Attachment"]
    G --> H
    H --> I["Browser saves the file"]
Loading

Design choices and limits

  • No report templates. PyRIT doesn't read, fill or upload a team's report. The export is a findings section to paste into it.
  • Rendering stays in the backend, outside pyrit/output. The output printers return str, and a .docx is a ZIP file. A GUI download didn't need that contract widened.
  • Exported links depend on the dashboard route. The server builds evidence links the same way as the dashboard's attackConversationRoutePath. That function's JSDoc now says exported documents link there, and the seeded test opens one.
  • Two filenames. The browser saves under the local-time name. The server's Content-Disposition uses the same rule in UTC with a -utc suffix, for API clients and for cross-origin deployments that don't expose the header.
  • Known limits. Some Markdown viewers, GitHub included, turn a bare URL in a one-line value into a link. Raw Markdown shows escapes such as \&. Evidence is read finding by finding; tests cover 101 findings and 101 evidence items on one finding.

Follow-ups

  • Configurable finding fields, which is still in design, will replace _project_finding_fields. The renderers already take any list of labelled fields; that work also needs to decide how a field picks its Word column.
  • The severity badge colors aren't color-blind friendly. Important and Moderate share one color, and red against dark orange is hard to tell apart for red-green color blindness. That's a separate change.

Tests and Documentation

Backend tests cover the HTTP contract (attachment headers, Unicode filenames, 422, 404 and 500), the report built from SQLite (selection across the 100-row page, dashboard order, 101 evidence items, human and automated scores, undetermined and missing scores, deleted attacks, Other severity and harm type, cross-Operation evidence), and both renderers on whole documents (table layout, bullets, escaping and fencing, hostile text, links under a path prefix, control characters, Word document properties). Frontend tests cover the dialog (paging, selection, stale responses, focus, errors), blob error decoding, the download helper and the filename rule. A seeded Playwright test downloads both formats, checks the dialog at 390px (including that the warning fits its bar), and opens an exported link. Results on a0beb5543, based on 0f58aed71:

  • pytest -n 4 --dist=loadfile --cov=pyrit --cov-fail-under=78 tests/unit: 31,218 passed, 57 skipped, 96.23% coverage. This ran on 0c62fda60; a0beb5543 changes only frontend files.
  • diff-cover coverage.xml --compare-branch=0f58aed71 --fail-under=90: 99% of 329 changed lines. The one uncovered line is the _percent_encode fallback in finding_export.py, which no generated link reaches.
  • pre-commit run --all-files: passed.
  • npm run test:coverage: 3,654 passed.
  • npm run lint, npx tsc --noEmit, npm run build and npx playwright test --list: passed.
  • npx playwright test --project=seeded: 104 passed on 0c62fda60. On a0beb5543, operations.spec.ts and operations-acceptance.spec.ts passed (15 tests).
  • npx playwright test --project=mock: 180 passed, 22 failed, all in target registry, accessibility, routing and onboarding specs that this PR doesn't touch. With main's frontend, config.spec.ts fails 15 tests on this Windows machine, the same count as with this branch.
  • Not run: integration tests and anything against SQL Server. The export adds no memory methods and reads through OperationService.

Docs: doc/gui/0_gui.md adds an "Exporting findings" section under Operations. No notebooks changed, so JupyText wasn't needed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread pyrit/backend/services/finding_export.py Fixed
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment on lines +187 to +226
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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Load the selected findings and their evidence in batches

The PR description lists finding-by-finding reads as a known limit. These loaders cause two problems:

  • Speed: The loaders reuse the paged API methods. Each list_finding_evidence_async call checks the operation and the finding again. Then it reads the evidence, attacks, operation names, score pieces, and conversation statistics. A finding with evidence needs about seven sequential database calls. The dialog selects all findings by default, so an operation with 300 findings can need about 2,000 calls. The findings scan also calculates evidence counts and then discards them.
  • Skipped rows: Offset pages move when rows change. setdefault removes duplicate rows, but it cannot find skipped rows. For example, a finding has 101 evidence links. If someone removes one of the first 100 links between the two page reads, the 101st link moves to offset 99. The second page does not read it, and the export succeeds without it. In an operation with more than 100 findings, a delete or a severity change between page reads can cause a false "not found" error.

Please read the selected findings by ID with one query, and use the ORDER BY from get_findings_async to keep the dashboard order. Then read their evidence and attack context in batches. This fixes both problems, and the export stops scanning findings that the user did not select.

Review authored by GitHub Copilot.

Comment on lines +486 to +489
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)}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep multi-line text as prose in the Markdown export

Each value with a line break goes into a fenced code block. Descriptions come from a multi-line text box, and model-generated score rationales often contain line breaks. As a result, many descriptions and rationales show as monospace code. The dashboard and the Word export show this text as prose. In Markdown, the same field is prose when it has one line and code when it has two lines.

The PR description says that the fences stop pasted text from forming headings, links, images, or HTML. Escaping can give the same protection and keep the text as prose:

  • Escape each line with the same rules as _markdown_inline.
  • Also escape block syntax at the start of each line: list markers, a number followed by a period, = underlines, and leading spaces.
  • Join the lines with hard line breaks.

Users copy this export into reports, so prose is easier to use.

Review authored by GitHub Copilot.

return quote(value, safe="!*'()")


def _render_docx(report: _FindingReport, *, now: datetime) -> bytes:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Revisit keeping the renderers in the backend

The PR description says that the renderers stay here because output printers return str and a .docx file is a ZIP file. That is true for DOCX. But Markdown fits the current printer contract.

doc/code/framework.md gives rendering to the Output module and tells the backend to reuse framework components. With both renderers in a backend service, notebook and SDK users have no public way to make the same report. The move is small now, because the renderers depend only on _FindingReport.

Please keep data loading and HTTP handling in the backend. Move the Markdown renderer, and the small report model that it takes, to pyrit.output. DOCX needs a decision about binary output first, for example a renderer that returns bytes beside the printers.

Minor: _render_docx(report, *, now) has two parameters, so the style guide requires report to be keyword-only too.

Review authored by GitHub Copilot.

_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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Set the table column widths

python-docx divides the table width equally between the columns. The Finding column holds the title, description, and all evidence. The Assessment column holds at most two short fields. As a result, the long text wraps in half the page width, and most of the Assessment column stays empty. Please set explicit widths on the grid columns and the cells, for example 75% and 25%.

Review authored by GitHub Copilot.

Comment on lines +353 to +380
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Consider removing the theme and spacing rewrites

The docs say that users copy rows from this file into their report. By default, Word applies the destination document's styles when style names conflict, for example Normal and List Bullet. As a result, these rewrites mostly change the downloaded file, not the pasted rows. They also depend on the XML of the python-docx default template, and test_docx_opens_as_a_current_word_document locks in their fonts, colors, and sizes.

Please keep the compatibility-mode and metadata fixes, because they are small and useful. Remove these two rewrites, or use a small template .docx file if the downloaded file must look like a new Word document.

Review authored by GitHub Copilot.

Comment on lines +287 to +304
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="!*'()")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Simplify how the evidence links are built

The path-prefix support has no effect today. Vite sets no base, the router has no basename, and the backend serves the app at /. So import.meta.env.BASE_URL is always /, and the prefix tests in test_finding_export.py and FindingExportDialog.test.tsx test a deployment that the app does not support. Please send only the origin and remove those tests, or add base-path support to the app first.

Also, _conversation_link and _encode_segment copy attackConversationRoutePath and encodeURIComponent. The PR description accepts this dependency. But no unit test compares the two builders, and the seeded E2E test opens only one generated link. If the frontend route changes, new exports can contain broken links. Please add a test that keeps the two builders in step, or link to a stable route that the frontend owns, for example one that opens evidence by its ID.

Review authored by GitHub Copilot.

Comment on lines +140 to +158
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}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Keep the filename rules in one place

The PR description says that the server name is for API clients. No client in this repository reads it: operationsApi.exportFindings returns only the Blob, and the dialog builds its own name with findingExportFilename. So the same Unicode slug and truncation rules exist in Python and in TypeScript, each with its own tests, and the two copies can drift.

Please keep one copy. The dialog can use the server name: a same-origin deployment can read Content-Disposition, and a cross-origin deployment can expose it. Or the server can send a simple name for API clients, for example with the operation ID and the UTC time.

Review authored by GitHub Copilot.

Comment on lines +465 to +468
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Remove the unused summary branch and the Word layout assertions

As the PR description says, the generic report fields prepare for configurable finding fields. This branch is different. Outcome and Score are single-line values, so with real data only the first branch runs. Only test_markdown_fences_literal_body_and_escapes_inline_values reaches the else branch, with a synthetic multi-line Outcome. Please remove the branch until a real summary field needs it.

Also, test_docx_lists_every_finding_in_one_finding_and_assessment_table asserts paragraph styles and indents. These assertions fail after harmless layout changes. Please keep the Word tests on content, links, literal text, and document validity.

Review authored by GitHub Copilot.

Comment on lines +155 to +159
<li key={finding.id} className={styles.row}>
<Checkbox className={styles.checkbox} label={{ children: finding.title, className: styles.label }}
checked={selected.has(finding.id)} disabled={exporting}
onChange={(_, data) => { toggle(finding.id, data.checked === true) }} />
<FindingSeverityBadge finding={finding} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Help users tell similar findings apart

Titles do not have to be unique, and each row shows only the title and severity. So two findings with the same title and severity look the same in this list. A user cannot be sure which one they remove from a report that can contain sensitive content. Please show more context in each row, for example the first line of the description or the creation date.

Review authored by GitHub Copilot.

Comment on lines +125 to +126
} catch (cause: unknown) {
if (!controller.signal.aborted) setExportError(toApiError(cause).detail)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Make the error for a deleted finding actionable

If someone deletes a selected finding while this dialog is open, the export fails with Finding '<id>' not found in operation '<id>'. The message shows only IDs, so the user cannot tell which row to clear. The dialog keeps its list, so each new attempt fails the same way until the user closes and opens the dialog again. Please show a clear message, and reload the list or remove the missing finding from the selection.

Review authored by GitHub Copilot.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants