From 83152ae1a60aaae9ba3b11a073bae4c1bbca2207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= <98956809+puchy22@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:09:05 +0100 Subject: [PATCH] feat(ui): triage issues and send them to Jira (#13151) Co-authored-by: alejandrobailo --- ui/actions/issues/issues.adapter.ts | 44 ++-- ui/actions/issues/issues.test.ts | 149 ++++++++++++- ui/actions/issues/issues.ts | 82 ++++++- ui/actions/issues/types.ts | 65 ++++++ .../attack-paths-page.harness.ts | 12 +- .../attack-paths-page.integration.test.tsx | 8 +- .../issues/_components/issues-list.tsx | 2 + .../findings/table/finding-note-modal.tsx | 6 +- .../table/finding-triage-cells.test.tsx | 17 -- .../findings/table/finding-triage-cells.tsx | 28 +-- .../table/finding-triage-status-control.tsx | 94 +------- .../resource-detail-drawer-content.test.tsx | 6 - .../resource-detail-drawer-content.tsx | 4 +- ui/components/issues/issue-detail.test.tsx | 98 ++++++++- ui/components/issues/issue-detail.tsx | 64 +++++- ui/components/issues/issue-findings.tsx | 83 +++++++ .../issues/issue-graph.adapter.test.ts | 4 + ui/components/issues/issue-graph.test.tsx | 2 + ui/components/issues/issue-triage.test.tsx | 203 ++++++++++++++++++ ui/components/issues/issue-triage.tsx | 138 ++++++++++++ ui/components/issues/issues-table.test.tsx | 10 +- ui/components/issues/issues-table.tsx | 9 + .../issues/issues.integration.test.tsx | 102 +++++++++ ui/components/issues/types.ts | 15 ++ ui/components/issues/verdict-filter.tsx | 2 + .../triage/triage-status-badge.test.tsx | 31 +++ .../shared/triage/triage-status-badge.tsx | 23 ++ .../shared/triage/triage-status-picker.tsx | 77 +++++++ ui/components/shared/triage/types.ts | 25 +++ ui/lib/triage.ts | 11 + ui/types/findings-triage.ts | 3 + ui/types/issues.ts | 15 ++ 32 files changed, 1244 insertions(+), 188 deletions(-) create mode 100644 ui/actions/issues/types.ts create mode 100644 ui/components/issues/issue-findings.tsx create mode 100644 ui/components/issues/issue-triage.test.tsx create mode 100644 ui/components/issues/issue-triage.tsx create mode 100644 ui/components/issues/types.ts create mode 100644 ui/components/shared/triage/triage-status-badge.test.tsx create mode 100644 ui/components/shared/triage/triage-status-badge.tsx create mode 100644 ui/components/shared/triage/triage-status-picker.tsx create mode 100644 ui/components/shared/triage/types.ts create mode 100644 ui/lib/triage.ts diff --git a/ui/actions/issues/issues.adapter.ts b/ui/actions/issues/issues.adapter.ts index 017c7b66bf..3ed2e4a24c 100644 --- a/ui/actions/issues/issues.adapter.ts +++ b/ui/actions/issues/issues.adapter.ts @@ -1,20 +1,7 @@ -import { FindingProps } from "@/types/components"; -import { IssueFinding } from "@/types/issues"; -import { JsonApiDocument, JsonApiResource } from "@/types/jsonapi"; -import { ResourceProps } from "@/types/resources"; +import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage"; +import type { IssueFinding, IssueTriageNote } from "@/types/issues"; -interface IssueFindingResource extends Pick { - attributes: Pick< - FindingProps["attributes"], - "check_id" | "check_metadata" | "severity" - >; - relationships: Pick; -} - -interface IssueFindingsDocument - extends JsonApiDocument { - included?: JsonApiResource>[]; -} +import type { IssueFindingsDocument, IssueTriageNotesDocument } from "./types"; export const toIssueFindings = ( payload: IssueFindingsDocument | undefined, @@ -33,14 +20,39 @@ export const toIssueFindings = ( return [ { id: finding.id, + uid: finding.attributes.uid, checkId: finding.attributes.check_id, checkTitle: finding.attributes.check_metadata?.checktitle || finding.attributes.check_id, severity: finding.attributes.severity, + // A finding nobody has triaged reads as open, as on the Findings page. + triageStatus: + finding.attributes.triage_status ?? FINDING_TRIAGE_STATUS.OPEN, resourceUid, }, ]; }), ); }; + +export const toIssueTriageNotes = ( + payload: IssueTriageNotesDocument | undefined, +): IssueTriageNote[] | undefined => { + if (!Array.isArray(payload?.data)) return undefined; + const authors = new Map( + (payload.included ?? []) + .filter((resource) => resource.type === "users") + .map((user) => [user.id, user.attributes.name || user.attributes.email]), + ); + + return payload.data.map((note) => { + const authorId = note.relationships?.created_by?.data?.id; + return { + id: note.id, + body: note.attributes.body, + insertedAt: note.attributes.inserted_at, + author: authorId ? authors.get(authorId) : undefined, + }; + }); +}; diff --git a/ui/actions/issues/issues.test.ts b/ui/actions/issues/issues.test.ts index 4286ad6064..3e03dfb52e 100644 --- a/ui/actions/issues/issues.test.ts +++ b/ui/actions/issues/issues.test.ts @@ -1,12 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( - () => ({ - fetchMock: vi.fn(), - getAuthHeadersMock: vi.fn(), - handleApiResponseMock: vi.fn(), - }), -); +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", @@ -14,14 +18,20 @@ vi.mock("@/lib", () => ({ })); vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, handleApiResponse: handleApiResponseMock, })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + import { + addIssueNote, getIssue, getIssueFindings, + getIssueNotes, getIssues, getIssuesOverview, + updateIssueTriage, } from "./issues"; describe("issues actions", () => { @@ -54,9 +64,12 @@ describe("issues actions", () => { { id: "finding-1", attributes: { + uid: "finding-uid", check_id: "ssh", severity: "critical", check_metadata: { checktitle: "SSH exposed" }, + // Nobody has triaged it yet. + triage_status: null, }, relationships: { resources: { data: [{ id: "r1" }, { id: "r2" }] } }, }, @@ -76,12 +89,15 @@ describe("issues actions", () => { expect(url.searchParams.get("filter[scan]")).toBe("issue-scan"); expect(url.searchParams.get("filter[uid__in]")).toBe("finding-uid"); expect(url.searchParams.get("include")).toBe("resources"); + expect(url.searchParams.get("fields[findings]")).toContain("triage_status"); expect(findings).toEqual( ["instance", "security-group"].map((resourceUid) => ({ id: "finding-1", + uid: "finding-uid", checkId: "ssh", checkTitle: "SSH exposed", severity: "critical", + triageStatus: "open", resourceUid, })), ); @@ -118,4 +134,123 @@ describe("issues actions", () => { getIssueFindings("scan-1", ["uid-1"]), ).resolves.toBeUndefined(); }); + + it("triages through the issue's triage, as findings are triaged", async () => { + // Given + const response = new Response("{}", { status: 200 }); + fetchMock.mockResolvedValue(response); + handleApiResponseMock.mockResolvedValue({ data: {} }); + + // When + const result = await updateIssueTriage("issue-1", { + status: "risk_accepted", + note: "Static site.", + }); + + // Then + expect(result).toEqual({ success: true }); + // The list shows each issue's triage status. + expect(handleApiResponseMock).toHaveBeenCalledWith(response, "/issues"); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.example.com/api/v1/issues/issue-1/triage"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body)).toEqual({ + data: { + type: "issue-triages", + attributes: { status: "risk_accepted", note: "Static site." }, + }, + }); + }); + + it("returns the API's reason when a triage write is refused", async () => { + handleApiResponseMock.mockResolvedValue({ + error: "You do not have permission.", + status: 403, + }); + + await expect(addIssueNote("issue-1", "note")).resolves.toEqual({ + error: "You do not have permission.", + }); + expect(fetchMock.mock.calls[0][0]).toBe( + "https://api.example.com/api/v1/issues/issue-1/triage/notes", + ); + }); + + it("returns the shared, sanitized message when a triage write fails on the server", async () => { + // Given: handleApiResponse rejects on a 5xx (see beforeEach) + handleApiErrorMock.mockReturnValue({ error: "Something went wrong." }); + + // When + const result = await addIssueNote("issue-1", "note"); + + // Then + expect(result).toEqual({ error: "Something went wrong." }); + expect(handleApiErrorMock).toHaveBeenCalledWith(new Error("Server error")); + }); + + it.each([ + { + operation: "updating triage", + save: () => updateIssueTriage("issue-1", { status: "risk_accepted" }), + }, + { + operation: "adding a note", + save: () => addIssueNote("issue-1", "Accepted."), + }, + ])( + "returns a save error when authentication fails while $operation", + async ({ save }) => { + // Given + getAuthHeadersMock.mockRejectedValue(new Error("Session lookup failed")); + handleApiErrorMock.mockReturnValue({ error: "Something went wrong." }); + + // When / Then + await expect(save()).resolves.toEqual({ error: "Something went wrong." }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("names each note's author from the included users", async () => { + // Given + handleApiResponseMock.mockResolvedValue({ + data: [ + { + type: "issue-triage-notes", + id: "note-1", + attributes: { body: "Accepted.", inserted_at: "2026-10-20" }, + relationships: { created_by: { data: { type: "users", id: "u1" } } }, + }, + { + type: "issue-triage-notes", + id: "note-2", + attributes: { body: "Orphan.", inserted_at: "2026-10-19" }, + relationships: { created_by: { data: null } }, + }, + ], + included: [{ type: "users", id: "u1", attributes: { name: "Ana" } }], + }); + + // When + const notes = await getIssueNotes("issue-1"); + + // Then + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.pathname).toBe("/api/v1/issues/issue-1/triage/notes"); + expect(url.searchParams.get("include")).toBe("created_by"); + expect(url.searchParams.get("page[size]")).toBe("100"); + expect(notes).toEqual([ + { + id: "note-1", + body: "Accepted.", + insertedAt: "2026-10-20", + author: "Ana", + }, + { + id: "note-2", + body: "Orphan.", + insertedAt: "2026-10-19", + author: undefined, + }, + ]); + }); }); diff --git a/ui/actions/issues/issues.ts b/ui/actions/issues/issues.ts index a32d354570..1bdb23ba9b 100644 --- a/ui/actions/issues/issues.ts +++ b/ui/actions/issues/issues.ts @@ -1,16 +1,23 @@ "use server"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; -import { handleApiResponse } from "@/lib/server-actions-helper"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; import { ISSUE_FINDINGS_LIMIT, IssueDetailResponse, IssueFinding, IssueResponse, IssuesOverviewResponse, + IssueTriageNote, } from "@/types/issues"; -import { toIssueFindings } from "./issues.adapter"; +import { toIssueFindings, toIssueTriageNotes } from "./issues.adapter"; +import { + ISSUE_TRIAGE_WRITE_METHOD, + type IssueTriageWriteMethod, + type IssueWriteResult, + type UpdateIssueTriageInput, +} from "./types"; const withFilters = ( url: URL, @@ -107,7 +114,7 @@ export const getIssueFindings = async ( url.searchParams.set("include", "resources"); url.searchParams.set( "fields[findings]", - "check_id,severity,check_metadata,resources", + "uid,check_id,severity,check_metadata,triage_status,resources", ); url.searchParams.set("fields[resources]", "uid"); url.searchParams.set("page[size]", String(uids.length)); @@ -120,3 +127,72 @@ export const getIssueFindings = async ( return undefined; } }; + +/** Write one issue triage request; any non-2xx comes back as an error to show. */ +const writeIssueTriage = async ( + path: string, + method: IssueTriageWriteMethod, + data: Record, +): Promise => { + try { + const headers = await getAuthHeaders({ contentType: true }); + const response = await fetch(`${apiBaseUrl}${path}`, { + method, + headers, + body: JSON.stringify({ data }), + }); + // The list shows each issue's triage status. + const result = await handleApiResponse(response, "/issues"); + return result?.error ? { error: result.error } : { success: true }; + } catch (error) { + return handleApiError(error); + } +}; + +/** Set the status of the path an issue describes, with an optional note. */ +export const updateIssueTriage = async ( + id: string, + { status, note }: UpdateIssueTriageInput, +): Promise => + writeIssueTriage( + `/issues/${encodeURIComponent(id)}/triage`, + ISSUE_TRIAGE_WRITE_METHOD.UPDATE, + { + type: "issue-triages", + attributes: { status, ...(note ? { note } : {}) }, + }, + ); + +/** Add a note to an issue's triage without changing its status. */ +export const addIssueNote = async ( + id: string, + body: string, +): Promise => + writeIssueTriage( + `/issues/${encodeURIComponent(id)}/triage/notes`, + ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE, + { + type: "issue-triage-notes", + attributes: { body }, + }, + ); + +export const getIssueNotes = async ( + id: string, +): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL( + `${apiBaseUrl}/issues/${encodeURIComponent(id)}/triage/notes`, + ); + url.searchParams.set("include", "created_by"); + // shortcut: newest 100 notes only (the API's max page size; its default is 10), page through links.next once an issue's history outgrows it + url.searchParams.set("page[size]", "100"); + + try { + const response = await fetch(url.toString(), { headers }); + return toIssueTriageNotes(await handleApiResponse(response)); + } catch (error) { + console.error("Error fetching issue notes:", error); + return undefined; + } +}; diff --git a/ui/actions/issues/types.ts b/ui/actions/issues/types.ts new file mode 100644 index 0000000000..ca8b48ad72 --- /dev/null +++ b/ui/actions/issues/types.ts @@ -0,0 +1,65 @@ +import type { FindingProps } from "@/types/components"; +import type { + FindingTriageManualStatus, + FindingTriageStatus, +} from "@/types/findings-triage"; +import type { IssueTriageNote } from "@/types/issues"; +import type { JsonApiDocument, JsonApiResource } from "@/types/jsonapi"; +import type { ResourceProps } from "@/types/resources"; +import type { UserAttributes, UserData } from "@/types/users"; + +export interface IssueFindingAttributes + extends Pick< + FindingProps["attributes"], + "uid" | "check_id" | "check_metadata" | "severity" + > { + triage_status?: FindingTriageStatus | null; +} + +export interface IssueFindingResource extends Pick { + attributes: IssueFindingAttributes; + relationships: Pick; +} + +export interface IssueFindingsDocument + extends JsonApiDocument { + included?: JsonApiResource>[]; +} + +export interface IssueTriageNoteAttributes + extends Pick { + inserted_at: IssueTriageNote["insertedAt"]; +} + +export interface IssueTriageNoteAuthorRelationship { + data: Pick | null; +} + +export interface IssueTriageNoteRelationships { + created_by?: IssueTriageNoteAuthorRelationship; +} + +export interface IssueTriageNoteResource + extends JsonApiResource { + relationships?: IssueTriageNoteRelationships; +} + +export interface IssueTriageNotesDocument + extends JsonApiDocument { + included?: JsonApiResource>>[]; +} + +export type IssueWriteResult = { success: true } | { error: string }; + +export interface UpdateIssueTriageInput { + status: FindingTriageManualStatus; + note?: string; +} + +export const ISSUE_TRIAGE_WRITE_METHOD = { + UPDATE: "PATCH", + ADD_NOTE: "POST", +} as const; + +export type IssueTriageWriteMethod = + (typeof ISSUE_TRIAGE_WRITE_METHOD)[keyof typeof ISSUE_TRIAGE_WRITE_METHOD]; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts index 219c08bd61..b5aa61db43 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts @@ -588,7 +588,8 @@ export class AttackPathPageHarness extends BrowserHarness { /** * Click every resource that the fixture's relationships connect to a finding. * Findings are hidden by default in the full graph view (Tier 1) — clicking - * their adjacent resources reveals them. + * their adjacent resources reveals them. Restore the full view after each + * expansion because its contextual fit can move the next resource off-screen. */ async expandAllFindings(): Promise { const findingIds = new Set( @@ -604,8 +605,15 @@ export class AttackPathPageHarness extends BrowserHarness { for (const id of Array.from(resourceWithFindingIds)) { const el = this.getNodeById(id); if (el) { + const beforeExpansion = await this.waitForViewportSettled(); await this.user.click(el); - await this.waitForTransition(50); + // Measuring new findings can delay the automatic fit. A quiet viewport + // alone does not prove that this fit has started or finished. + await this.waitForViewportChange(beforeExpansion); + await this.waitForViewportSettled(); + await this.fit(); + await this.waitForNodesInViewport(this.renderedNodeIds); + await this.waitForViewportSettled(); } } } diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx index 515292627c..e9153a7040 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx @@ -616,11 +616,15 @@ describe("auto-fitting the viewport", () => { // their positions can sit outside the framed viewport. Expanding the // resources should re-fit so the user does not have to hunt for the // newly visible findings off-screen. - const before = graph.viewportTransform; + const before = await graph.waitForViewportSettled(); expect(before).toBeTruthy(); - await graph.expandAllFindings(); + // Use a single resource click so only the automatic fit can recover the + // findings; expandAllFindings also uses the toolbar to frame the full graph. + await graph.clickNode("s3-1"); await graph.waitForViewportChange(before); + await graph.waitForViewportSettled(); + await graph.waitForNodesInViewport(["s3-1", "f-1"]); expect(graph.viewportTransform).not.toBe(before); }); diff --git a/ui/app/(prowler)/issues/_components/issues-list.tsx b/ui/app/(prowler)/issues/_components/issues-list.tsx index c0ca294660..9e784053da 100644 --- a/ui/app/(prowler)/issues/_components/issues-list.tsx +++ b/ui/app/(prowler)/issues/_components/issues-list.tsx @@ -17,6 +17,8 @@ const ISSUE_FILTER_KEYS = [ "filter[severity]", "filter[provider_type]", "filter[provider_alias]", + "filter[triage_status]", + "filter[triage_status__in]", ...ISSUE_SCOPE_KEYS, ]; diff --git a/ui/components/findings/table/finding-note-modal.tsx b/ui/components/findings/table/finding-note-modal.tsx index 168479d84d..c180ae43a4 100644 --- a/ui/components/findings/table/finding-note-modal.tsx +++ b/ui/components/findings/table/finding-note-modal.tsx @@ -31,6 +31,7 @@ import { FINDING_TRIAGE_RESOLVED_LOCKED_COPY, FINDING_TRIAGE_STATUS, MANUAL_PASS_PROVENANCE, + MANUAL_PASS_NOTE_REQUIRED_COPY, type FindingTriageContext, type FindingTriageDetail, type FindingTriageModalStatus, @@ -41,10 +42,7 @@ import { isTriageStatusLocked, } from "@/types/findings-triage"; -import { - FindingTriageStatusControl, - MANUAL_PASS_NOTE_REQUIRED_COPY, -} from "./finding-triage-status-control"; +import { FindingTriageStatusControl } from "./finding-triage-status-control"; import { buildFindingTriageUpdateInput } from "./finding-triage-submit"; interface FindingNoteModalProps { diff --git a/ui/components/findings/table/finding-triage-cells.test.tsx b/ui/components/findings/table/finding-triage-cells.test.tsx index 920be817be..15821fe59e 100644 --- a/ui/components/findings/table/finding-triage-cells.test.tsx +++ b/ui/components/findings/table/finding-triage-cells.test.tsx @@ -92,7 +92,6 @@ import { import { FindingNoteActionItem, - FindingTriageStatusBadge, FindingTriageStatusCell, } from "./finding-triage-cells"; @@ -803,22 +802,6 @@ describe("finding triage cells", () => { ).not.toBeInTheDocument(); }); - it("renders a read-only triage status badge with the status color", () => { - // Given / When - render( - , - ); - - // Then - expect(screen.getByText("Triage:")).toBeInTheDocument(); - expect(screen.getByText("Remediating")).toHaveClass("text-bg-data-info"); - }); - it("should disable table status mutation when no update handler is wired", async () => { // Given const user = userEvent.setup(); diff --git a/ui/components/findings/table/finding-triage-cells.tsx b/ui/components/findings/table/finding-triage-cells.tsx index da915fea0c..17c9a5e15c 100644 --- a/ui/components/findings/table/finding-triage-cells.tsx +++ b/ui/components/findings/table/finding-triage-cells.tsx @@ -12,7 +12,6 @@ import { TooltipTrigger, } from "@/components/shadcn/tooltip"; import { applyOptimisticTriageSummaryUpdate } from "@/lib/finding-triage"; -import { cn } from "@/lib/utils"; import { useCloudUpgradeStore } from "@/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { FINDING_STATUS } from "@/types/components"; @@ -39,10 +38,7 @@ import { FindingNoteModal, type FindingNoteModalMode, } from "./finding-note-modal"; -import { - FindingTriageStatusControl, - TRIAGE_STATUS_TEXT_CLASS, -} from "./finding-triage-status-control"; +import { FindingTriageStatusControl } from "./finding-triage-status-control"; export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud"; export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable."; @@ -340,28 +336,6 @@ export function FindingTriageStatusCell({ ); } -// Read-only triage status indicator, e.g. for the side drawer header where the -// editable picker would be out of place among the status/severity badges. -export function FindingTriageStatusBadge({ - triage, -}: { - triage: FindingTriageSummary; -}) { - return ( -
- Triage: - - {triage.label} - -
- ); -} - export function FindingNoteActionItem({ triage, findingContext = { title: "Finding" }, diff --git a/ui/components/findings/table/finding-triage-status-control.tsx b/ui/components/findings/table/finding-triage-status-control.tsx index a35ada33dd..f29224d488 100644 --- a/ui/components/findings/table/finding-triage-status-control.tsx +++ b/ui/components/findings/table/finding-triage-status-control.tsx @@ -1,27 +1,19 @@ "use client"; -import { type ComponentProps, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from "@/components/shadcn/select/select"; import { useToast } from "@/components/shadcn/toast/use-toast"; -import { cn } from "@/lib/utils"; +import { TriageStatusPicker } from "@/components/shared/triage/triage-status-picker"; import { FINDING_STATUS } from "@/types/components"; import { FINDING_TRIAGE_MANUAL_STATUS_VALUES, FINDING_TRIAGE_MODAL_STATUS_VALUES, FINDING_TRIAGE_ORIGIN, FINDING_TRIAGE_STATUS, - FINDING_TRIAGE_STATUS_LABELS, type FindingTriageManualStatus, type FindingTriageModalStatus, - type FindingTriageStatus, type FindingTriageSummary, type FindingTriageUpdateHandler, getFindingTriageMuteInfoCopy, @@ -29,89 +21,7 @@ import { isTriageStatusLocked, } from "@/types/findings-triage"; -type TriageStatusPickerSize = NonNullable< - ComponentProps["size"] ->; - -export const TRIAGE_STATUS_TEXT_CLASS = { - open: "text-text-error-primary", - under_review: "text-text-warning-primary", - remediating: "text-bg-data-info", - resolved: "text-bg-pass", - risk_accepted: "text-bg-pass", - false_positive: "text-text-neutral-secondary", - reopened: "text-text-error-primary", -} as const satisfies Record; - const MUTELIST_CONFIRMATION_TITLE = "Mute finding?"; -export const MANUAL_PASS_NOTE_REQUIRED_COPY = - "Add a Triage Note explaining why this finding passes."; - -function TriageStatusPicker({ - disabled, - size = "sm", - value, - statusValues, - showManualPassTooltip = false, - onValueChange, -}: { - disabled: boolean; - size?: TriageStatusPickerSize; - value: FindingTriageStatus; - statusValues: readonly FindingTriageModalStatus[]; - showManualPassTooltip?: boolean; - onValueChange: (status: FindingTriageModalStatus) => void; -}) { - return ( - - ); -} type TableStatusControlProps = { origin: typeof FINDING_TRIAGE_ORIGIN.TABLE; diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx index e9eae3ee65..98117b27c0 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -524,12 +524,6 @@ vi.mock("../finding-triage-cells", () => ({ ) : ( - ), - FindingTriageStatusBadge: ({ triage }: { triage: { label: string } }) => ( -
- Triage: - {triage.label} -
- ), })); vi.mock("./resource-detail-skeleton", () => ({ diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index 24fff8df1c..84bbac8b3d 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -75,6 +75,7 @@ import { type QueryEditorLanguage, } from "@/components/shared/query-code-editor"; import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel"; +import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badge"; import { useLighthouseCurrentContext } from "@/hooks/use-lighthouse-context"; import { getFailingForLabel, formatDuration } from "@/lib/date-utils"; import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage"; @@ -97,7 +98,6 @@ import { Muted } from "../../muted"; import { DeltaIndicator } from "../delta-indicator"; import { FindingNoteActionItem, - FindingTriageStatusBadge, FindingTriageStatusCell, } from "../finding-triage-cells"; import { DeltaValues, NotificationIndicator } from "../notification-indicator"; @@ -534,7 +534,7 @@ export function ResourceDetailDrawerContent({ {findingIsMuted !== undefined && ( )} - {findingTriage && } + {findingTriage && } {showCheckMetaContent ? ( diff --git a/ui/components/issues/issue-detail.test.tsx b/ui/components/issues/issue-detail.test.tsx index e2679e534d..34a505f074 100644 --- a/ui/components/issues/issue-detail.test.tsx +++ b/ui/components/issues/issue-detail.test.tsx @@ -1,8 +1,9 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { IssueDetail } from "@/components/issues/issue-detail"; +import { useJiraDispatchStore } from "@/store/jira-dispatch/store"; import { IssueAttributes } from "@/types/issues"; const { getIssueMock, getIssueFindingsMock } = vi.hoisted(() => ({ @@ -15,8 +16,12 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) })); vi.mock("@/actions/issues", () => ({ getIssue: getIssueMock, getIssueFindings: getIssueFindingsMock, + getIssueNotes: async () => [], })); +vi.mock("@/components/findings/send-to-jira-modal", () => ({ + SendToJiraModal: () => null, +})); vi.mock("@/components/issues/issue-graph", () => ({ IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
{resourceUids.length} resources
@@ -35,6 +40,7 @@ const issue = { legs: {}, fix: "Review these findings individually.", findings_count: 300, + triage_status: "open", inserted_at: "", updated_at: "", } as unknown as IssueAttributes; @@ -43,6 +49,7 @@ describe("IssueDetail", () => { beforeEach(() => { getIssueMock.mockReset(); getIssueFindingsMock.mockResolvedValue([]); + useJiraDispatchStore.getState().closeJiraDispatch(); }); it("links to the issue's scan findings and reveals every folded resource", async () => { @@ -108,4 +115,93 @@ describe("IssueDetail", () => { "finding-uid", ]); }); + + it("lists each folded finding once, with its own triage and a link to it", async () => { + getIssueMock.mockResolvedValue({ + data: { + attributes: { resource_uids: ["bucket"], finding_uids: ["f-1", "f-2"] }, + }, + }); + const finding = { + id: "finding-1", + uid: "f-1", + checkId: "s3_bucket_public_access", + checkTitle: "Bucket is public", + severity: "critical", + triageStatus: "risk_accepted", + resourceUid: "bucket", + }; + getIssueFindingsMock.mockResolvedValue([ + finding, + // The same finding on a second resource is still one finding. + { ...finding, resourceUid: "other-bucket" }, + { + ...finding, + id: "finding-2", + uid: "f-2", + checkTitle: "Bucket has no logging", + triageStatus: "open", + }, + ]); + render(); + + const section = await screen.findByRole("region", { name: "Findings" }); + const link = within(section).getByRole("link", { + name: "Bucket is public", + }); + const destination = new URL(link.getAttribute("href")!, "https://app.test"); + expect(Object.fromEntries(destination.searchParams)).toEqual({ + "filter[scan__in]": "scan-1", + "filter[check_id__in]": "s3_bucket_public_access", + "filter[resource_uid]": "other-bucket", + "filter[muted]": "include", + expandedCheckId: "s3_bucket_public_access", + }); + expect(within(section).getAllByRole("listitem")).toHaveLength(2); + expect(within(section).getByText("Risk Accepted")).toBeInTheDocument(); + expect(within(section).getByText("Showing 2 of 300.")).toBeInTheDocument(); + }); + + it("opens the global Jira flow with unique finding IDs", async () => { + // Given + const user = userEvent.setup(); + getIssueMock.mockResolvedValue({ + data: { + attributes: { resource_uids: ["bucket"], finding_uids: ["f-1"] }, + }, + }); + const finding = { + id: "finding-1", + uid: "f-1", + checkId: "s3_public", + checkTitle: "Bucket is public", + severity: "critical", + triageStatus: "open", + resourceUid: "bucket", + }; + getIssueFindingsMock.mockResolvedValue([ + finding, + { ...finding, resourceUid: "other-bucket" }, + { ...finding, id: "finding-2", uid: "f-2" }, + ]); + const view = render( + , + ); + + // When + await user.click( + await screen.findByRole("button", { name: "Send to Jira" }), + ); + view.unmount(); + + // Then: the globally hosted modal survives the detail closing. + expect(useJiraDispatchStore.getState().activePayload).toMatchObject({ + findingTitle: issue.title, + selection: { + kind: "target-list", + targetType: "finding_id", + targetIds: ["finding-1", "finding-2"], + }, + }); + }); }); diff --git a/ui/components/issues/issue-detail.tsx b/ui/components/issues/issue-detail.tsx index e5d0eeda18..fd9a03ff69 100644 --- a/ui/components/issues/issue-detail.tsx +++ b/ui/components/issues/issue-detail.tsx @@ -1,16 +1,22 @@ "use client"; +import { Send } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { getIssue, getIssueFindings } from "@/actions/issues"; +import { IssueFindings } from "@/components/issues/issue-findings"; import { IssueGraph } from "@/components/issues/issue-graph"; +import { IssueTriage } from "@/components/issues/issue-triage"; import { IssuesLoadError } from "@/components/issues/issues-load-error"; import { Badge } from "@/components/shadcn/badge/badge"; import { Button } from "@/components/shadcn/button/button"; import { Card } from "@/components/shadcn/card/card"; import { CodeSnippet } from "@/components/shadcn/code-snippet"; import { legBadgeVariant } from "@/lib/issues/verdicts"; +import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection"; +import { useJiraDispatchStore } from "@/store/jira-dispatch/store"; +import { JIRA_DISPATCH_TARGET } from "@/types/integrations"; import { IssueAttributes, IssueFinding, @@ -64,6 +70,9 @@ export const IssueDetail = ({ scanId, providerId, }: IssueDetailProps) => { + const openJiraDispatch = useJiraDispatchStore( + (state) => state.openJiraDispatch, + ); const [visibleResources, setVisibleResources] = useState(RESOURCE_PAGE_SIZE); const [attempt, setAttempt] = useState(0); // Resource UIDs are only included by the detail endpoint, fetched on expansion. @@ -111,6 +120,20 @@ export const IssueDetail = ({ setAttempt((value) => value + 1); }; const activeScanId = state.status === "loaded" ? state.scanId : scanId; + const uniqueFindings = + state.status === "loaded" + ? Array.from( + new Map( + state.findings?.map((finding) => [finding.id, finding]), + ).values(), + ) + : []; + const jiraPayload = createJiraDispatchPayload({ + findingTitle: issue.title, + targetIds: uniqueFindings.map((finding) => finding.id), + targetType: JIRA_DISPATCH_TARGET.FINDING_ID, + description: `Send ${uniqueFindings.length} of ${issue.findings_count} findings on this attack path.`, + }); return (
@@ -219,16 +242,41 @@ export const IssueDetail = ({

Remediation

{issue.fix}

- {activeScanId && ( - + )} + {jiraPayload && ( + // shortcut: the ticket groups the loaded preview (ISSUE_FINDINGS_LIMIT + // findings), not every finding of a larger issue; page through + // finding_uids if tickets for big issues need all of them. + - )} +
+ {state.status === "loaded" && + state.findings && + state.findings.length > 0 && ( + + )} + ); }; diff --git a/ui/components/issues/issue-findings.tsx b/ui/components/issues/issue-findings.tsx new file mode 100644 index 0000000000..3f366314c0 --- /dev/null +++ b/ui/components/issues/issue-findings.tsx @@ -0,0 +1,83 @@ +import Link from "next/link"; + +import { Button } from "@/components/shadcn/button/button"; +import { Card } from "@/components/shadcn/card/card"; +import { SeverityBadge } from "@/components/shadcn/table/severity-badge"; +import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badge"; +import { MUTED_FILTER } from "@/lib/findings-filters"; + +import type { IssueFindingsProps } from "./types"; + +/** + * The findings the issue folds, each with its own triage. That triage stays on + * the finding: the issue's status answers for the combination, the finding's + * for whether that one check is right. + */ +export const IssueFindings = ({ + findings, + scanId, + total, +}: IssueFindingsProps) => { + return ( +
+

+ Findings ({total.toLocaleString()}) +

+
    + {findings.map((finding) => ( +
  • + +
    +
    + {scanId ? ( +
    + +
    + ) : ( + + {finding.checkTitle} + + )} + +
    +
    + + {finding.checkId} + + +
    +
    +
    +
  • + ))} +
+ {findings.length < total && ( +

+ Showing {findings.length.toLocaleString()} of {total.toLocaleString()} + . +

+ )} +
+ ); +}; diff --git a/ui/components/issues/issue-graph.adapter.test.ts b/ui/components/issues/issue-graph.adapter.test.ts index 75372477b9..593834b44d 100644 --- a/ui/components/issues/issue-graph.adapter.test.ts +++ b/ui/components/issues/issue-graph.adapter.test.ts @@ -38,9 +38,11 @@ describe("buildIssueGraph", () => { assessedIssue.legs.reachability.evidence = ["ssh · instance"]; const findings = resources.map((resourceUid, index) => ({ id: `finding-${index}`, + uid: `finding-uid-${index}`, checkId: index === 2 ? "rdp" : "ssh", checkTitle: index === 2 ? "RDP exposed" : "SSH exposed", severity: index === 0 ? ("critical" as const) : ("high" as const), + triageStatus: "open" as const, resourceUid, })); @@ -78,9 +80,11 @@ describe("buildIssueGraph", () => { const view = buildIssueGraph(assessedIssue, resources, [ { id: "folded", + uid: "folded-uid", checkId: "ssh", checkTitle: "SSH exposed", severity: "critical", + triageStatus: "open", resourceUid, }, ]); diff --git a/ui/components/issues/issue-graph.test.tsx b/ui/components/issues/issue-graph.test.tsx index cecc2d9651..92362685a0 100644 --- a/ui/components/issues/issue-graph.test.tsx +++ b/ui/components/issues/issue-graph.test.tsx @@ -76,9 +76,11 @@ describe("IssueGraph finding lookup", () => { findings={[ { id: "folded-finding", + uid: "folded-finding-uid", checkId: "ssh", checkTitle: "SSH exposed", severity: "critical", + triageStatus: "open", resourceUid: "bucket", }, ]} diff --git a/ui/components/issues/issue-triage.test.tsx b/ui/components/issues/issue-triage.test.tsx new file mode 100644 index 0000000000..70847e24b1 --- /dev/null +++ b/ui/components/issues/issue-triage.test.tsx @@ -0,0 +1,203 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { IssueTriage } from "./issue-triage"; + +const { + addIssueNoteMock, + getIssueNotesMock, + refreshMock, + toastMock, + updateIssueTriageMock, +} = vi.hoisted(() => ({ + addIssueNoteMock: vi.fn(), + getIssueNotesMock: vi.fn(), + refreshMock: vi.fn(), + toastMock: vi.fn(), + updateIssueTriageMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: refreshMock }), +})); + +vi.mock("@/actions/issues", () => ({ + addIssueNote: addIssueNoteMock, + getIssueNotes: getIssueNotesMock, + updateIssueTriage: updateIssueTriageMock, +})); + +vi.mock("@/components/shadcn/toast/use-toast", () => ({ + useToast: () => ({ toast: toastMock }), +})); + +describe("IssueTriage", () => { + beforeAll(() => { + // Radix Select needs pointer capture and scrollIntoView, which jsdom lacks. + for (const name of [ + "hasPointerCapture", + "setPointerCapture", + "releasePointerCapture", + "scrollIntoView", + ]) { + Object.defineProperty(HTMLElement.prototype, name, { + configurable: true, + value: vi.fn(() => false), + }); + } + }); + + beforeEach(() => { + getIssueNotesMock.mockResolvedValue([ + { + id: "note-1", + body: "Public by design.", + insertedAt: "2026-10-20T10:00:00Z", + author: "Ana", + }, + ]); + updateIssueTriageMock.mockResolvedValue({ success: true }); + addIssueNoteMock.mockResolvedValue({ success: true }); + }); + + it("lists the notes already on the issue, with their author", async () => { + render(); + + expect(await screen.findByText("Public by design.")).toBeInTheDocument(); + expect(screen.getByText("Ana")).toBeInTheDocument(); + expect(screen.getByText("Oct 20, 2026")).toBeInTheDocument(); + }); + + it("saves a new status together with its note", async () => { + // Given + const user = userEvent.setup(); + render(); + expect(screen.getByRole("button", { name: "Save triage" })).toBeDisabled(); + + // When + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + await user.click(screen.getByRole("option", { name: "Risk Accepted" })); + await user.type( + screen.getByRole("textbox", { name: "Triage note" }), + " Static site. ", + ); + await user.click(screen.getByRole("button", { name: "Save triage" })); + + // Then + expect(updateIssueTriageMock).toHaveBeenCalledWith("issue-1", { + status: "risk_accepted", + note: "Static site.", + }); + expect(addIssueNoteMock).not.toHaveBeenCalled(); + expect(refreshMock).toHaveBeenCalled(); + expect(screen.getByRole("textbox", { name: "Triage note" })).toHaveValue( + "", + ); + }); + + it("records a note alone without touching the status", async () => { + const user = userEvent.setup(); + render(); + + await user.type( + screen.getByRole("textbox", { name: "Triage note" }), + "Asked the owner.", + ); + await user.click(screen.getByRole("button", { name: "Save triage" })); + + expect(addIssueNoteMock).toHaveBeenCalledWith( + "issue-1", + "Asked the owner.", + ); + expect(updateIssueTriageMock).not.toHaveBeenCalled(); + }); + + it("follows a status someone else set, so a note alone does not undo it", async () => { + // Given + const user = userEvent.setup(); + const { rerender } = render(); + + // When - a refresh brings another user's decision + rerender(); + await user.type( + screen.getByRole("textbox", { name: "Triage note" }), + "Asked the owner.", + ); + await user.click(screen.getByRole("button", { name: "Save triage" })); + + // Then + expect( + screen.getByRole("combobox", { name: "Triage status" }), + ).toHaveTextContent("Remediating"); + expect(addIssueNoteMock).toHaveBeenCalledWith( + "issue-1", + "Asked the owner.", + ); + expect(updateIssueTriageMock).not.toHaveBeenCalled(); + }); + + it("locks the note while it is being saved", async () => { + // Given + let finish: (value: { success: true }) => void = () => {}; + addIssueNoteMock.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const user = userEvent.setup(); + render(); + const textbox = screen.getByRole("textbox", { name: "Triage note" }); + + // When + await user.type(textbox, "Asked the owner."); + await user.click(screen.getByRole("button", { name: "Save triage" })); + + // Then + expect(textbox).toBeDisabled(); + finish({ success: true }); + await waitFor(() => expect(textbox).toBeEnabled()); + }); + + it("keeps the note and says why when the save fails", async () => { + // Given + updateIssueTriageMock.mockResolvedValue({ + error: "You do not have permission to perform this action.", + }); + const user = userEvent.setup(); + render(); + + // When + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + await user.click(screen.getByRole("option", { name: "False Positive" })); + await user.type(screen.getByRole("textbox", { name: "Triage note" }), "x"); + await user.click(screen.getByRole("button", { name: "Save triage" })); + + // Then + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + description: "You do not have permission to perform this action.", + }), + ); + expect(refreshMock).not.toHaveBeenCalled(); + expect(screen.getByRole("textbox", { name: "Triage note" })).toHaveValue( + "x", + ); + }); + + it("says so when the notes cannot be loaded, and loads them on retry", async () => { + const user = userEvent.setup(); + getIssueNotesMock.mockResolvedValueOnce(undefined); + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not load the notes for this issue.", + ); + + await user.click(screen.getByRole("button", { name: "Retry" })); + + expect(await screen.findByText("Public by design.")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/issues/issue-triage.tsx b/ui/components/issues/issue-triage.tsx new file mode 100644 index 0000000000..e181df7beb --- /dev/null +++ b/ui/components/issues/issue-triage.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState, useTransition } from "react"; + +import { + addIssueNote, + getIssueNotes, + updateIssueTriage, +} from "@/actions/issues"; +import { IssuesLoadError } from "@/components/issues/issues-load-error"; +import { Button } from "@/components/shadcn/button/button"; +import { DateWithTime } from "@/components/shadcn/entities"; +import { Textarea } from "@/components/shadcn/textarea/textarea"; +import { useToast } from "@/components/shadcn/toast/use-toast"; +import { TriageStatusPicker } from "@/components/shared/triage/triage-status-picker"; +import { + FINDING_TRIAGE_MANUAL_STATUS_VALUES, + FINDING_TRIAGE_NOTE_MAX_LENGTH, + isManualStatus, +} from "@/types/findings-triage"; +import { IssueTriageNote } from "@/types/issues"; + +import type { IssueTriageProps } from "./types"; + +/** + * The decision on the path, and why. + * + * It belongs to the account and the pattern, not to this scan's row, so it + * still applies after the next scan. The verdict beside it is the engine's: + * accepting a risk does not make a path unexploitable. + */ +export const IssueTriage = ({ id, status }: IssueTriageProps) => { + const router = useRouter(); + const { toast } = useToast(); + const [pending, startTransition] = useTransition(); + const [nextStatus, setNextStatus] = useState(status); + const [shownStatus, setShownStatus] = useState(status); + const [note, setNote] = useState(""); + const [notes, setNotes] = useState([]); + const [reload, setReload] = useState(0); + + // Follow the status the server now reports (a refresh after a save, or + // someone else's decision), so a note-only save never writes back a stale one. + if (status !== shownStatus) { + setShownStatus(status); + setNextStatus(status); + } + + useEffect(() => { + let cancelled = false; + getIssueNotes(id).then((loaded) => { + if (!cancelled) setNotes(loaded); + }); + return () => { + cancelled = true; + }; + }, [id, reload]); + + const statusChanged = nextStatus !== status; + const trimmedNote = note.trim(); + + const save = () => + startTransition(async () => { + const result = + statusChanged && isManualStatus(nextStatus) + ? await updateIssueTriage(id, { + status: nextStatus, + note: trimmedNote || undefined, + }) + : await addIssueNote(id, trimmedNote); + + if ("error" in result) { + toast({ + variant: "destructive", + title: "Triage not saved", + description: result.error, + }); + return; + } + setNote(""); + setReload((value) => value + 1); + router.refresh(); + }); + + return ( +
+

Triage

+
+
+ +
+ +
+