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

describe("api service", () => {
Expand Down Expand Up @@ -823,6 +823,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 @@ -502,6 +503,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
},

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 @@ -987,6 +987,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 @@ -1012,7 +1016,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 @@ -2082,13 +2086,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 @@ -2338,7 +2343,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 @@ -2446,6 +2454,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 @@ -112,6 +112,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 @@ -262,6 +262,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 @@ -360,6 +364,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 @@ -42,13 +42,15 @@
LabelFilterStr,
ProblemDetail,
)
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.attack_service import AttackObjectiveConflictError, get_attack_service
from pyrit.backend.services.chat_scoring_service import ChatScoringError, get_chat_scoring_service
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.deprecation import print_deprecation_message
from pyrit.memory.memory_interface import AttackStateConflictError

Expand Down Expand Up @@ -380,6 +382,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