From 406401841b2488eb6cd8daf6e6f88cff3285d58b Mon Sep 17 00:00:00 2001 From: Ross <144740362+ross0x01@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:27:31 -0400 Subject: [PATCH 01/34] Add structured vulnerability findings workflow --- app/(chat)/findings/__tests__/page.test.tsx | 130 +++++ app/(chat)/findings/page.tsx | 284 +++++++++++ app/components/ComputerSidebar.tsx | 27 +- app/components/MessagePartHandler.tsx | 15 + app/components/SidebarHeader.tsx | 40 +- app/components/computer-sidebar-utils.tsx | 7 + app/components/findings/FindingCard.tsx | 81 +++ app/components/findings/FindingDetail.tsx | 268 ++++++++++ .../findings/__tests__/FindingCard.test.tsx | 35 ++ .../findings/__tests__/FindingDetail.test.tsx | 112 +++++ app/components/tools/FindingToolHandler.tsx | 181 +++++++ .../__tests__/FindingToolHandler.test.tsx | 138 ++++++ app/hooks/useSidebarNavigation.ts | 4 + .../components/SharedMessagePartHandler.tsx | 13 + .../chats.agentApprovalLifecycle.test.ts | 68 +++ .../findings.shared-security.test.ts | 67 +++ convex/__tests__/findings.test.ts | 467 ++++++++++++++++++ convex/__tests__/userDeletion.test.ts | 16 + convex/_generated/api.d.ts | 8 + convex/chats.ts | 42 ++ convex/findings.ts | 332 +++++++++++++ convex/messages.ts | 33 +- convex/schema.ts | 82 +++ convex/sharedChats.ts | 5 +- convex/userDeletion.ts | 11 + lib/__tests__/system-prompt.test.ts | 28 +- lib/ai/tools/__tests__/findings.test.ts | 107 ++++ lib/ai/tools/__tests__/schemas.test.ts | 37 ++ lib/ai/tools/findings.ts | 56 +++ lib/ai/tools/index.ts | 4 + lib/ai/tools/schemas.ts | 173 ++++++- .../__tests__/agent-long-contracts.test.ts | 25 + lib/chat/chat-processor.ts | 6 +- .../__tests__/prune-tool-outputs.test.ts | 3 +- lib/chat/compaction/prune-tool-outputs.ts | 1 + lib/db/actions.ts | 40 ++ lib/findings/__tests__/cvss31.test.ts | 132 +++++ .../__tests__/share-sanitizer.test.ts | 92 ++++ lib/findings/__tests__/validation.test.ts | 209 ++++++++ lib/findings/cvss31.ts | 90 ++++ lib/findings/share-sanitizer.ts | 59 +++ lib/findings/validation.ts | 161 ++++++ lib/system-prompt.ts | 19 +- lib/utils/sidebar-utils.ts | 26 + types/chat.ts | 18 + types/finding.ts | 41 ++ types/index.ts | 1 + 47 files changed, 3745 insertions(+), 49 deletions(-) create mode 100644 app/(chat)/findings/__tests__/page.test.tsx create mode 100644 app/(chat)/findings/page.tsx create mode 100644 app/components/findings/FindingCard.tsx create mode 100644 app/components/findings/FindingDetail.tsx create mode 100644 app/components/findings/__tests__/FindingCard.test.tsx create mode 100644 app/components/findings/__tests__/FindingDetail.test.tsx create mode 100644 app/components/tools/FindingToolHandler.tsx create mode 100644 app/components/tools/__tests__/FindingToolHandler.test.tsx create mode 100644 convex/__tests__/findings.shared-security.test.ts create mode 100644 convex/__tests__/findings.test.ts create mode 100644 convex/findings.ts create mode 100644 lib/ai/tools/__tests__/findings.test.ts create mode 100644 lib/ai/tools/findings.ts create mode 100644 lib/findings/__tests__/cvss31.test.ts create mode 100644 lib/findings/__tests__/share-sanitizer.test.ts create mode 100644 lib/findings/__tests__/validation.test.ts create mode 100644 lib/findings/cvss31.ts create mode 100644 lib/findings/share-sanitizer.ts create mode 100644 lib/findings/validation.ts create mode 100644 types/finding.ts diff --git a/app/(chat)/findings/__tests__/page.test.tsx b/app/(chat)/findings/__tests__/page.test.tsx new file mode 100644 index 000000000..c25deea1a --- /dev/null +++ b/app/(chat)/findings/__tests__/page.test.tsx @@ -0,0 +1,130 @@ +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 : mockSourceChats, +); +const mockCapture = jest.fn(); +let mockMobile = false; + +jest.mock("convex/react", () => ({ + 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: jest.fn(), + }), +})); + +jest.mock("@/hooks/use-mobile", () => ({ + useIsMobile: () => mockMobile, +})); + +jest.mock("@/app/hooks/useTauri", () => ({ navigateToAuth: jest.fn() })); +jest.mock("@/lib/analytics/client", () => ({ + captureAuthenticatedEvent: mockCapture, +})); + +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, + 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 mockSourceChats = [{ chat_id: "chat-1", chat_title: "Invoice test" }]; + +const Page = require("../page").default as typeof import("../page").default; + +describe("FindingsPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockMobile = false; + mockUsePaginatedQuery.mockReturnValue({ + results: [mockFinding], + status: "Exhausted", + loadMore: jest.fn(), + }); + }); + + it("lists metadata, searches, and exposes severity/source filters", async () => { + render(); + expect(screen.getByRole("heading", { name: "Findings" })).toBeVisible(); + expect(screen.getByText("Confirmed IDOR")).toBeVisible(); + expect(screen.getByText("/api/invoices/other")).toBeVisible(); + expect(screen.getByText("Invoice test")).toBeVisible(); + expect(screen.getByText("CVSS 7.1")).toBeVisible(); + expect(screen.getByLabelText("Filter by severity")).toBeVisible(); + expect(screen.getByLabelText("Filter by source chat")).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(mockCapture).toHaveBeenCalledWith("findings_page_viewed"); + }); + + it("opens the reusable detail alongside the desktop list", () => { + render(); + fireEvent.click(screen.getByText("Confirmed IDOR")); + expect(screen.getByText(mockFinding.description)).toBeVisible(); + expect(screen.getByRole("link", { name: /Invoice test/i })).toHaveAttribute( + "href", + "/c/chat-1", + ); + expect(mockCapture).toHaveBeenCalledWith("finding_viewed", { + surface: "findings_page", + }); + }); + + it("uses a full-screen mobile detail with a back control", () => { + mockMobile = true; + render(); + fireEvent.click(screen.getByText("Confirmed IDOR")); + expect( + screen.getByRole("button", { name: "Back to findings" }), + ).toBeVisible(); + expect(screen.getByText(mockFinding.description)).toBeVisible(); + }); +}); diff --git a/app/(chat)/findings/page.tsx b/app/(chat)/findings/page.tsx new file mode 100644 index 000000000..98eba1194 --- /dev/null +++ b/app/(chat)/findings/page.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useDeferredValue, useEffect, useState } from "react"; +import { useConvexAuth, usePaginatedQuery, useQuery } from "convex/react"; +import { formatDistanceToNow } from "date-fns"; +import { ArrowLeft, PanelLeft, Search, ShieldAlert } from "lucide-react"; +import { api } from "@/convex/_generated/api"; +import { Button } from "@/components/ui/button"; +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 } from "@/app/components/findings/FindingCard"; +import { useGlobalState } from "@/app/contexts/GlobalState"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { navigateToAuth } from "@/app/hooks/useTauri"; +import { captureAuthenticatedEvent } from "@/lib/analytics/client"; +import { cn } from "@/lib/utils"; +import type { + FindingSeverity, + FindingSourceChat, + FindingSummary, +} from "@/types/finding"; + +const SEVERITIES: FindingSeverity[] = [ + "critical", + "high", + "medium", + "low", + "info", +]; + +export default function FindingsPage() { + const { isLoading, isAuthenticated } = useConvexAuth(); + const { setChatSidebarOpen, closeSidebar } = useGlobalState(); + const isMobile = useIsMobile(); + const [search, setSearch] = useState(""); + const deferredSearch = useDeferredValue(search.trim()); + const [severity, setSeverity] = useState<"all" | FindingSeverity>("all"); + const [chatId, setChatId] = useState("all"); + const [selectedFindingId, setSelectedFindingId] = useState( + null, + ); + + useEffect(() => { + if (!isLoading && !isAuthenticated) navigateToAuth("/login"); + }, [isAuthenticated, isLoading]); + + useEffect(() => { + if (!isAuthenticated) return; + captureAuthenticatedEvent("findings_page_viewed"); + }, [isAuthenticated]); + + const findingsQuery = usePaginatedQuery( + api.findings.listFindings, + isAuthenticated + ? { + ...(deferredSearch ? { search: deferredSearch } : {}), + ...(severity !== "all" ? { severity } : {}), + ...(chatId !== "all" ? { chatId } : {}), + } + : "skip", + { initialNumItems: 25 }, + ); + const sourceChats = useQuery( + api.findings.getFindingSourceChats, + isAuthenticated ? {} : "skip", + ) as FindingSourceChat[] | undefined; + const findings = (findingsQuery.results ?? []) as FindingSummary[]; + + const selectFinding = (findingId: string) => { + setSelectedFindingId(findingId); + closeSidebar(); + captureAuthenticatedEvent("finding_viewed", { surface: "findings_page" }); + }; + + if (isLoading || !isAuthenticated) { + return ( +
+ Loading findings… +
+ ); + } + + return ( +
+
+
+ +
+

Findings

+

+ Confirmed vulnerabilities saved by Agent +

+
+
+ +
+
+
+ + setSearch(event.target.value)} + placeholder="Search title, target, endpoint, CVE, or CWE" + className="pl-9" + aria-label="Search findings" + /> +
+ + +
+ +
+ {findingsQuery.status === "LoadingFirstPage" ? ( +
+ Loading findings… +
+ ) : findings.length === 0 ? ( +
+
+ +
+
+

+ No findings found +

+

+ Agent saves a finding only after it has concrete evidence, + reliable reproduction, and a working PoC. +

+
+
+ ) : ( +
+ {findings.map((finding) => ( + + ))} +
+ )} + + {findingsQuery.status === "CanLoadMore" && ( +
+ +
+ )} + {findingsQuery.status === "LoadingMore" && ( +
+ Loading more… +
+ )} +
+
+
+ + {selectedFindingId && isMobile === false && ( + + )} + + {selectedFindingId && isMobile && ( +
+
+ + Finding +
+
+ setSelectedFindingId(null)} + /> +
+
+ )} +
+ ); +} diff --git a/app/components/ComputerSidebar.tsx b/app/components/ComputerSidebar.tsx index 6b14bd27c..9e912b8ea 100644 --- a/app/components/ComputerSidebar.tsx +++ b/app/components/ComputerSidebar.tsx @@ -31,11 +31,13 @@ import { isSidebarProxy, isSidebarWebSearch, isSidebarNotes, + isSidebarFinding, isSidebarSharedFiles, type SidebarContent, type ChatStatus, type NoteCategory, } from "@/types/chat"; +import { FindingDetail } from "./findings/FindingDetail"; import type { Id } from "@/convex/_generated/dataModel"; import type { FilePart } from "@/types/file"; import { FilePartRenderer } from "./FilePartRenderer"; @@ -439,6 +441,7 @@ export const ComputerSidebarBase: React.FC = ({ const isProxy = isSidebarProxy(sidebarContent); const isWebSearch = isSidebarWebSearch(sidebarContent); const isNotes = isSidebarNotes(sidebarContent); + const isFinding = isSidebarFinding(sidebarContent); const isSharedFiles = isSidebarSharedFiles(sidebarContent); // Use resolved versions for display metadata so streaming updates are reflected @@ -452,9 +455,11 @@ 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" + : isProxy + ? "HackerAI\u2019s Proxy" + : "HackerAI\u2019s Computer"; const handleClose = () => { closeSidebar(); @@ -542,6 +547,10 @@ export const ComputerSidebarBase: React.FC = ({
Notes
+ ) : isFinding ? ( +
+ Vulnerability report +
) : isSharedFiles ? (
Shared Files @@ -558,7 +567,7 @@ export const ComputerSidebarBase: React.FC = ({
{/* Action buttons - far right */} - {!isWebSearch && !isNotes && !isSharedFiles && ( + {!isWebSearch && !isNotes && !isFinding && !isSharedFiles && ( = ({ 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 +774,13 @@ export const ComputerSidebarBase: React.FC = ({ )} + {isFinding && ( + + )} {isNotes && (
diff --git a/app/components/MessagePartHandler.tsx b/app/components/MessagePartHandler.tsx index 129eb03cf..c445b4b88 100644 --- a/app/components/MessagePartHandler.tsx +++ b/app/components/MessagePartHandler.tsx @@ -8,6 +8,8 @@ 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 { FindingCard } from "./findings/FindingCard"; import { ProxyToolHandler } from "./tools/ProxyToolHandler"; import { GetTerminalFilesHandler } from "./tools/GetTerminalFilesHandler"; import { SummarizationHandler } from "./tools/SummarizationHandler"; @@ -257,6 +259,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 ( = ({ }) => { const isMobile = useIsMobile(); const router = useRouter(); + const pathname = usePathname(); const { setChatSidebarOpen, closeSidebar, @@ -101,6 +103,12 @@ const SidebarHeaderContentImpl: FC = ({ setIsSearchOpen(true); }; + const handleFindingsOpen = () => { + closeSidebar(); + if (isMobile) setChatSidebarOpen(false); + router.push("/findings"); + }; + const handleSearchClose = () => { setIsSearchOpen(false); }; @@ -162,6 +170,20 @@ const SidebarHeaderContentImpl: FC = ({
+ +
+ +
@@ -232,6 +254,22 @@ const SidebarHeaderContentImpl: FC = ({ + +
+ +
{/* Search Dialog */} diff --git a/app/components/computer-sidebar-utils.tsx b/app/components/computer-sidebar-utils.tsx index 36d9efa1a..400295374 100644 --- a/app/components/computer-sidebar-utils.tsx +++ b/app/components/computer-sidebar-utils.tsx @@ -7,6 +7,7 @@ import { Search, FolderSearch, StickyNote, + ShieldAlert, FileDown, Radar, } from "lucide-react"; @@ -16,6 +17,7 @@ import { isSidebarProxy, isSidebarWebSearch, isSidebarNotes, + isSidebarFinding, isSidebarSharedFiles, type SidebarContent, type NoteCategory, @@ -163,6 +165,8 @@ export function getActionText(content: SidebarContent): string { return completedActionMap[content.action]; } + if (isSidebarFinding(content)) return "Saved finding"; + if (isSidebarSharedFiles(content)) { if (content.isExecuting) { const ready = content.files.length; @@ -198,6 +202,7 @@ export function getSidebarIcon(content: SidebarContent): React.ReactNode { if (isSidebarTerminal(content)) return ; if (isSidebarWebSearch(content)) return ; if (isSidebarNotes(content)) return ; + if (isSidebarFinding(content)) return ; if (isSidebarSharedFiles(content)) return ; return ; } @@ -215,6 +220,7 @@ export function getToolName(content: SidebarContent): string { } if (isSidebarWebSearch(content)) return "Search"; if (isSidebarNotes(content)) return "Notes"; + if (isSidebarFinding(content)) return "Findings"; if (isSidebarSharedFiles(content)) return "Downloads"; return "Tool"; } @@ -244,6 +250,7 @@ export function getDisplayTarget(content: SidebarContent): string { } return content.affectedTitle || ""; } + if (isSidebarFinding(content)) return content.title; 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..95482ecb1 --- /dev/null +++ b/app/components/findings/FindingCard.tsx @@ -0,0 +1,81 @@ +"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", +}; + +export const getFindingSeverityClasses = (severity: FindingSeverity) => + severityClasses[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..eb0dcb118 --- /dev/null +++ b/app/components/findings/FindingDetail.tsx @@ -0,0 +1,268 @@ +"use client"; + +import Link from "next/link"; +import { useMutation, useQuery } from "convex/react"; +import { api } from "@/convex/_generated/api"; +import { ExternalLink, ShieldAlert, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { MemoizedMarkdown } from "@/app/components/MemoizedMarkdown"; +import { captureAuthenticatedEvent } from "@/lib/analytics/client"; +import type { FindingDetailRecord } from "@/types/finding"; +import { cn } from "@/lib/utils"; +import { getFindingSeverityClasses } from "./FindingCard"; + +const DetailSection = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => ( +
+

+ {title} +

+
{children}
+
+); + +const MarkdownSection = ({ + title, + value, +}: { + title: string; + value: string; +}) => ( + + + +); + +export function FindingDetail({ + findingId, + finding: suppliedFinding, + onDeleted, + surface = "detail", + className, +}: { + findingId?: string; + finding?: FindingDetailRecord | null; + onDeleted?: () => void; + surface?: "computer_sidebar" | "findings_page" | "detail"; + className?: string; +}) { + const queriedFinding = useQuery( + api.findings.getFinding, + suppliedFinding !== undefined || !findingId ? "skip" : { findingId }, + ) as FindingDetailRecord | null | undefined; + const deleteFinding = useMutation(api.findings.deleteFinding); + const [isDeleting, setIsDeleting] = useState(false); + const finding = + suppliedFinding !== undefined ? suppliedFinding : queriedFinding; + + if (finding === undefined) { + return ( +
+ Loading finding… +
+ ); + } + + if (!finding) { + return ( +
+ +
+

Finding deleted

+

+ This structured finding is no longer available. +

+
+
+ ); + } + + const handleDelete = async () => { + setIsDeleting(true); + try { + const result = await deleteFinding({ findingId: finding.finding_id }); + if (result.deleted) { + captureAuthenticatedEvent("finding_deleted", { surface }); + toast.success("Finding deleted"); + onDeleted?.(); + } + } catch { + toast.error("Could not delete finding"); + } finally { + setIsDeleting(false); + } + }; + + return ( +
+
+
+
+ + {finding.severity} + + + CVSS {finding.cvss_score.toFixed(1)} + + {finding.cve && ( + + {finding.cve} + + )} + {finding.cwe && ( + + {finding.cwe} + + )} +
+

+ {finding.title} +

+
+
{finding.target}
+ {finding.endpoint && ( +
+ {finding.method ? `${finding.method} ` : ""} + {finding.endpoint} +
+ )} +
+
+ + {finding.chat_title} +
+
+ + + + + + +
+            {finding.poc_script_code}
+          
+
+ + + + + {finding.code_locations && finding.code_locations.length > 0 && ( + +
+ {finding.code_locations.map((location, index) => ( +
+
+ {location.file}:{location.start_line}-{location.end_line} +
+ {location.label && ( +
+ {location.label} +
+ )} + {location.snippet && ( +
+                      {location.snippet}
+                    
+ )} + {location.fix_before && location.fix_after && ( +
+
+
+ Before +
+
+                          {location.fix_before}
+                        
+
+
+
+ After +
+
+                          {location.fix_after}
+                        
+
+
+ )} +
+ ))} +
+
+ )} + + +
+
{finding.cvss_vector}
+
Fix effort: {finding.fix_effort}
+
+
+
+
+ ); +} diff --git a/app/components/findings/__tests__/FindingCard.test.tsx b/app/components/findings/__tests__/FindingCard.test.tsx new file mode 100644 index 000000000..c74eab76f --- /dev/null +++ b/app/components/findings/__tests__/FindingCard.test.tsx @@ -0,0 +1,35 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { FindingCard } from "../FindingCard"; + +describe("FindingCard", () => { + it("is non-clickable when rendered for a public share", () => { + render( + , + ); + expect(screen.getByText("Confirmed IDOR")).toBeVisible(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("opens an authenticated finding when clickable", () => { + const onClick = jest.fn(); + render( + , + ); + fireEvent.click( + screen.getByRole("button", { name: "Open finding: Confirmed IDOR" }), + ); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/findings/__tests__/FindingDetail.test.tsx b/app/components/findings/__tests__/FindingDetail.test.tsx new file mode 100644 index 000000000..ebe5bf4fa --- /dev/null +++ b/app/components/findings/__tests__/FindingDetail.test.tsx @@ -0,0 +1,112 @@ +import "@testing-library/jest-dom"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useMutation } from "convex/react"; +import type { FindingDetailRecord } from "@/types/finding"; + +const mockCapture = jest.fn(); +jest.mock("@/lib/analytics/client", () => ({ + captureAuthenticatedEvent: mockCapture, +})); + +const { FindingDetail } = + require("../FindingDetail") as typeof import("../FindingDetail"); + +const finding: FindingDetailRecord = { + finding_id: "finding-1", + title: "Confirmed IDOR", + target: "https://app.example.test", + endpoint: "/api/invoices/other", + method: "GET", + severity: "high", + cvss_score: 7.1, + chat_id: "chat-1", + chat_title: "Invoice test", + created_at: 1, + updated_at: 1, + message_id: "message-1", + description: "Another account's invoice is readable.", + impact: "Billing data disclosure.", + technical_analysis: "The handler omits an 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 the other account's data.", + assumptions: "Ordinary authenticated 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", + }, + cwe: "CWE-639", + code_locations: [ + { + file: "app/api/invoices/route.ts", + start_line: 20, + end_line: 22, + label: "Missing ownership predicate", + snippet: "findUnique({ id })", + fix_before: "where: { id }", + fix_after: "where: { id, userId }", + }, + ], +}; + +describe("FindingDetail", () => { + beforeEach(() => jest.clearAllMocks()); + + it("renders the complete report and source chat link", () => { + render(); + expect(screen.getByRole("heading", { name: finding.title })).toBeVisible(); + expect(screen.getByText("CWE-639")).toBeVisible(); + expect(screen.getByText(finding.poc_script_code)).toBeVisible(); + expect(screen.getByText(finding.evidence)).toBeVisible(); + expect(screen.getByText("app/api/invoices/route.ts:20-22")).toBeVisible(); + expect(screen.getByRole("link", { name: /Invoice test/i })).toHaveAttribute( + "href", + "/c/chat-1", + ); + }); + + it("requires confirmation, deletes, and emits only surface analytics", async () => { + const mutation = useMutation({} as any) as jest.Mock; + mutation.mockResolvedValue({ deleted: true }); + const onDeleted = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + expect(screen.getByText("Delete this finding?")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Delete finding" })); + + await waitFor(() => { + expect(mutation).toHaveBeenCalledWith({ findingId: "finding-1" }); + expect(onDeleted).toHaveBeenCalledTimes(1); + }); + expect(mockCapture).toHaveBeenCalledWith("finding_deleted", { + surface: "findings_page", + }); + expect(JSON.stringify(mockCapture.mock.calls)).not.toMatch( + /Confirmed IDOR|app\.example|HTTP 200|curl/, + ); + }); + + it("renders loading and deleted states", () => { + const { rerender } = render(); + expect(screen.getByText("Loading finding…")).toBeVisible(); + rerender(); + expect(screen.getByText("Finding deleted")).toBeVisible(); + }); +}); diff --git a/app/components/tools/FindingToolHandler.tsx b/app/components/tools/FindingToolHandler.tsx new file mode 100644 index 000000000..7a172176c --- /dev/null +++ b/app/components/tools/FindingToolHandler.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { memo, useMemo } from "react"; +import { useQuery } from "convex/react"; +import { ShieldAlert } from "lucide-react"; +import { api } from "@/convex/_generated/api"; +import type { ChatStatus, SidebarFinding } from "@/types/chat"; +import type { FindingDetailRecord } from "@/types/finding"; +import { isSidebarFinding } from "@/types/chat"; +import ToolBlock from "@/components/ui/tool-block"; +import { useToolSidebar } from "@/app/hooks/useToolSidebar"; +import { captureAuthenticatedEvent } from "@/lib/analytics/client"; +import { FindingCard } from "@/app/components/findings/FindingCard"; + +type FindingOutput = { + success?: boolean; + finding_id?: string; + title?: string; + target?: string; + endpoint?: string; + severity?: SidebarFinding["severity"]; + cvss_score?: number; + error?: "validation" | "duplicate" | "chat_not_found" | "general"; + message?: string; +}; + +const failureAction = (output: FindingOutput) => { + if (output.error === "duplicate") return "Duplicate finding rejected"; + if (output.error === "validation") return "Finding validation failed"; + return "Finding was not saved"; +}; + +const SavedFindingCard = ({ + output, + toolCallId, +}: { + output: Required< + Pick< + FindingOutput, + "finding_id" | "title" | "target" | "severity" | "cvss_score" + > + > & + Pick; + toolCallId: string; +}) => { + const finding = useQuery(api.findings.getFinding, { + findingId: output.finding_id, + }) as FindingDetailRecord | null | undefined; + + const content = useMemo( + () => ({ + findingId: output.finding_id, + title: finding?.title ?? output.title, + target: finding?.target ?? output.target, + endpoint: finding?.endpoint ?? output.endpoint, + severity: finding?.severity ?? output.severity, + cvssScore: finding?.cvss_score ?? output.cvss_score, + isExecuting: false, + toolCallId, + }), + [finding, output, toolCallId], + ); + const { handleOpenInSidebar } = useToolSidebar({ + toolCallId, + content, + typeGuard: isSidebarFinding, + }); + + if (finding === null) { + return ( +