Repository navigation
FEAT: Backend APIs for scan results and running scan attacks (part 1) - #4030
Richard Lundeen (richlundeen) wants to merge 8 commits into
Conversation
- GET /api/attacks/{id}/findings lists findings linked to an attack across Operations,
backed by a new FindingEvidenceEntries.attack_result_id index (Alembic b5d7f9a1c3e6).
- Attack read APIs (summary, metadata, conversations, messages) resolve scan attacks that
are still running through a read-only LiveAttackLookup provided by ScenarioRunService.
Saved results always win; summaries/metadata expose in_progress and scenario_result_id.
- ScenarioActiveConversation.conversation_ids lists every objective conversation so far.
- ScenarioProgressScore carries score_category for progress results and hierarchy nodes.
- Frontend types and attacksApi.getAttackFindings for later layers.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues were identified for the backend and API contract changes.
No open issues 🎉
What changed in this PR
Adds the backend foundation for viewing running and completed scan attacks in Chat, with linked findings and richer score details. This layer updates API contracts without changing the UI.
Changes:
- Makes running scan attacks readable through a read-only lookup, with saved results taking precedence.
- Adds attack-linked findings retrieval and a supporting database index.
- Exposes scan provenance, objective conversation IDs, and score categories through backend and frontend contracts.
| File | Description |
|---|---|
tests/unit/memory/test_score_participation_migration.py |
Allows a newer migration while preserving the single-head check. |
tests/unit/memory/test_findings.py |
Tests cross-operation findings lookup and ordering. |
tests/unit/memory/test_finding_evidence_attack_index_migration.py |
Tests index migration, rollback, and SQL Server compilation. |
tests/unit/memory/memory_interface/test_interface_scenario_progress.py |
Verifies score categories in progress results. |
tests/unit/backend/test_scenario_resume.py |
Tests live attack reads transitioning to saved results. |
tests/unit/backend/test_live_attack_reads.py |
Covers live lookup, read APIs, and linked findings. |
pyrit/models/scenario_progress.py |
Adds score categories and objective conversation IDs. |
pyrit/memory/memory_models.py |
Declares the attack-result evidence index. |
pyrit/memory/memory_interface.py |
Adds findings lookup, shared reference paging, and score categories. |
pyrit/memory/alembic/versions/b5d7f9a1c3e6_index_finding_evidence_attack_result.py |
Adds the evidence lookup index migration. |
pyrit/backend/services/scenario_run_service.py |
Provides live attack lookup and conversation IDs. |
pyrit/backend/services/saved_conversation_read_service.py |
Accepts an injectable conversation reference reader. |
pyrit/backend/services/operation_service.py |
Builds linked findings responses with operation names. |
pyrit/backend/services/live_attack_reader.py |
Defines the read-only live attack projection. |
pyrit/backend/services/attack_service.py |
Resolves live attacks when no saved result exists. |
pyrit/backend/routes/attacks.py |
Exposes the attack findings endpoint. |
pyrit/backend/README.md |
Documents live reads and linked findings contracts. |
pyrit/backend/models/operations.py |
Defines attack-linked findings response models. |
pyrit/backend/models/attacks.py |
Adds running-state and scan provenance fields. |
pyrit/backend/mappers/attack_mappers.py |
Maps running-state and provenance into summaries. |
frontend/src/types/index.ts |
Mirrors the expanded backend contracts. |
frontend/src/services/api.ts |
Adds the attack findings API client method. |
frontend/src/services/api.test.ts |
Tests findings requests and error propagation. |
Review effort: Balanced
Integrate main 25417ff without rewriting the existing stack history. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Limit running-state guarantees to attack summary and metadata reads without expanding conversation/message schemas or removing score details. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Integrate confirmed CI base c979a97 without rewriting either Alembic branch. The reproduced dual-head conflict is repaired in the following commit before publication. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Preserve both published revision histories and restore singular-head backend startup. Cover fresh, common-parent, either-branch and dual-head upgrades with preserved data, idempotency, downgrade/re-upgrade and SQLite/SQL Server merge semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Richard Lundeen (richlundeen)
left a comment
There was a problem hiding this comment.
Automated review completed. No blocking concerns remain. This review records source coverage; it does not authorize merge.
Integrate microsoft/PyRIT main at 8253694. Preserve live attack reads, findings, score provenance and both migration histories while adopting canonical public imports. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Behnam (behnam-o)
left a comment
There was a problem hiding this comment.
Approving; the three comments below are non-blocking. I didn't find any security issues: auth and runtime admission still apply, and writes still require a saved result.
Reviewed at d69f177. This was a static review; I didn't run the code or tests. The two failing checks don't look related to this change.
| def test_migration_branches_have_one_merge_head_without_rewriting_history() -> None: | ||
| scripts = ScriptDirectory.from_config(_config()) | ||
| assert scripts.get_heads() == [MERGE_REVISION] | ||
| assert set(scripts.get_revision(MERGE_REVISION).down_revision) == {REVISION, HTTP_REVISION} | ||
| assert scripts.get_revision(REVISION).down_revision == PREVIOUS_REVISION | ||
| assert scripts.get_revision(HTTP_REVISION).down_revision == PREVIOUS_REVISION |
There was a problem hiding this comment.
[LOW] These migration tests pin the global Alembic head
Line 46 asserts get_heads() == [MERGE_REVISION], and _assert_merged_schema (line 89) checks that the database is at MERGE_REVISION after an upgrade to head. So this test and every test that calls _assert_merged_schema will fail as soon as the next migration lands on main. Other migration tests only assert len(scripts.get_heads()) == 1 plus their own down_revision links (e.g. test_http_request_fingerprint_migration.py:25). Could these do the same?
There was a problem hiding this comment.
Fixed in 2f41ad9. These tests now require one current Alembic head without pinning its revision, while preserving their historical parent-link and data-preservation assertions. I also joined the finding-index and updated main heads with an additive no-DDL merge revision; startup upgrades from either head and both together pass.
Verified on published commit 2f41ad977bd36a6cd17eaa74c89ff41276f3651e.
| /** Findings in any Operation that link this attack as evidence; empty for an unsaved running attack. */ | ||
| getAttackFindings: async (attackResultId: string): Promise<AttackFindingListResponse> => { | ||
| const response = await apiClient.get(`/attacks/${encodeURIComponent(attackResultId)}/findings`) | ||
| return response.data | ||
| }, |
There was a problem hiding this comment.
[LOW] The findings endpoint and these frontend additions have no caller in this PR
Outside tests, nothing in this PR calls GET /api/attacks/{id}/findings or attacksApi.getAttackFindings. That covers the route, OperationService.list_attack_findings_async, MemoryInterface.get_findings_for_attack_result_async, the index migration b5d7f9a1c3e6 and the merge migration f8a6c2d4e901 it needs; the first real caller is AttackAttemptDetails in #4032. Likewise, the AttackFinding* types and the new optional fields (in_progress, scenario_result_id, score_category, conversation_ids) are only used by each other and their own tests until #4031 and #4032.
I recommend moving these changes to the PRs where they actually have a caller.
There was a problem hiding this comment.
I'm keeping this backend layer in 2f41ad9. The live-read path already benefits the existing Chat calls before a scan finishes, and microsoft/PyRIT#4032 has the concrete AttackAttemptDetails/useAttackFindings caller. The existing findings API is scoped to one Operation; it cannot replace an attack-wide lookup without first discovering every linked Operation. This keeps the query/index and API contract together rather than moving persistence work into the UI layer. No source change was made for this recommendation.
Verified on published commit 2f41ad977bd36a6cd17eaa74c89ff41276f3651e.
| Reads the saved AttackResult by its primary key, or projects a scan attack that is still running. | ||
|
|
||
| Returns: | ||
| AttackSummary if found, None otherwise. | ||
| """ | ||
| results = await self._memory.get_attack_results_async(attack_result_ids=[attack_result_id]) | ||
| if not results: | ||
| attack = await self._read_attack_async(attack_result_id=attack_result_id) | ||
| if attack is None: | ||
| return None | ||
|
|
||
| return await self._summarize_attack_async(ar=results[0]) | ||
| return await self._summarize_attack_async(ar=attack.result, in_progress=attack.in_progress) |
There was a problem hiding this comment.
[LOW] The saved-chat lookup now also returns running attacks
get_saved_conversation_async (line 577, unchanged) resolves its attack through get_attack_async, which now also projects running scan attacks. So GET /api/attacks/conversations/{id} returns 200 for a conversation whose attack is still running, although the backend README describes it as the saved-chat lookup. Saving that conversation from the scanner then fails with 422, because _get_save_source_async still requires a saved result. Could this lookup stay saved-only, or support live sources end to end with a README update and a test?
There was a problem hiding this comment.
Fixed in 2f41ad9. The saved-chat lookup now excludes in-progress attacks; the general attack, conversation and message read APIs still support them. The SQLite regression checks that saved-chat lookup is absent while running and works once the same attack has been saved.
Verified on published commit 2f41ad977bd36a6cd17eaa74c89ff41276f3651e.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Description
Layer 1 of 3 for the scan results redesign: backend APIs so Chat can be the single conversation view for both finished and still-running scan attacks, and so scan results can show linked findings and score details without fabricating data.
GET /api/attacks/{id},/metadata,/conversations, and/messages(paged and unpaged) now resolve an attack that an active scan is still running, instead of 404. Summary and metadata responses setin_progress: true, outcomeundetermined, andexecution_read_only: true; conversation/message responses do not carry the running-state flags. The current conversation is main; other objective conversations so far are pruned references. A saved result always wins, so the same IDs resolve to the saved result once the attack finishes.AttackServicereads through a small read-onlyLiveAttackLookupport (pyrit/backend/services/live_attack_reader.py) thatScenarioRunService.find_live_attackprovides; the singleton factory wires it. The saved-conversation reader takes an injectable reference source, so live and saved paging share one code path, andAttackConversationPage.from_referencesowns the reference ordering/revision for both.scenario_result_id(the owning scan, fromattribution_parent_id).GET /api/attacks/{id}/findingslists findings across Operations that link the attack (finding +operation_name+evidence_id), backed byMemoryInterface.get_findings_for_attack_result_asyncand a newix_FindingEvidenceEntries_attack_result_idindex (Alembicb5d7f9a1c3e6, a plain UUID-key index valid on SQLite and SQL Server).ScenarioActiveConversation.conversation_idslists every objective conversation so far, alongsideconversation_id.ScenarioProgressScore.score_categoryadds the missing category. Official scorer evaluation metrics remain available separately underprogress.summary.objective_scorer.metrics.attacksApi.getAttackFindings; no UI changes in this layer.Stack: this PR (backend APIs) → #4031 (frontend foundations) → #4032 (scan results redesign + live Chat).
API walkthrough
These are illustrative, selected-field contract fragments, not captured production responses or measured scorer performance. UUID placeholders identify related records.
GET /api/attacks/{id}/metadataGET /api/attacks/{id}/conversations?limit=100and/messages?conversation_id={cid}&limit=100conversation_idand all objectiveconversation_ids.GET /api/attacks/{id}/findingsoperation_nameand theevidence_idused to detach the link. Results are ordered by severity, then newest; the migration indexes the attack-result evidence lookup on SQLite and SQL Server.progress.results[].scoreand hierarchy nodescoreLive metadata → saved result, without changing the attack URL
{ "attack_result_id": "<attack UUID>", "scenario_result_id": "<scan UUID>", "in_progress": true, "outcome": "undetermined", "execution_read_only": true }Once the saved result exists it wins over live lookup. Writes still require a saved result. Before the first objective conversation, the read remains 404. Read running-state flags from summary/metadata, not conversation/message responses. Restart reference paging if the live-to-saved transition changes its revision.
Findings identify both the finding and its evidence link
{ "attack_result_id": "<attack UUID>", "items": [{ "id": "<finding UUID>", "operation_id": "<Operation UUID>", "operation_name": "<current Operation name>", "evidence_id": "<evidence-link UUID>", "title": "<stored finding title>", "severity": "<stored severity>" }] }No linked findings, including for an unsaved attack, yields
items: []; a non-UUID attack ID returns 422.Attack scores are not scorer evaluation-quality metrics
A per-attack score fragment, using a unit-test fixture rather than a measured evaluation:
{ "scorer_name": "TestScorer", "score_type": "true_false", "status": "complete", "score_value": "true", "score_rationale": "The objective was achieved.", "score_category": ["jailbreak"] }That is one attack's score, not the scorer's accuracy or the scan's success rate. Existing official evaluation metrics remain at
progress.summary.objective_scorer.metrics: accuracy, accuracy standard error, F1, precision, recall, and average score time. They are loaded only for matching evaluation identity; no matching evaluation means null metrics, not invented zeros. The scorer registry API already exposesmetricsand a separateevaluation_identifierfor comparison/disclosure in the frontend layers. This backend layer removes no inspected score or official-evaluation fields.Before and after
There is no UI change in this layer, but the API change is visible in the existing Chat page. Before is
main; after is this PR. Both open the same attack while a scan is still running it, from production builds against the same disposable backend (in-memory database, offline echo target that answers after 20 s, substring objective scorer; no providers).Layer 3 (#4032) adds live refresh and the in-progress bar on top of these reads.