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>

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.

2 participants