diff --git a/app/(chat)/findings/__tests__/page.test.tsx b/app/(chat)/findings/__tests__/page.test.tsx new file mode 100644 index 000000000..7cee2d0d5 --- /dev/null +++ b/app/(chat)/findings/__tests__/page.test.tsx @@ -0,0 +1,387 @@ +import "@testing-library/jest-dom"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +const mockUsePaginatedQuery = jest.fn(); +const mockUseQuery = jest.fn((_ref: unknown, args: any) => + args?.findingId ? mockFinding : undefined, +); +const mockConvexQuery = jest.fn(); +const mockDownloadFile = jest.fn(); +const mockCapture = jest.fn(); +const mockPush = jest.fn(); +const mockSearchParams = new URLSearchParams(); +const mockCloseSidebar = jest.fn(); +const mockInitializeNewChat = jest.fn(); +const mockSetChatMode = jest.fn(); +const mockSetTemporaryChatsEnabled = jest.fn(); + +jest.mock("convex/react", () => ({ + useConvex: () => ({ query: mockConvexQuery }), + useConvexAuth: () => ({ isLoading: false, isAuthenticated: true }), + usePaginatedQuery: (...args: unknown[]) => mockUsePaginatedQuery(...args), + useQuery: (...args: unknown[]) => mockUseQuery(...args), + useMutation: () => jest.fn(), +})); + +jest.mock("@/app/contexts/GlobalState", () => ({ + useGlobalState: () => ({ + setChatSidebarOpen: jest.fn(), + closeSidebar: mockCloseSidebar, + initializeNewChat: mockInitializeNewChat, + setChatMode: mockSetChatMode, + setTemporaryChatsEnabled: mockSetTemporaryChatsEnabled, + }), +})); + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), + useSearchParams: () => mockSearchParams, +})); + +jest.mock("@/app/hooks/useTauri", () => ({ navigateToAuth: jest.fn() })); +jest.mock("@/lib/analytics/client", () => ({ + captureAuthenticatedEvent: mockCapture, +})); +jest.mock("@/lib/utils/file-download", () => ({ + downloadFile: (...args: unknown[]) => mockDownloadFile(...args), +})); + +const mockFinding = { + finding_id: "finding-1", + title: "Confirmed IDOR", + target: "https://app.example.test", + endpoint: "/api/invoices/other", + method: "GET", + severity: "high", + cvss_score: 7.1, + category: "access_control", + status: "active", + chat_id: "chat-1", + chat_title: "Invoice test", + created_at: Date.now(), + updated_at: Date.now(), + message_id: "message-1", + description: "Another account's invoice is readable.", + impact: "Billing data disclosure.", + technical_analysis: "Missing owner predicate.", + poc_description: "Request another account's invoice.", + poc_script_code: "curl /api/invoices/other", + remediation_steps: "Add an owner predicate.", + evidence: "HTTP 200 returned another account's data.", + assumptions: "Ordinary account.", + fix_effort: "low", + cvss_vector: "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N", + cvss_breakdown: { + attack_vector: "N", + attack_complexity: "L", + privileges_required: "L", + user_interaction: "N", + scope: "U", + confidentiality: "H", + integrity: "N", + availability: "N", + }, +}; + +const Page = require("../page").default as typeof import("../page").default; + +describe("FindingsPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + for (const key of [...mockSearchParams.keys()]) { + mockSearchParams.delete(key); + } + window.history.replaceState({}, "", "/findings"); + mockUseQuery.mockImplementation((_ref: unknown, args: any) => + args?.findingId ? mockFinding : undefined, + ); + mockUsePaginatedQuery.mockReturnValue({ + results: [mockFinding], + status: "Exhausted", + loadMore: jest.fn(), + }); + mockConvexQuery.mockResolvedValue({ + page: [mockFinding], + isDone: true, + continueCursor: "", + }); + mockDownloadFile.mockResolvedValue(undefined); + }); + + it("lists metadata and searches without a source-chat filter", async () => { + render(); + expect(screen.getByRole("heading", { name: "Findings" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Open navigation" })).toHaveClass( + "md:hidden", + ); + expect(screen.getByText("Confirmed IDOR")).toBeVisible(); + expect(screen.queryByText("/api/invoices/other")).toBeNull(); + expect(screen.getByText("7.1")).toBeVisible(); + expect(screen.getByTestId("finding-severity-dot-finding-1")).toHaveClass( + "bg-orange-500", + ); + expect(screen.getByText("Current Results")).toBeVisible(); + expect(screen.queryByText("Validation Standard")).toBeNull(); + expect(screen.queryByText("Evidence + working PoC")).toBeNull(); + expect(screen.queryByText("Endpoint")).toBeNull(); + expect(screen.queryByText("Source Chat")).toBeNull(); + expect(screen.getAllByText("Category").length).toBeGreaterThan(0); + expect(screen.getAllByText("Status").length).toBeGreaterThan(0); + expect(screen.getByText("Access Control / IDOR")).toBeVisible(); + expect(screen.getAllByText("Active").length).toBeGreaterThan(0); + expect(screen.getByRole("list", { name: "Findings" })).toBeVisible(); + expect(screen.getByLabelText("Filter by category")).toBeVisible(); + expect(screen.getByLabelText("Filter by status")).toBeVisible(); + expect(screen.getByLabelText("Filter by severity")).toBeVisible(); + expect(screen.queryByLabelText("Filter by source chat")).toBeNull(); + expect( + screen.getByRole("button", { name: "Export findings as CSV" }), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Start new scan" }), + ).toBeVisible(); + + fireEvent.change(screen.getByLabelText("Search findings"), { + target: { value: "CWE-639" }, + }); + await waitFor(() => { + expect(mockUsePaginatedQuery).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ search: "CWE-639" }), + { initialNumItems: 25 }, + ); + }); + expect(screen.getByText("Best matches for “CWE-639”")).toBeVisible(); + expect(window.location.search).toBe("?q=CWE-639"); + + fireEvent.change(screen.getByLabelText("Search findings"), { + target: { value: "" }, + }); + await waitFor(() => { + expect(window.location.search).toBe(""); + }); + expect(mockCapture).toHaveBeenCalledWith("findings_page_viewed"); + }); + + it("filters by category and lifecycle status", async () => { + render(); + + fireEvent.click(screen.getByLabelText("Filter by category")); + fireEvent.click(screen.getByRole("option", { name: "Injection" })); + await waitFor(() => { + expect(mockUsePaginatedQuery).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ category: "injection" }), + { initialNumItems: 25 }, + ); + }); + + fireEvent.click(screen.getByLabelText("Filter by status")); + fireEvent.click(screen.getByRole("option", { name: "Closed" })); + await waitFor(() => { + expect(mockUsePaginatedQuery).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + category: "injection", + status: "closed", + }), + { initialNumItems: 25 }, + ); + }); + expect(window.location.search).toContain("category=injection"); + expect(window.location.search).toContain("status=closed"); + }); + + it("exports the complete filtered summary as CSV", async () => { + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Export findings as CSV" }), + ); + + await waitFor(() => { + expect(mockConvexQuery).toHaveBeenCalledWith(expect.anything(), { + paginationOpts: { cursor: null, numItems: 25 }, + }); + expect(mockDownloadFile).toHaveBeenCalledWith( + expect.objectContaining({ + filename: expect.stringMatching(/^findings-\d{4}-\d{2}-\d{2}\.csv$/), + mimeType: "text/csv;charset=utf-8", + content: expect.stringContaining( + '"Confirmed IDOR","https://app.example.test","Access Control / IDOR","high","7.1","active"', + ), + }), + ); + }); + }); + + it("guards exported cells against spreadsheet formulas", async () => { + mockConvexQuery.mockResolvedValue({ + page: [ + { + ...mockFinding, + title: '=HYPERLINK("https://evil.example","Open")', + target: "+cmd|' /C calc'!A0", + }, + ], + isDone: true, + continueCursor: "", + }); + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Export findings as CSV" }), + ); + + await waitFor(() => { + expect(mockDownloadFile).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining( + `"'=HYPERLINK(""https://evil.example"",""Open"")","'+cmd|' /C calc'!A0"`, + ), + }), + ); + }); + }); + + it("drops legacy source-chat filter parameters", () => { + mockSearchParams.set("chat", "chat-1"); + window.history.replaceState({}, "", "/findings?chat=chat-1"); + + render(); + + expect(screen.queryByLabelText("Filter by source chat")).toBeNull(); + expect(window.location.search).toBe(""); + expect(mockUsePaginatedQuery).toHaveBeenLastCalledWith( + expect.anything(), + expect.not.objectContaining({ chatId: expect.anything() }), + { initialNumItems: 25 }, + ); + }); + + it("guides first-time users into a persistent Agent security test", () => { + mockUsePaginatedQuery.mockReturnValue({ + results: [], + status: "Exhausted", + loadMore: jest.fn(), + }); + + render(); + + expect(screen.getByText("No findings yet")).toBeVisible(); + expect( + screen.getByText(/Once it confirms a vulnerability with solid evidence/i), + ).toBeVisible(); + + fireEvent.click( + screen.getByRole("button", { name: "Start your first scan" }), + ); + + expect(mockCloseSidebar).toHaveBeenCalled(); + expect(mockInitializeNewChat).toHaveBeenCalled(); + expect(mockSetTemporaryChatsEnabled).toHaveBeenCalledWith(false); + expect(mockSetChatMode).toHaveBeenCalledWith("agent"); + expect(mockPush).toHaveBeenCalledWith("/"); + }); + + it("shows a reset action when search results are empty", async () => { + mockUsePaginatedQuery.mockReturnValue({ + results: [], + status: "Exhausted", + loadMore: jest.fn(), + }); + + render(); + fireEvent.change(screen.getByLabelText("Search findings"), { + target: { value: "missing target" }, + }); + + expect(await screen.findByText("No matching findings")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); + + expect(screen.getByLabelText("Search findings")).toHaveValue(""); + await waitFor(() => { + expect(screen.getByText("No findings yet")).toBeVisible(); + expect(window.location.search).toBe(""); + }); + }); + + it("opens and closes the reusable detail in a focused modal", async () => { + render(); + const findingRow = screen.getByRole("link", { + name: /Confirmed IDOR/i, + }); + expect(findingRow).toHaveAttribute("href", "/findings?finding=finding-1"); + fireEvent.click(findingRow); + const dialog = screen.getByRole("dialog", { + name: "Vulnerability Report", + }); + expect(dialog).toBeVisible(); + expect(dialog).toHaveClass("sm:max-w-6xl", "sm:rounded-2xl"); + expect(document.querySelector('[data-slot="dialog-overlay"]')).toHaveClass( + "bg-black/60", + "backdrop-blur-sm", + ); + expect(screen.getByText(mockFinding.description)).toBeVisible(); + expect( + screen.getByRole("link", { + name: "Open source message in Invoice test", + }), + ).toHaveAttribute("href", "/c/chat-1#message=message-1"); + expect(mockCapture).toHaveBeenCalledWith("finding_viewed", { + surface: "findings_page", + }); + + fireEvent.click( + screen.getByRole("button", { name: "Close vulnerability report" }), + ); + await waitFor(() => { + expect(screen.queryByText(mockFinding.description)).toBeNull(); + expect(findingRow).toHaveFocus(); + }); + expect(window.location.pathname).toBe("/findings"); + expect(window.location.search).toBe(""); + }); + + it("opens a finding directly from the URL", () => { + mockSearchParams.set("finding", "finding-1"); + window.history.replaceState({}, "", "/findings?finding=finding-1"); + + render(); + + expect(screen.getByText(mockFinding.description)).toBeVisible(); + expect( + screen.getByRole("button", { name: "Close vulnerability report" }), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Close Finding" })).toBeVisible(); + expect(mockCapture).toHaveBeenCalledWith("finding_viewed", { + surface: "findings_page", + }); + }); + + it("keeps a full-screen mobile close path and restores list focus", async () => { + render(); + const findingRow = screen.getByRole("link", { + name: /Confirmed IDOR/i, + }); + findingRow.focus(); + fireEvent.click(findingRow); + + expect( + screen.getByRole("dialog", { name: "Vulnerability Report" }), + ).toHaveClass("h-dvh", "w-screen"); + expect( + screen.getByRole("button", { name: "Back to Findings" }), + ).toBeVisible(); + expect(screen.getByText(mockFinding.description)).toBeVisible(); + + fireEvent.click(screen.getByRole("button", { name: "Back to Findings" })); + await waitFor(() => { + expect( + screen.queryByRole("dialog", { name: "Vulnerability Report" }), + ).toBeNull(); + expect(findingRow).toHaveFocus(); + }); + }); +}); diff --git a/app/(chat)/findings/page.tsx b/app/(chat)/findings/page.tsx new file mode 100644 index 000000000..3af3db5e8 --- /dev/null +++ b/app/(chat)/findings/page.tsx @@ -0,0 +1,901 @@ +"use client"; + +import Link from "next/link"; +import { Suspense, useDeferredValue, useEffect, useRef, useState } from "react"; +import { useConvex, useConvexAuth, usePaginatedQuery } from "convex/react"; +import { + ChevronRight, + Download, + Filter, + PanelLeft, + Play, + Search, + ShieldAlert, + ShieldCheck, + X, +} from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { api } from "@/convex/_generated/api"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FindingDetail } from "@/app/components/findings/FindingDetail"; +import { + getFindingSeverityClasses, + getFindingSeverityDotClasses, +} from "@/app/components/findings/FindingCard"; +import { FindingRelativeTime } from "@/app/components/findings/FindingTime"; +import { useGlobalState } from "@/app/contexts/GlobalState"; +import { navigateToAuth } from "@/app/hooks/useTauri"; +import { captureAuthenticatedEvent } from "@/lib/analytics/client"; +import { + FINDING_CATEGORIES, + FINDING_CATEGORY_LABELS, +} from "@/lib/findings/category"; +import { downloadFile } from "@/lib/utils/file-download"; +import { cn } from "@/lib/utils"; +import type { + FindingCategory, + FindingSeverity, + FindingStatus, + FindingSummary, +} from "@/types/finding"; +import { toast } from "sonner"; + +const SEVERITIES: FindingSeverity[] = [ + "critical", + "high", + "medium", + "low", + "info", +]; + +const getSeverityFilter = (value: string | null): "all" | FindingSeverity => + SEVERITIES.includes(value as FindingSeverity) + ? (value as FindingSeverity) + : "all"; + +const getCategoryFilter = (value: string | null): "all" | FindingCategory => + FINDING_CATEGORIES.includes(value as FindingCategory) + ? (value as FindingCategory) + : "all"; + +const getStatusFilter = (value: string | null): "all" | FindingStatus => + value === "active" || value === "closed" ? value : "all"; + +const escapeCsvCell = (value: string | number) => { + const raw = String(value); + const guarded = /^[=+\-@\t\r]/.test(raw) ? `'${raw}` : raw; + + return `"${guarded.replaceAll('"', '""')}"`; +}; + +const getFindingsHref = ({ + search, + severity, + category, + status, + findingId, +}: { + search: string; + severity: "all" | FindingSeverity; + category: "all" | FindingCategory; + status: "all" | FindingStatus; + findingId: string | null; +}) => { + const params = new URLSearchParams(); + if (search) params.set("q", search); + if (severity !== "all") params.set("severity", severity); + if (category !== "all") params.set("category", category); + if (status !== "all") params.set("status", status); + if (findingId) params.set("finding", findingId); + const query = params.toString(); + return query ? `/findings?${query}` : "/findings"; +}; + +const updateFindingsHistory = ( + href: string, + method: "push" | "replace" = "replace", +) => { + window.history[method === "push" ? "pushState" : "replaceState"]( + null, + "", + href, + ); +}; + +function FindingsPageContent() { + const router = useRouter(); + const convex = useConvex(); + const searchParams = useSearchParams(); + const { isLoading, isAuthenticated } = useConvexAuth(); + const { + setChatSidebarOpen, + closeSidebar, + initializeNewChat, + setChatMode, + setTemporaryChatsEnabled, + } = useGlobalState(); + const [search, setSearch] = useState(() => searchParams.get("q") ?? ""); + const deferredSearch = useDeferredValue(search.trim()); + const [severity, setSeverity] = useState<"all" | FindingSeverity>(() => + getSeverityFilter(searchParams.get("severity")), + ); + const [category, setCategory] = useState<"all" | FindingCategory>(() => + getCategoryFilter(searchParams.get("category")), + ); + const [status, setStatus] = useState<"all" | FindingStatus>(() => + getStatusFilter(searchParams.get("status")), + ); + const [selectedFindingId, setSelectedFindingId] = useState( + () => searchParams.get("finding"), + ); + const selectedFindingTriggerRef = useRef(null); + const [isExporting, setIsExporting] = useState(false); + + useEffect(() => { + if (!isLoading && !isAuthenticated) navigateToAuth("/login"); + }, [isAuthenticated, isLoading]); + + useEffect(() => { + if (!isAuthenticated) return; + captureAuthenticatedEvent("findings_page_viewed"); + }, [isAuthenticated]); + + useEffect(() => { + const syncFromBrowserHistory = () => { + const params = new URLSearchParams(window.location.search); + if (params.has("chat")) { + params.delete("chat"); + const query = params.toString(); + updateFindingsHistory(query ? `/findings?${query}` : "/findings"); + } + setSearch(params.get("q") ?? ""); + setSeverity(getSeverityFilter(params.get("severity"))); + setCategory(getCategoryFilter(params.get("category"))); + setStatus(getStatusFilter(params.get("status"))); + setSelectedFindingId(params.get("finding")); + }; + + syncFromBrowserHistory(); + window.addEventListener("popstate", syncFromBrowserHistory); + return () => window.removeEventListener("popstate", syncFromBrowserHistory); + }, []); + + useEffect(() => { + const currentSearch = + new URLSearchParams(window.location.search).get("q") ?? ""; + if (currentSearch === deferredSearch) return; + updateFindingsHistory( + getFindingsHref({ + search: deferredSearch, + severity, + category, + status, + findingId: selectedFindingId, + }), + ); + }, [category, deferredSearch, selectedFindingId, severity, status]); + + useEffect(() => { + if (!selectedFindingId) return; + captureAuthenticatedEvent("finding_viewed", { surface: "findings_page" }); + }, [selectedFindingId]); + + const findingsQuery = usePaginatedQuery( + api.findings.listFindings, + isAuthenticated + ? { + ...(deferredSearch ? { search: deferredSearch } : {}), + ...(severity !== "all" ? { severity } : {}), + ...(category !== "all" ? { category } : {}), + ...(status !== "all" ? { status } : {}), + } + : "skip", + { initialNumItems: 25 }, + ); + const findings = (findingsQuery.results ?? []) as FindingSummary[]; + const hasActiveFilters = + Boolean(deferredSearch) || + severity !== "all" || + category !== "all" || + status !== "all"; + const visibleSeverityCounts = findings.reduce< + Record + >( + (counts, finding) => { + counts[finding.severity] += 1; + return counts; + }, + { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, + ); + const visibleFindingCount = + findingsQuery.status === "Exhausted" + ? String(findings.length) + : `${findings.length}+`; + const visibleStatusCounts = findings.reduce>( + (counts, finding) => { + counts[finding.status] += 1; + return counts; + }, + { active: 0, closed: 0 }, + ); + + const selectFinding = (findingId: string, trigger: HTMLAnchorElement) => { + selectedFindingTriggerRef.current = trigger; + setSelectedFindingId(findingId); + updateFindingsHistory( + getFindingsHref({ + search: deferredSearch, + severity, + category, + status, + findingId, + }), + selectedFindingId ? "replace" : "push", + ); + closeSidebar(); + }; + + const clearSelectedFinding = () => { + setSelectedFindingId(null); + updateFindingsHistory( + getFindingsHref({ + search: deferredSearch, + severity, + category, + status, + findingId: null, + }), + ); + }; + + const startNewScan = () => { + closeSidebar(); + initializeNewChat(); + setTemporaryChatsEnabled(false); + setChatMode("agent"); + router.push("/"); + }; + + const clearFilters = () => { + setSearch(""); + setSeverity("all"); + setCategory("all"); + setStatus("all"); + updateFindingsHistory( + getFindingsHref({ + search: "", + severity: "all", + category: "all", + status: "all", + findingId: selectedFindingId, + }), + ); + }; + + const handleExport = async () => { + setIsExporting(true); + try { + const filters = { + ...(deferredSearch ? { search: deferredSearch } : {}), + ...(severity !== "all" ? { severity } : {}), + ...(category !== "all" ? { category } : {}), + ...(status !== "all" ? { status } : {}), + }; + const exportedFindings: FindingSummary[] = []; + let cursor: string | null = null; + let isDone = false; + + while (!isDone && exportedFindings.length < 5_000) { + const result: { + page: FindingSummary[]; + isDone: boolean; + continueCursor: string; + } = await convex.query(api.findings.listFindings, { + ...filters, + paginationOpts: { cursor, numItems: 25 }, + }); + exportedFindings.push(...result.page); + isDone = result.isDone || !result.continueCursor; + cursor = result.continueCursor || null; + } + + const rows = exportedFindings + .slice(0, 5_000) + .map((finding) => [ + finding.title, + finding.target, + FINDING_CATEGORY_LABELS[finding.category], + finding.severity, + finding.cvss_score.toFixed(1), + finding.status, + new Date(finding.created_at).toISOString(), + ]); + const csv = [ + ["Title", "Target", "Category", "Severity", "CVSS", "Status", "Found"], + ...rows, + ] + .map((row) => row.map(escapeCsvCell).join(",")) + .join("\n"); + + await downloadFile({ + filename: `findings-${new Date().toISOString().slice(0, 10)}.csv`, + content: csv, + mimeType: "text/csv;charset=utf-8", + }); + if (!isDone) { + toast.warning("Exported the first 5,000 matching findings."); + } + } catch { + toast.error("Could not export findings. Try again."); + } finally { + setIsExporting(false); + } + }; + + if (isLoading || !isAuthenticated) { + return ( +
+ Loading findings… +
+ ); + } + + return ( +
+
+
+ +
+
+
+

Findings

+

+ Confirmed vulnerabilities, evidence, and remediation guidance +

+
+
+ + +
+
+ +
+
+ {findings.length > 0 ? ( +
+
+
+
+ Current Results +
+
+ {visibleFindingCount} +
+
+
+
+ By Severity +
+
+ {SEVERITIES.filter( + (value) => visibleSeverityCounts[value] > 0, + ).map((value) => ( + + {value[0].toUpperCase() + value.slice(1)} + + {visibleSeverityCounts[value]} + + + ))} +
+
+
+
+ Active +
+
+
+
+
+
+ Closed +
+
+
+
+
+
+ ) : null} + +
+ + +
+
+
+

+ {findingsQuery.status === "LoadingFirstPage" + ? "Findings" + : `${visibleFindingCount} ${ + findings.length === 1 ? "finding" : "findings" + }`} +

+

+ {deferredSearch + ? `Best matches for “${deferredSearch}”` + : "Newest confirmed findings first"} +

+
+
+ + {findingsQuery.status === "LoadingFirstPage" ? ( +
+ Loading findings… +
+ ) : findings.length === 0 ? ( +
+
+
+
+

+ {hasActiveFilters + ? "No matching findings" + : "No findings yet"} +

+

+ {hasActiveFilters + ? "Try a different search or clear your filters to see all findings." + : "Use Agent to test a target. Once it confirms a vulnerability with solid evidence and a working proof of concept, you’ll find it here."} +

+
+ {hasActiveFilters ? ( + + ) : ( + + )} +
+ ) : ( + <> +
+
+
+
+
+ + {selectedFindingId && ( + { + if (!open) clearSelectedFinding(); + }} + > + { + event.preventDefault(); + if (selectedFindingTriggerRef.current?.isConnected) { + selectedFindingTriggerRef.current.focus(); + } + }} + className="inset-0 flex h-dvh w-screen max-w-none translate-x-0 translate-y-0 flex-col gap-0 overflow-hidden rounded-none border-0 p-0 shadow-none sm:inset-auto sm:top-1/2 sm:left-1/2 sm:h-[calc(100dvh-3rem)] sm:w-[calc(100vw-3rem)] sm:max-w-6xl sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl sm:border sm:shadow-2xl" + > + Vulnerability Report + + + +
+ +
+
+
+ )} +
+ ); +} + +export default function FindingsPage() { + return ( + + Loading findings… + + } + > + + + ); +} diff --git a/app/components/ComputerSidebar.tsx b/app/components/ComputerSidebar.tsx index 6b14bd27c..14f2eb209 100644 --- a/app/components/ComputerSidebar.tsx +++ b/app/components/ComputerSidebar.tsx @@ -13,6 +13,7 @@ import { Play, SkipBack, SkipForward, + X, } from "lucide-react"; import { useState, useEffect, useRef, useMemo } from "react"; import { useGlobalState } from "../contexts/GlobalState"; @@ -31,11 +32,15 @@ import { isSidebarProxy, isSidebarWebSearch, isSidebarNotes, + isSidebarFinding, + isSidebarToolError, isSidebarSharedFiles, type SidebarContent, type ChatStatus, type NoteCategory, } from "@/types/chat"; +import { FindingDetail } from "./findings/FindingDetail"; +import { ToolErrorDetail } from "./tools/ToolErrorDetail"; import type { Id } from "@/convex/_generated/dataModel"; import type { FilePart } from "@/types/file"; import { FilePartRenderer } from "./FilePartRenderer"; @@ -439,6 +444,8 @@ export const ComputerSidebarBase: React.FC = ({ const isProxy = isSidebarProxy(sidebarContent); const isWebSearch = isSidebarWebSearch(sidebarContent); const isNotes = isSidebarNotes(sidebarContent); + const isFinding = isSidebarFinding(sidebarContent); + const isToolError = isSidebarToolError(sidebarContent); const isSharedFiles = isSidebarSharedFiles(sidebarContent); // Use resolved versions for display metadata so streaming updates are reflected @@ -452,9 +459,13 @@ export const ComputerSidebarBase: React.FC = ({ const icon = getSidebarIcon(displayContent); const toolName = getToolName(displayContent); const displayTarget = getDisplayTarget(displayContent); - const headerTitle = isProxy - ? "HackerAI\u2019s Proxy" - : "HackerAI\u2019s Computer"; + const headerTitle = isFinding + ? "Finding" + : isToolError + ? "Tool details" + : isProxy + ? "HackerAI\u2019s Proxy" + : "HackerAI\u2019s Computer"; const handleClose = () => { closeSidebar(); @@ -470,6 +481,8 @@ export const ComputerSidebarBase: React.FC = ({ setIsWrapped(!isWrapped); }; + const usesCloseAction = isFinding || isToolError; + return (
@@ -486,14 +499,28 @@ export const ComputerSidebarBase: React.FC = ({ type="button" onClick={handleClose} className="w-7 h-7 relative rounded-md inline-flex items-center justify-center gap-2.5 cursor-pointer hover:bg-muted/50 transition-colors" - aria-label="Minimize sidebar" + aria-label={ + usesCloseAction ? "Close details" : "Minimize sidebar" + } tabIndex={0} onKeyDown={handleKeyDown} > - + {usesCloseAction ? ( +
@@ -504,8 +531,17 @@ export const ComputerSidebarBase: React.FC = ({
- HackerAI is using{" "} - {toolName} + {isToolError ? ( + <> + {toolName} needs + attention + + ) : ( + <> + HackerAI is using{" "} + {toolName} + + )}
= ({
Notes
+ ) : isFinding ? ( +
+ Vulnerability report +
+ ) : isToolError ? ( +
+ Error details +
) : isSharedFiles ? (
Shared Files @@ -558,49 +602,53 @@ export const ComputerSidebarBase: React.FC = ({
{/* Action buttons - far right */} - {!isWebSearch && !isNotes && !isSharedFiles && ( - - )} + {!isWebSearch && + !isNotes && + !isFinding && + !isToolError && + !isSharedFiles && ( + + )}
{/* Content */} @@ -750,8 +798,7 @@ export const ComputerSidebarBase: React.FC = ({ key={file.fileId || `file-${index}`} part={{ fileId: file.fileId as - | Id<"files"> - | undefined, + Id<"files"> | undefined, s3Key: file.s3Key, name: file.name, filename: file.name, @@ -766,6 +813,16 @@ export const ComputerSidebarBase: React.FC = ({
)} + {isFinding && ( + + )} + {isToolError && ( + + )} {isNotes && (
diff --git a/app/components/MessagePartHandler.tsx b/app/components/MessagePartHandler.tsx index 129eb03cf..46b117d3f 100644 --- a/app/components/MessagePartHandler.tsx +++ b/app/components/MessagePartHandler.tsx @@ -8,12 +8,16 @@ import { HttpRequestToolHandler } from "./tools/HttpRequestToolHandler"; import { WebToolHandler } from "./tools/WebToolHandler"; import { TodoToolHandler } from "./tools/TodoToolHandler"; import { NotesToolHandler } from "./tools/NotesToolHandler"; +import { FindingToolHandler } from "./tools/FindingToolHandler"; +import { ToolValidationErrorHandler } from "./tools/ToolErrorHandler"; +import { FindingCard } from "./findings/FindingCard"; import { ProxyToolHandler } from "./tools/ProxyToolHandler"; import { GetTerminalFilesHandler } from "./tools/GetTerminalFilesHandler"; import { SummarizationHandler } from "./tools/SummarizationHandler"; import type { ChatStatus } from "@/types"; import type { FileDetails } from "@/types/file"; import { ReasoningHandler } from "./ReasoningHandler"; +import { isToolInputValidationError } from "@/lib/chat/tool-error-display"; interface MessagePartHandlerProps { message: UIMessage; @@ -64,7 +68,7 @@ function deepEqual(a: any, b: any): boolean { } // Custom comparison for MessagePartHandler to minimize re-renders -function arePropsEqual( +export function areMessagePartHandlerPropsEqual( prevProps: MessagePartHandlerProps, nextProps: MessagePartHandlerProps, ): boolean { @@ -84,6 +88,8 @@ function arePropsEqual( ) return false; + if (prevProps.part?.type !== nextProps.part?.type) return false; + // Shared file details change for get_terminal_files during streaming // Must be checked before the part reference check below, because the part // reference may be stable while new file metadata arrives via the stream. @@ -103,6 +109,10 @@ function arePropsEqual( ) return false; + if (prevProps.part?.type === "data-shared-finding") { + return deepEqual(prevProps.part.data, nextProps.part.data); + } + // For tool parts, compare state and output which change during streaming if ( prevProps.part?.type?.startsWith("tool-") || @@ -111,10 +121,12 @@ function arePropsEqual( return ( prevProps.part.state === nextProps.part.state && prevProps.part.toolCallId === nextProps.part.toolCallId && + prevProps.part.toolName === nextProps.part.toolName && prevProps.part.output === nextProps.part.output && + prevProps.part.errorText === nextProps.part.errorText && deepEqual(prevProps.part.approval, nextProps.part.approval) && // Tool input is an object — reference check first (fast path), then - // shallow comparison so new objects with identical content don't re-render. + // deep comparison so new objects with identical content don't re-render. (prevProps.part.input === nextProps.part.input || deepEqual(prevProps.part.input, nextProps.part.input)) ); @@ -148,6 +160,26 @@ export const MessagePartHandler = memo(function MessagePartHandler({ terminalOutputByToolCallId, sharedFileDetails, }: MessagePartHandlerProps) { + const validationToolType = + typeof part.type === "string" && part.type.startsWith("tool-") + ? part.type + : part.type === "dynamic-tool" && typeof part.toolName === "string" + ? `tool-${part.toolName}` + : null; + if ( + validationToolType && + part.state === "output-error" && + isToolInputValidationError(part.errorText) + ) { + return ( + + ); + } + // Main switch for different part types switch (part.type) { case "text": { @@ -257,6 +289,19 @@ export const MessagePartHandler = memo(function MessagePartHandler({ ); + case "tool-create_vulnerability_report": + return ; + + case "data-shared-finding": + return part.data ? ( + + ) : null; + case "tool-list_requests": return ( import("./AllFilesDialog").then((module) => module.AllFilesDialog), @@ -120,6 +123,11 @@ export const Messages = ({ () => messages.filter((msg) => !msg.metadata?.isAutoContinue), [messages], ); + const sourceMessageId = useSourceMessageNavigation({ + loadedMessageCount: messages.length, + paginationStatus, + loadMore, + }); // Memoize expensive calculations const lastAssistantMessageIndex = useMemo(() => { @@ -313,46 +321,59 @@ export const Messages = ({
)} - {visibleMessages.map((message, index) => ( - - ))} + {visibleMessages.map((message, index) => { + const isSourceMessage = sourceMessageId === message.id; + + return ( +
+ +
+ ); + })} {/* Processing status - upload/loading dots always separate, summarization only when no content */} {(showSummarizationSeparately || diff --git a/app/components/SidebarHeader.tsx b/app/components/SidebarHeader.tsx index aa331bd34..7514e0797 100644 --- a/app/components/SidebarHeader.tsx +++ b/app/components/SidebarHeader.tsx @@ -1,15 +1,19 @@ "use client"; import { useState, useEffect, useMemo, FC } from "react"; +import { usePathname, useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { PanelLeft, Sidebar as SidebarIcon, SquarePen, Search, + ShieldAlert, } from "lucide-react"; import { useSidebar } from "@/components/ui/sidebar"; import { HackerAISVG } from "@/components/icons/hackerai-svg"; +import { useGlobalState } from "../contexts/GlobalState"; +import { useIsMobile } from "@/hooks/use-mobile"; import { useChats } from "../hooks/useChats"; import { useStartNewChat } from "../hooks/useStartNewChat"; import { MessageSearchDialog } from "./MessageSearchDialog"; @@ -36,6 +40,10 @@ const SidebarHeaderContentImpl: FC = ({ toggleSidebar, }) => { const startNewChat = useStartNewChat(); + const isMobile = useIsMobile(); + const router = useRouter(); + const pathname = usePathname(); + const { setChatSidebarOpen, closeSidebar } = useGlobalState(); // Search dialog state const [isSearchOpen, setIsSearchOpen] = useState(false); @@ -79,6 +87,12 @@ const SidebarHeaderContentImpl: FC = ({ setIsSearchOpen(true); }; + const handleFindingsOpen = () => { + closeSidebar(); + if (isMobile) setChatSidebarOpen(false); + router.push("/findings"); + }; + const handleSearchClose = () => { setIsSearchOpen(false); }; @@ -140,6 +154,21 @@ const SidebarHeaderContentImpl: FC = ({
+ +
+ +
@@ -210,6 +239,23 @@ const SidebarHeaderContentImpl: FC = ({ + +
+ +
{/* Search Dialog */} diff --git a/app/components/__tests__/ComputerSidebar.reconnect.test.tsx b/app/components/__tests__/ComputerSidebar.reconnect.test.tsx index 954427d2e..9e73b8168 100644 --- a/app/components/__tests__/ComputerSidebar.reconnect.test.tsx +++ b/app/components/__tests__/ComputerSidebar.reconnect.test.tsx @@ -1,6 +1,6 @@ import "@testing-library/jest-dom"; import React from "react"; -import { act, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, @@ -10,6 +10,7 @@ import { jest, } from "@jest/globals"; import type { SidebarContent } from "@/types/chat"; +import { createToolInputErrorContent } from "@/lib/chat/tool-error-display"; jest.mock("next/dynamic", () => ({ __esModule: true, @@ -153,4 +154,45 @@ describe("ComputerSidebar reconnect behavior", () => { expect.objectContaining({ toolCallId: "tool-other" }), ); }); + + it("shows safe tool failure details and provides a clear close action", () => { + const closeSidebar = jest.fn(); + const rawError = + 'Invalid input for tool create_vulnerability_report: Value: {"evidence":"private"}'; + const toolError = createToolInputErrorContent({ + toolType: "tool-create_vulnerability_report", + toolCallId: "finding-error", + }); + + render( + , + ); + + expect( + screen.getByText("The vulnerability report wasn’t saved"), + ).toBeVisible(); + expect(screen.getByText("What to do next")).toBeVisible(); + expect(screen.queryByText(rawError)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Close details" })); + expect(closeSidebar).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/components/__tests__/MessagePartHandler.memo.test.ts b/app/components/__tests__/MessagePartHandler.memo.test.ts new file mode 100644 index 000000000..3f8706a89 --- /dev/null +++ b/app/components/__tests__/MessagePartHandler.memo.test.ts @@ -0,0 +1,102 @@ +import { areMessagePartHandlerPropsEqual } from "../MessagePartHandler"; + +const props = (part: Record) => + ({ + message: { id: "message-1", role: "assistant", parts: [part] }, + part, + partIndex: 0, + status: "ready", + }) as any; + +describe("MessagePartHandler memoization", () => { + it("re-renders when shared finding metadata changes", () => { + const original = props({ + type: "data-shared-finding", + data: { + title: "Confirmed IDOR", + target: "app.example.test", + severity: "high", + cvss_score: 7.1, + }, + }); + const updated = props({ + type: "data-shared-finding", + data: { + title: "Confirmed IDOR", + target: "api.example.test", + severity: "critical", + cvss_score: 9.1, + }, + }); + + expect(areMessagePartHandlerPropsEqual(original, updated)).toBe(false); + }); + + it("never treats different part types as equal", () => { + const sharedFinding = props({ + type: "data-shared-finding", + data: { title: "Confirmed IDOR" }, + }); + const otherData = props({ + type: "data-notification", + data: { title: "Confirmed IDOR" }, + }); + + expect(areMessagePartHandlerPropsEqual(sharedFinding, otherData)).toBe( + false, + ); + }); + + it("keeps equivalent shared finding data memoized", () => { + const first = props({ + type: "data-shared-finding", + data: { title: "Confirmed IDOR", cvss_score: 7.1 }, + }); + const second = props({ + type: "data-shared-finding", + data: { title: "Confirmed IDOR", cvss_score: 7.1 }, + }); + + expect(areMessagePartHandlerPropsEqual(first, second)).toBe(true); + }); + + it("re-renders when a tool error changes classification", () => { + const runtimeFailure = props({ + type: "tool-shell", + state: "output-error", + toolCallId: "call-1", + input: { command: "npm test" }, + errorText: "Sandbox unavailable", + }); + const validationFailure = props({ + type: "tool-shell", + state: "output-error", + toolCallId: "call-1", + input: { command: "npm test" }, + errorText: "Invalid input for tool shell: Type validation failed", + }); + + expect( + areMessagePartHandlerPropsEqual(runtimeFailure, validationFailure), + ).toBe(false); + }); + + it("re-renders when a dynamic tool name changes", () => { + const firstTool = props({ + type: "dynamic-tool", + toolName: "first_tool", + state: "output-error", + toolCallId: "call-1", + errorText: "Invalid tool arguments", + }); + const secondTool = props({ + type: "dynamic-tool", + toolName: "second_tool", + state: "output-error", + toolCallId: "call-1", + errorText: "Invalid tool arguments", + }); + + expect(areMessagePartHandlerPropsEqual(firstTool, secondTool)).toBe(false); + }); +}); diff --git a/app/components/__tests__/SidebarHeader.test.tsx b/app/components/__tests__/SidebarHeader.test.tsx new file mode 100644 index 000000000..d8f57f741 --- /dev/null +++ b/app/components/__tests__/SidebarHeader.test.tsx @@ -0,0 +1,61 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import SidebarHeader from "../SidebarHeader"; + +let pathname = "/findings"; + +jest.mock("next/navigation", () => ({ + usePathname: () => pathname, + useRouter: () => ({ push: jest.fn() }), +})); +jest.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false })); +jest.mock("@/app/hooks/useChats", () => ({ useChats: jest.fn() })); +jest.mock("@/app/contexts/GlobalState", () => ({ + useGlobalState: () => ({ + setChatSidebarOpen: jest.fn(), + closeSidebar: jest.fn(), + initializeNewChat: jest.fn(), + setTemporaryChatsEnabled: jest.fn(), + }), +})); +jest.mock("../MessageSearchDialog", () => ({ + MessageSearchDialog: () => null, +})); + +describe("SidebarHeader findings navigation", () => { + afterEach(() => { + pathname = "/findings"; + }); + + it.each([true, false])( + "marks the findings destination as current when collapsed is %s", + (isCollapsed) => { + render( + , + ); + + expect( + screen.getByRole("button", { name: "Open findings" }), + ).toHaveAttribute("aria-current", "page"); + }, + ); + + it("does not mark findings current on another route", () => { + pathname = "/"; + render( + , + ); + + expect( + screen.getByRole("button", { name: "Open findings" }), + ).not.toHaveAttribute("aria-current"); + }); +}); diff --git a/app/components/computer-sidebar-utils.tsx b/app/components/computer-sidebar-utils.tsx index 36d9efa1a..3c72da464 100644 --- a/app/components/computer-sidebar-utils.tsx +++ b/app/components/computer-sidebar-utils.tsx @@ -7,8 +7,10 @@ import { Search, FolderSearch, StickyNote, + ShieldAlert, FileDown, Radar, + CircleAlert, } from "lucide-react"; import { isSidebarFile, @@ -16,6 +18,8 @@ import { isSidebarProxy, isSidebarWebSearch, isSidebarNotes, + isSidebarFinding, + isSidebarToolError, isSidebarSharedFiles, type SidebarContent, type NoteCategory, @@ -163,6 +167,10 @@ export function getActionText(content: SidebarContent): string { return completedActionMap[content.action]; } + if (isSidebarFinding(content)) return "Saved finding"; + + if (isSidebarToolError(content)) return "Needs attention"; + if (isSidebarSharedFiles(content)) { if (content.isExecuting) { const ready = content.files.length; @@ -198,6 +206,10 @@ export function getSidebarIcon(content: SidebarContent): React.ReactNode { if (isSidebarTerminal(content)) return ; if (isSidebarWebSearch(content)) return ; if (isSidebarNotes(content)) return ; + if (isSidebarFinding(content)) return ; + if (isSidebarToolError(content)) { + return ; + } if (isSidebarSharedFiles(content)) return ; return ; } @@ -215,6 +227,8 @@ export function getToolName(content: SidebarContent): string { } if (isSidebarWebSearch(content)) return "Search"; if (isSidebarNotes(content)) return "Notes"; + if (isSidebarFinding(content)) return "Findings"; + if (isSidebarToolError(content)) return content.toolName; if (isSidebarSharedFiles(content)) return "Downloads"; return "Tool"; } @@ -244,6 +258,8 @@ export function getDisplayTarget(content: SidebarContent): string { } return content.affectedTitle || ""; } + if (isSidebarFinding(content)) return content.title; + if (isSidebarToolError(content)) return content.action; if (isSidebarSharedFiles(content)) { const names = content.files.length ? content.files.map((f) => f.name) diff --git a/app/components/findings/FindingCard.tsx b/app/components/findings/FindingCard.tsx new file mode 100644 index 000000000..f3b4c8a49 --- /dev/null +++ b/app/components/findings/FindingCard.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { ShieldAlert } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { FindingSeverity } from "@/types/finding"; + +const severityClasses: Record = { + critical: "border-red-500/30 bg-red-500/10 text-red-500", + high: "border-orange-500/30 bg-orange-500/10 text-orange-500", + medium: "border-yellow-500/30 bg-yellow-500/10 text-yellow-500", + low: "border-blue-500/30 bg-blue-500/10 text-blue-500", + info: "border-slate-500/30 bg-slate-500/10 text-slate-500", +}; + +const severityDotClasses: Record = { + critical: "bg-red-500", + high: "bg-orange-500", + medium: "bg-yellow-500", + low: "bg-blue-500", + info: "bg-slate-500", +}; + +export const getFindingSeverityClasses = (severity: FindingSeverity) => + severityClasses[severity]; + +export const getFindingSeverityDotClasses = (severity: FindingSeverity) => + severityDotClasses[severity]; + +export function FindingCard({ + title, + target, + severity, + cvssScore, + onClick, + className, +}: { + title: string; + target: string; + severity: FindingSeverity; + cvssScore: number; + onClick?: () => void; + className?: string; +}) { + const content = ( + <> +
+
+
+
+ {title} +
+
+ {target} +
+
+
+ {severity} · {cvssScore.toFixed(1)} +
+ + ); + + const classes = cn( + "flex w-full max-w-xl items-center gap-3 rounded-xl border border-border bg-muted/20 p-3", + onClick && + "cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + className, + ); + + return onClick ? ( + + ) : ( +
{content}
+ ); +} diff --git a/app/components/findings/FindingDetail.tsx b/app/components/findings/FindingDetail.tsx new file mode 100644 index 000000000..f5f717953 --- /dev/null +++ b/app/components/findings/FindingDetail.tsx @@ -0,0 +1,950 @@ +"use client"; + +import Link from "next/link"; +import { useMutation, useQuery } from "convex/react"; +import { api } from "@/convex/_generated/api"; +import { + ArrowLeft, + CheckCircle2, + ChevronDown, + Clock3, + Copy, + LockKeyhole, + MessageSquareText, + ShieldAlert, + ShieldCheck, + Target, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Textarea } from "@/components/ui/textarea"; +import { MemoizedMarkdown } from "@/app/components/MemoizedMarkdown"; +import { FINDING_CATEGORY_LABELS } from "@/lib/findings/category"; +import { + FINDING_CLOSURE_CONTEXT_MAX, + FINDING_CLOSURE_REASON_LABELS, + FINDING_CLOSURE_REASONS, +} from "@/lib/findings/lifecycle"; +import type { + Cvss31Breakdown, + FindingClosureReason, + FindingDetailRecord, +} from "@/types/finding"; +import { cn } from "@/lib/utils"; +import { getSourceMessageHref } from "@/lib/findings/source-message"; +import { + getFindingSeverityClasses, + getFindingSeverityDotClasses, +} from "./FindingCard"; +import { FindingDiscoveredAt } from "./FindingTime"; + +const CVSS_METRICS: Array<{ + key: keyof Cvss31Breakdown; + label: string; +}> = [ + { key: "attack_vector", label: "Attack Vector" }, + { key: "attack_complexity", label: "Attack Complexity" }, + { key: "privileges_required", label: "Privileges Required" }, + { key: "user_interaction", label: "User Interaction" }, + { key: "scope", label: "Scope" }, + { key: "confidentiality", label: "Confidentiality" }, + { key: "integrity", label: "Integrity" }, + { key: "availability", label: "Availability" }, +]; + +const CVSS_VALUE_LABELS: Record< + keyof Cvss31Breakdown, + Record +> = { + attack_vector: { + N: "Network", + A: "Adjacent", + L: "Local", + P: "Physical", + }, + attack_complexity: { L: "Low", H: "High" }, + privileges_required: { N: "None", L: "Low", H: "High" }, + user_interaction: { N: "None", R: "Required" }, + scope: { U: "Unchanged", C: "Changed" }, + confidentiality: { N: "None", L: "Low", H: "High" }, + integrity: { N: "None", L: "Low", H: "High" }, + availability: { N: "None", L: "Low", H: "High" }, +}; + +const CVSS_VALUE_EXPLANATIONS: Record< + keyof Cvss31Breakdown, + Record +> = { + attack_vector: { + N: "Reachable over a network", + A: "Requires access to an adjacent network", + L: "Requires local system access", + P: "Requires physical access", + }, + attack_complexity: { + L: "No special conditions are required", + H: "Requires conditions beyond the attacker's control", + }, + privileges_required: { + N: "No existing account or privileges are required", + L: "Requires basic user privileges", + H: "Requires elevated privileges", + }, + user_interaction: { + N: "No separate user action is required", + R: "A separate user must take an action", + }, + scope: { + U: "Impact stays within the vulnerable component's security boundary", + C: "Impact crosses into another component's security boundary", + }, + confidentiality: { + N: "No confidentiality impact was demonstrated", + L: "Limited confidential information can be exposed", + H: "Broad or critical confidential information can be exposed", + }, + integrity: { + N: "No integrity impact was demonstrated", + L: "Limited data or behavior can be modified", + H: "Broad or critical data or behavior can be modified", + }, + availability: { + N: "No availability impact was demonstrated", + L: "Availability can be partially reduced", + H: "Availability can be severely disrupted", + }, +}; + +const formatLabel = (value: string) => + value.charAt(0).toUpperCase() + value.slice(1).replaceAll("_", " "); + +const CopyTextButton = ({ + value, + label, + successMessage, +}: { + value: string; + label: string; + successMessage: string; +}) => { + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(value); + toast.success(successMessage); + } catch { + toast.error("Could not copy. Try again."); + } + }; + + return ( + + ); +}; + +const DetailSection = ({ + id, + title, + children, +}: { + id?: string; + title: string; + children: React.ReactNode; +}) => ( +
+

+ {title} +

+
+ {children} +
+
+); + +const MarkdownSection = ({ + id, + title, + value, +}: { + id?: string; + title: string; + value: string; +}) => ( + + + +); + +export function FindingDetail({ + findingId, + finding: suppliedFinding, + surface = "detail", + className, + onRequestClose, +}: { + findingId?: string; + finding?: FindingDetailRecord | null; + surface?: "computer_sidebar" | "findings_page" | "detail"; + className?: string; + onRequestClose?: () => void; +}) { + const queriedFinding = useQuery( + api.findings.getFinding, + suppliedFinding !== undefined || !findingId ? "skip" : { findingId }, + ) as FindingDetailRecord | null | undefined; + const closeFinding = useMutation(api.findings.closeFinding); + const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const [closureReason, setClosureReason] = + useState("already_fixed"); + const [closureContext, setClosureContext] = useState(""); + const [localClosure, setLocalClosure] = useState< + | Pick< + FindingDetailRecord, + "status" | "closure_reason" | "closure_context" | "closed_at" + > + | undefined + >(); + const persistedFinding = + suppliedFinding !== undefined ? suppliedFinding : queriedFinding; + + if (persistedFinding === undefined) { + return ( +
+ Loading finding… +
+ ); + } + + if (!persistedFinding) { + return ( +
+
+ ); + } + + const finding = localClosure + ? { ...persistedFinding, ...localClosure } + : persistedFinding; + + const handleCloseFinding = async () => { + if (!closureContext.trim()) return; + setIsClosing(true); + try { + const result = await closeFinding({ + findingId: finding.finding_id, + reason: closureReason, + context: closureContext, + }); + if (result.closed) { + setLocalClosure({ + status: "closed", + closure_reason: closureReason, + closure_context: closureContext.trim(), + closed_at: result.closed_at, + }); + setIsCloseDialogOpen(false); + setClosureContext(""); + toast.success("Finding closed"); + } else if (result.already_closed) { + setIsCloseDialogOpen(false); + toast.info("This finding is already closed."); + } + } catch { + toast.error("Could not close finding. Try again."); + } finally { + setIsClosing(false); + } + }; + + const sectionId = (section: string) => `${finding.finding_id}-${section}`; + + return ( +
+
+
+ {surface === "findings_page" && onRequestClose ? ( + + ) : null} + +
+
+
+ + + + + +
+ +

+ {finding.title} +

+
+ + {surface === "findings_page" ? ( +
+ + {finding.status === "active" ? ( + + ) : null} + + From {finding.chat_title} + +
+ ) : null} +
+ +
+
+
+ Severity +
+
+
+
+ CVSS 3.1 · {finding.cvss_score.toFixed(1)} +
+
+ +
+
+ Category +
+
+ {FINDING_CATEGORY_LABELS[finding.category]} +
+ {finding.cwe || finding.cve ? ( +
+ {[finding.cwe, finding.cve].filter(Boolean).join(" · ")} +
+ ) : null} +
+ +
+
+
+
+ {finding.target} +
+ {finding.endpoint ? ( +
+ {finding.method ? ( + + {finding.method} + + ) : null} + {finding.endpoint} +
+ ) : null} +
+ +
+
+ Fix Effort +
+
+ {formatLabel(finding.fix_effort)} +
+
+
+
+
+ + {finding.status === "closed" && + finding.closure_reason && + finding.closure_context ? ( +
+
+

+

+
+ + {FINDING_CLOSURE_REASON_LABELS[finding.closure_reason]} + + {finding.closed_at ? ( + + Closed{" "} + + + ) : null} +
+
+

+ {finding.closure_context} +

+
+ ) : null} +
+ +
+
+
+

+ Summary +

+
+ +
+
+ +
+

+

+
+ +
+
+ +
+

+ Root Cause +

+
+ +
+
+ +
+
+

+ Validation +

+

+ Reproduction steps, observed evidence, and affected locations + used to confirm this vulnerability. +

+
+ +
+

+

+
+ +
+
+ + + +
+
+

+ PoC Script or Payload +

+ +
+
+                  {finding.poc_script_code}
+                
+
+ + {finding.code_locations && finding.code_locations.length > 0 ? ( + +
+ {finding.code_locations.map((location, index) => ( +
+
+
+ {location.label ? ( +
+ {location.label} +
+ ) : null} +
+ {location.file}:{location.start_line}- + {location.end_line} +
+
+ + Source Reference + +
+ {location.snippet ? ( +
+                            {location.snippet}
+                          
+ ) : ( +

+ No source snippet was included with this report. +

+ )} +
+ ))} +
+
+ ) : null} +
+ +
+
+

+ Remediation +

+

+ Recommended changes to remove the root cause and prevent + regression. +

+
+ +
+ +
+ + {finding.code_locations?.some( + (location) => location.fix_before && location.fix_after, + ) ? ( + +
+ {finding.code_locations + ?.filter( + (location) => location.fix_before && location.fix_after, + ) + .map((location, index) => ( +
+
+
+ {location.file}:{location.start_line}- + {location.end_line} +
+ + Read-only Guidance + +
+
+
+
+ Current Code +
+
+                                
+                                  {location.fix_before}
+                                
+                              
+
+
+
+ Suggested Change +
+
+                                {location.fix_after}
+                              
+
+
+
+ ))} +
+
+ ) : null} +
+ +
+
+

+ Assessment Details +

+

+ Assumptions and the server-calculated CVSS 3.1 score. +

+
+ + + + + + + + Why this severity? + + + See how exploitability and demonstrated impact produced + this score. + + + + +
+ {CVSS_METRICS.map((metric) => { + const value = finding.cvss_breakdown[metric.key]; + return ( +
+
+ {metric.label} + + {CVSS_VALUE_LABELS[metric.key][value] ?? value} + +
+
+ {CVSS_VALUE_EXPLANATIONS[metric.key][value]} +
+
+ ); + })} +
+
+
+ + +
+
+ {CVSS_METRICS.map((metric) => ( +
+
+ {metric.label} +
+
+ {CVSS_VALUE_LABELS[metric.key][ + finding.cvss_breakdown[metric.key] + ] ?? finding.cvss_breakdown[metric.key]} +
+
+ ))} +
+
+
+ {finding.cvss_vector} +
+ +
+
+
+
+
+
+
+ + {surface === "findings_page" ? ( + { + if (!isClosing) setIsCloseDialogOpen(open); + }} + > + +
{ + event.preventDefault(); + void handleCloseFinding(); + }} + > + + Close finding + + Choose a reason and leave a note for future reference. You can + still view the report and source message. + + + +
+ + Why are you closing it? + + + setClosureReason(value as FindingClosureReason) + } + className="grid gap-2 sm:grid-cols-3" + > + {FINDING_CLOSURE_REASONS.map((reason) => ( + + ))} + +
+ +
+ +