From 90030051cee68e8ea8f3541fdce3e87f9c81f8e8 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:28:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(findings):=20=E6=BC=8F=E6=B4=9E=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=8A=9F=E8=83=BD(=E4=BA=8C=E6=AC=A1=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db: DeleteFinding(id) 删 findings 行 + 来源探索节点(kind='finding'), 级联清理 edges/node_assets、置空 activity 引用;返回受影响行数。 - server: DELETE /api/exploration/findings/{id}。 - web: api.deleteFinding;三处均可删且带 AlertDialog 二次确认—— 发现列表页独立「操作」列(每行垃圾桶按钮)、漏洞详情页头部删除按钮、 任务详情·发现 Tab 展开区删除按钮。两处列表同源,删一次全部消失。 - 测试 TestDeleteFinding:findings 行 + 来源节点均被删,重复删返回(0,nil)。 go build/vet、tsc 通过。 --- db/findings.go | 21 ++++++ db/findings_test.go | 48 ++++++++++++++ server/server.go | 21 ++++++ .../(main)/function/findings/detail/page.tsx | 47 +++++++++++++- web/src/app/(main)/function/findings/page.tsx | 64 ++++++++++++++++++- .../tasks/detail/_tabs/findings-tab.tsx | 55 ++++++++++++++-- web/src/lib/api.ts | 2 + 7 files changed, 250 insertions(+), 8 deletions(-) diff --git a/db/findings.go b/db/findings.go index e1e7afc..277f371 100644 --- a/db/findings.go +++ b/db/findings.go @@ -308,6 +308,27 @@ func (d *DB) GetFinding(id int64) (*DBFinding, error) { return f, nil } +// DeleteFinding removes a finding entirely: the standalone findings row and its +// originating exploration node (kind='finding'), so it disappears from the findings +// list, the per-task 发现 Tab, and the exploration graph alike. Deleting the node +// cascades its edges + node_assets and nulls any activity referencing it. Returns +// rows affected (0 = no finding with that id). +func (d *DB) DeleteFinding(id int64) (int64, error) { + var nodeID *int64 + err := d.QueryRow(`DELETE FROM findings WHERE id=$1 RETURNING node_id`, id).Scan(&nodeID) + if err == sql.ErrNoRows { + return 0, nil + } + if err != nil { + return 0, err + } + if nodeID != nil { + // best-effort: the finding is already gone; a stray node must not fail the op. + _, _ = d.Exec(`DELETE FROM exploration_nodes WHERE id=$1 AND kind='finding'`, *nodeID) + } + return 1, nil +} + // SetFindingStatus updates one finding's triage state. Returns rows affected. func (d *DB) SetFindingStatus(id int64, status string) (int64, error) { res, err := d.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, status, id) diff --git a/db/findings_test.go b/db/findings_test.go index a978701..4d67c09 100644 --- a/db/findings_test.go +++ b/db/findings_test.go @@ -5,6 +5,54 @@ import ( "testing" ) +// TestDeleteFinding verifies删除漏洞 removes both the findings row and its +// originating exploration node (kind='finding'). +func TestDeleteFinding(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + tk, err := d.CreateTask("删除漏洞测试", "目标", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(tk.ID) + + // seed a finding node in the task's exploration graph, then a findings row on it + es := d.Exploration(tk.ExplorationID) + nodeID, err := es.AddNode(KindFinding, map[string]any{"summary": "x", "severity": "high"}, 5, "confirmed", "worker", nil) + if err != nil { + t.Fatal(err) + } + fid, err := d.AddFinding(tk.ID, nodeID, "XSS", "反射型 XSS", "high", "summary", "poc", "worker", nil) + if err != nil { + t.Fatal(err) + } + + n, err := d.DeleteFinding(fid) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("DeleteFinding rows: want 1, got %d", n) + } + if f, _ := d.GetFinding(fid); f != nil { + t.Fatalf("finding row should be gone, got %+v", f) + } + var cnt int + d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE id=$1`, nodeID).Scan(&cnt) + if cnt != 0 { + t.Fatalf("originating finding node should be deleted, still %d", cnt) + } + + // deleting a non-existent finding is a no-op (0 rows), not an error + if n, err := d.DeleteFinding(fid); err != nil || n != 0 { + t.Fatalf("re-delete: want (0,nil), got (%d,%v)", n, err) + } +} + // TestFindingsPageAndStats exercises ListFindingsPage (filter/sort/paging) and // FindingStats against the live dev PG. It tags its rows with a unique vulnclass // so assertions are isolated from any pre-existing data, and cleans up after. diff --git a/server/server.go b/server/server.go index 99f8710..4c68585 100644 --- a/server/server.go +++ b/server/server.go @@ -616,6 +616,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding) mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage) mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding) + mux.HandleFunc("DELETE /api/exploration/findings/{id}", s.deleteFinding) mux.HandleFunc("GET /api/exploration/intents", s.intents) mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph) mux.HandleFunc("GET /api/exploration/activity", s.activity) @@ -1623,6 +1624,26 @@ func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, findingFromDB(f, s.resolveAssetIDs(f.AssetIDs))) } +// deleteFinding removes a finding (findings row + originating exploration node). +// id is the standalone findings-table id (DTO finding_id). +func (s *Server) deleteFinding(w http.ResponseWriter, r *http.Request) { + id := int64(atoiDefault(r.PathValue("id"), 0)) + if id <= 0 { + writeErr(w, 400, "bad finding id") + return + } + n, err := s.m.pg.DeleteFinding(id) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if n == 0 { + writeErr(w, 404, "finding not found") + return + } + writeJSON(w, 200, map[string]any{"deleted": true, "id": id}) +} + func (s *Server) intents(w http.ResponseWriter, r *http.Request) { t := s.m.ResolveTask(r.URL.Query().Get("task")) if t == nil { diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index c456ee4..07876b3 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -2,12 +2,23 @@ import * as React from "react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "sonner"; -import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react"; +import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon, Trash2Icon } from "lucide-react"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { Separator } from "@/components/ui/separator"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -61,6 +72,7 @@ function FieldRow({ } function FindingDetailInner() { + const router = useRouter(); const searchParams = useSearchParams(); const id = searchParams.get("id") ?? ""; const [finding, setFinding] = React.useState(null); @@ -116,6 +128,18 @@ function FindingDetailInner() { [finding, id], ); + // deleteFinding 删除当前漏洞(需二次确认),成功后返回发现列表。 + const deleteFinding = React.useCallback(async () => { + if (!id) return; + try { + await api.deleteFinding(id); + toast.success("已删除漏洞"); + router.push("/function/findings"); + } catch (e) { + toast.error("删除失败:" + (e as Error).message); + } + }, [id, router]); + if (!finding) { return (
@@ -160,6 +184,25 @@ function FindingDetailInner() { + + + + + + + 确认删除该漏洞? + + 「{title}」将被永久删除,同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。 + + + + 取消 + 删除 + + +
概览 diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index 498714d..fdf4798 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -7,10 +7,22 @@ import { ShieldAlertIcon, ArrowUpRightIcon, FileTextIcon, + Trash2Icon, } from "lucide-react"; import { StatusBadge } from "@/components/status-badge"; import { Markdown } from "@/components/markdown"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -231,6 +243,24 @@ export default function FindingsPage() { [edit], ); + // deleteFinding 删除一个漏洞(需二次确认):删成功后从列表移除、收起行、刷新统计。 + const deleteFinding = React.useCallback( + async (f: Finding) => { + if (!f.finding_id) return; + try { + await api.deleteFinding(f.finding_id); + setFindings((cur) => cur.filter((x) => x.id !== f.id)); + setTotal((t) => Math.max(0, t - 1)); + setExpanded((cur) => (cur === f.id ? null : cur)); + toast.success("已删除漏洞"); + api.findingStats().then(setStats).catch(() => {}); + } catch (e) { + toast.error("删除失败:" + (e as Error).message); + } + }, + [], + ); + const statCards: { label: string; value: number; tone?: string }[] = [ { label: "发现总数", value: stats.total }, { label: "待处理", value: stats.pending, tone: "text-amber-500" }, @@ -375,6 +405,7 @@ export default function FindingsPage() { 状态 所属任务 时间 + 操作 @@ -490,12 +521,41 @@ export default function FindingsPage() { {fmtTime(f.ts)} + e.stopPropagation()}> + {f.finding_id && ( + + + + + + + 确认删除该漏洞? + + 「{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}」将被永久删除, + 同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。 + + + + 取消 + deleteFinding(f)}>删除 + + + + )} + {open && ( {/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字 被强制单行、直接溢出单元格。 */} - +
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */} {f.finding_id && edit && ( @@ -626,7 +686,7 @@ export default function FindingsPage() { {findings.length === 0 && ( 没有匹配的发现。 diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx index e68aed2..425825b 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx @@ -2,10 +2,22 @@ import * as React from "react"; import Link from "next/link"; -import { ArrowUpRightIcon, ChevronRightIcon } from "lucide-react"; +import { ArrowUpRightIcon, ChevronRightIcon, Trash2Icon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Card, CardContent } from "@/components/ui/card"; import { StatusBadge } from "@/components/status-badge"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; import { Select, SelectContent, @@ -31,9 +43,11 @@ const FINDING_STATUSES: FindingStatus[] = [ function Row({ f, onStatus, + onDelete, }: { f: Finding; onStatus: (f: Finding, next: FindingStatus) => void; + onDelete: (f: Finding) => void; }) { const [open, setOpen] = React.useState(false); return ( @@ -116,8 +130,30 @@ function Row({
{open && (
-
- 证据 / PoC +
+ 证据 / PoC + {f.finding_id && ( + + + + + + + 确认删除该漏洞? + + 「{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}」将被永久删除, + 同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。 + + + + 取消 + onDelete(f)}>删除 + + + + )}
             {f.evidence}
@@ -169,6 +205,17 @@ export function FindingsTab({ taskId }: { taskId: string }) {
     [],
   );
 
+  const onDelete = React.useCallback(async (f: Finding) => {
+    if (!f.finding_id) return;
+    try {
+      await api.deleteFinding(f.finding_id);
+      setFindings((cur) => cur.filter((x) => x.id !== f.id));
+      toast.success("已删除漏洞");
+    } catch (e) {
+      toast.error("删除失败:" + (e as Error).message);
+    }
+  }, []);
+
   const items = findings
     .filter((f) => f.task_id === taskId)
     .sort((a, b) => {
@@ -180,7 +227,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
     
       
         {items.map((f) => (
-          
+          
         ))}
         {items.length === 0 && (
           

diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d2eb7ec..a8eb4db 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -261,6 +261,8 @@ export const api = { id: string, fields: { name?: string; vulnclass?: string; severity?: Severity; status?: FindingStatus }, ) => patch(`/exploration/findings/${id}`, fields), + // 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。 + deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`), intents: (task?: string) => get(`/exploration/intents${tq(task)}`).then(arr), tokenStats: (task?: string) => get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({