Skip to content
Open
24 changes: 23 additions & 1 deletion frontend/src/services/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ import {
import { compatibility, COMPATIBILITY_HEADER } from "./compatibility";
import { toApiError } from "./errors";
import { makeAnalyticsFacets, makeAnalyticsReport, makeAnalyticsResults } from "@/test-utils/analyticsFixtures";
import type { AttackFindingListResponse, FindingExportRequest } from "@/types";
import { makeTarget } from "@/test-utils/targetFixtures";
import type { FindingExportRequest } from "@/types";
import { DEFAULT_ANALYTICS_VIEW } from "@/utils/attackAnalytics";

describe("api service", () => {
Expand Down Expand Up @@ -868,6 +868,28 @@ describe("api service", () => {
});
});

it("should get the findings linked to an attack", async () => {
const data: AttackFindingListResponse = {
attack_result_id: "attack/id",
items: [{
id: "finding", operation_id: "operation", operation_name: "Red team", evidence_id: "evidence",
title: "Protected system instructions disclosed", description: "", severity: "important",
created_at: "2026-11-06T00:00:00Z",
}],
};
(apiClient.get as jest.Mock).mockResolvedValueOnce({ data });

expect(await attacksApi.getAttackFindings("attack/id")).toEqual(data);
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(apiClient.get).toHaveBeenCalledWith("/attacks/attack%2Fid/findings");
});

it("should propagate attack finding errors", async () => {
const error = new Error("Findings unavailable");
(apiClient.get as jest.Mock).mockRejectedValueOnce(error);
await expect(attacksApi.getAttackFindings("attack")).rejects.toBe(error);
});

it("should get attack messages", async () => {
const mockResponse = {
data: {
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getGraphScopes } from '../auth/msalConfig'
import type {
ApplyChatScorerRequest,
ApplyChatScorerResponse,
AttackFindingListResponse,
TargetInstance,
UnregisteredTarget,
UnregisteredConverter,
Expand Down Expand Up @@ -521,6 +522,12 @@ export const attacksApi = {
return response.data
},

/** 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
},
Comment thread
richlundeen marked this conversation as resolved.
Resolved

updateAttack: async (attackResultId: string, request: UpdateAttackRequest): Promise<AttackSummary> => {
const response = await apiClient.patch(`/attacks/${encodeURIComponent(attackResultId)}`, request)
return response.data
Expand Down
26 changes: 24 additions & 2 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,10 @@ export interface AttackSummary {
chat_scorer?: RegisteredComponentReference | null
chat_scorer_inherited?: boolean
execution_read_only?: boolean
/** The attack is still running in a scan; its result is not saved yet. */
in_progress?: boolean
/** The scan run that produced this attack; null outside a scan. */
scenario_result_id?: string | null
manual_continuation?: ManualContinuation | null
last_score?: BackendScore | null
last_response?: BackendMessagePiece | null
Expand All @@ -1070,7 +1074,7 @@ export interface AttackSummary {
export interface AttackMetadata extends Pick<AttackSummary,
| 'attack_result_id' | 'conversation_id' | 'objective' | 'labels' | 'operator'
| 'target' | 'target_unbound' | 'outcome' | 'automated_score' | 'human_score'
| 'chat_scorer' | 'chat_scorer_inherited' | 'manual_continuation'
| 'chat_scorer' | 'chat_scorer_inherited' | 'manual_continuation' | 'in_progress' | 'scenario_result_id'
> {
readonly execution_read_only: boolean
readonly last_response_message_piece_id: string | null
Expand Down Expand Up @@ -2146,13 +2150,14 @@ export interface ScenarioQueueSnapshot {
queued: ScenarioQueueEntry[]
}

/** One persisted attack attempt in ascending progress order. */
/** The objective score of one persisted scan attack attempt. */
export interface ScenarioProgressScore {
scorer_name: string
score_type: 'true_false' | 'float_scale' | 'unknown'
status: 'complete' | 'undetermined'
score_value?: string | null
score_rationale?: string | null
score_category?: string[]
}

export type ScenarioIdentityValue =
Expand Down Expand Up @@ -2464,7 +2469,10 @@ export interface ScenarioProgressSummary {

export interface ScenarioActiveConversation {
attack_result_id: string
/** The conversation the attack is working on, or the recorded objective conversation. */
conversation_id: string
/** Every objective conversation the running attack has started so far, in first-use order. */
conversation_ids?: string[]
atomic_attack_name: string
seed_group_id?: string | null
objective: string
Expand Down Expand Up @@ -2572,6 +2580,20 @@ export interface FindingListItem extends Finding {
evidence_count: number
}

/** A finding that links an attack as evidence. */
export interface AttackFindingItem extends Finding {
/** Current name of the finding's Operation. */
operation_name: string
/** The evidence link; detach it through the Operation evidence route. */
evidence_id: string
}

/** Every finding that links an attack, highest severity first, then newest first. */
export interface AttackFindingListResponse {
attack_result_id: string
items: AttackFindingItem[]
}

export interface FindingEvidenceCreateRequest {
attack_result_id: string
}
Expand Down
36 changes: 36 additions & 0 deletions pyrit/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,42 @@ Both paged and unpaged reads expose only main and pruned conversations.
Adversarial and preparation contexts are excluded from lists, reference totals,
and transcript reads, including hierarchy node expansion.

### Running scan attacks

A scan saves each `AttackResult` only when its attack finishes. While an active scan
is still running an attack, `GET /api/attacks/{attack_result_id}`, `/metadata`,
`/conversations`, and `/messages` (paged or not) resolve it from the live run instead
of returning 404. The attack summary and metadata responses set `in_progress: true`,
report outcome `undetermined`, and set `execution_read_only: true`. Read these
endpoints for running state; conversation and message responses do not include
the running-state flags. Write routes still require a saved result. The
conversation the attack is working on is the main conversation,
and every other objective conversation started so far is listed as a pruned
reference. Messages are read from memory as they are saved. A saved result always
wins, so once the attack finishes the same IDs resolve to its saved result; restart
reference paging if a cursor becomes stale at that transition. An attack that has
not started an objective conversation yet still returns 404.

Attack summaries and metadata also include `scenario_result_id`, the scan run that
produced the attack (`null` for attacks outside a scan). Scan progress
`active_conversations` entries list every objective conversation so far in
`conversation_ids`, alongside the current `conversation_id`. Each scan progress
result and hierarchy node `score` summarizes the objective score: `scorer_name`,
`score_type`, `status`, `score_value`, `score_rationale`, and `score_category`.

The attack service reads running attacks through a read-only lookup
(`LiveAttackLookup`) that the scenario run service provides; it does not import
scenario scheduling.

### Findings linked to an attack

`GET /api/attacks/{attack_result_id}/findings` lists every finding, across all
Operations, that links the attack as evidence: highest severity first, then
newest. Each item is the finding plus `operation_name` and the `evidence_id` that
unlinks it through the Operation evidence route. An attack with no linked
findings, including one that is not saved yet, returns an empty list; a non-UUID
ID returns 422.

The saved Scenario Chat viewer consumes these bounded reads with independent
reference/message continuation controls. It merges stable piece IDs into their
ordered turns and reconciles page-local partial-sequence markers against the
Expand Down
4 changes: 4 additions & 0 deletions pyrit/backend/mappers/attack_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ async def attack_result_to_summary_async(
operation_name: str | None = None,
scoring_inputs: AttackScoringInputs | None = None,
execution_read_only: bool = False,
in_progress: bool = False,
) -> AttackSummary:
"""
Build an AttackSummary view from an AttackResult.
Expand All @@ -248,6 +249,7 @@ async def attack_result_to_summary_async(
Legacy attacks without an Operation ID show their historical name instead.
scoring_inputs: Effective inputs resolved against the current registry by the service.
execution_read_only: Whether execution changes require a manual continuation.
in_progress: Whether ``ar`` projects an attack still running in a scan.

Returns:
AttackSummary view ready for the API response.
Expand All @@ -264,6 +266,8 @@ async def attack_result_to_summary_async(
chat_scorer=scoring_inputs.scorer if scoring_inputs else None,
chat_scorer_inherited=scoring_inputs.inherited if scoring_inputs else False,
execution_read_only=execution_read_only,
in_progress=in_progress,
scenario_result_id=ar.attribution_parent_id,
last_response=await _summary_last_response_async(ar.last_response),
automated_score=(
ScoreView.from_domain(ar.automated_score, is_objective_score=True) if ar.automated_score else None
Expand Down
8 changes: 8 additions & 0 deletions pyrit/backend/models/attacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,10 @@ class AttackSummary(AttackResult[MessagePieceView, ScoreView]):
)
chat_scorer_inherited: bool = Field(default=False, description="Uses the scan's registered original scorer")
execution_read_only: bool = Field(default=False, description="Execution changes require a manual continuation")
in_progress: bool = Field(
default=False, description="The attack is still running in a scan; its result is not saved yet"
)
scenario_result_id: str | None = Field(default=None, description="The scan run that produced this attack")

@computed_field
@property
Expand Down Expand Up @@ -370,6 +374,10 @@ class AttackMetadata(BaseModel):
chat_scorer: RegisteredComponentReference | None = None
chat_scorer_inherited: bool = False
execution_read_only: bool = False
in_progress: bool = Field(
default=False, description="The attack is still running in a scan; its result is not saved yet"
)
scenario_result_id: str | None = Field(default=None, description="The scan run that produced this attack")
manual_continuation: ManualContinuation | None = None
last_response_message_piece_id: str | None = None
related_conversation_count: int = Field(..., ge=0, description="Number of pruned conversations, excluding main")
Expand Down
16 changes: 16 additions & 0 deletions pyrit/backend/models/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ class FindingListItem(Finding):
evidence_count: int = Field(ge=0)


class AttackFindingItem(Finding):
"""A finding that links an attack as evidence, with its Operation's current name and the evidence link."""

operation_name: str
evidence_id: UUID = Field(description="The evidence link, which unlinks the attack from the finding")


class AttackFindingListResponse(BaseModel):
"""Every finding that links an attack, highest severity first, then newest first."""

model_config = ConfigDict(extra="forbid")

attack_result_id: UUID
items: list[AttackFindingItem]


class FindingListResponse(BaseModel):
"""One page of an operation's findings."""

Expand Down
16 changes: 16 additions & 0 deletions pyrit/backend/routes/attacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
)
from pyrit.backend.models.attacks import AttackMetadata, SaveConversationRequest
from pyrit.backend.models.common import MAX_IDENTIFIER_LENGTH, MAX_ITEMS, CursorStr, IdentifierStr, LabelFilterStr
from pyrit.backend.models.operations import AttackFindingListResponse
from pyrit.backend.models.scores import ApplyChatScorerRequest, ApplyChatScorerResponse
from pyrit.backend.routes.common import parse_label_query_params
from pyrit.backend.services import get_attack_service
Expand All @@ -43,6 +44,7 @@
from pyrit.backend.services.history_replay import HistoryReplayError
from pyrit.backend.services.manual_send_scheduler import ManualSendConflictError, ManualSendQueueFullError
from pyrit.backend.services.media_persistence import MediaAccessDeniedError
from pyrit.backend.services.operation_service import OperationService
from pyrit.common import print_deprecation_message
from pyrit.memory.memory_interface import AttackStateConflictError

Expand Down Expand Up @@ -381,6 +383,20 @@ async def get_attack_metadata_async(
return metadata


@router.get("/{attack_result_id}/findings", response_model=AttackFindingListResponse)
async def get_attack_findings_async(attack_result_id: uuid.UUID) -> AttackFindingListResponse:
"""
List the findings that link this attack as evidence, across all Operations.

An attack with no linked findings, including a running scan attack that is not saved yet,
returns an empty list rather than 404.

Returns:
AttackFindingListResponse: The linked findings with their Operation names and evidence links.
"""
return await OperationService().list_attack_findings_async(attack_result_id)


@router.patch(
"/{attack_result_id}",
response_model=AttackSummary,
Expand Down
Loading
Loading