feat(findings): 漏洞删除功能(二次确认)
- 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 通过。
This commit is contained in:
1 parent
8047bb7aca
commit
90030051ce
7 files changed
+250
-8
No files matched your search
@@ -308,6 +308,27 @@ func (d *DB) GetFinding(id int64) (*DBFinding, error) {
|
|||||||
return f, nil
|
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.
|
// SetFindingStatus updates one finding's triage state. Returns rows affected.
|
||||||
func (d *DB) SetFindingStatus(id int64, status string) (int64, error) {
|
func (d *DB) SetFindingStatus(id int64, status string) (int64, error) {
|
||||||
res, err := d.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, status, id)
|
res, err := d.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, status, id)
|
||||||
|
|||||||
@@ -5,6 +5,54 @@ import (
|
|||||||
"testing"
|
"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
|
// TestFindingsPageAndStats exercises ListFindingsPage (filter/sort/paging) and
|
||||||
// FindingStats against the live dev PG. It tags its rows with a unique vulnclass
|
// 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.
|
// so assertions are isolated from any pre-existing data, and cleans up after.
|
||||||
|
|||||||
@@ -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}", s.getFinding)
|
||||||
mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage)
|
mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage)
|
||||||
mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding)
|
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/intents", s.intents)
|
||||||
mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph)
|
mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph)
|
||||||
mux.HandleFunc("GET /api/exploration/activity", s.activity)
|
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)))
|
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) {
|
func (s *Server) intents(w http.ResponseWriter, r *http.Request) {
|
||||||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||||||
if t == nil {
|
if t == nil {
|
||||||
|
|||||||
@@ -2,12 +2,23 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
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 { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -61,6 +72,7 @@ function FieldRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FindingDetailInner() {
|
function FindingDetailInner() {
|
||||||
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const id = searchParams.get("id") ?? "";
|
const id = searchParams.get("id") ?? "";
|
||||||
const [finding, setFinding] = React.useState<Finding | null>(null);
|
const [finding, setFinding] = React.useState<Finding | null>(null);
|
||||||
@@ -116,6 +128,18 @@ function FindingDetailInner() {
|
|||||||
[finding, id],
|
[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) {
|
if (!finding) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
|
||||||
@@ -160,6 +184,25 @@ function FindingDetailInner() {
|
|||||||
<Separator orientation="vertical" className="mx-1 h-4" />
|
<Separator orientation="vertical" className="mx-1 h-4" />
|
||||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||||
<StatusBadge domain="finding" value={finding.status} dot />
|
<StatusBadge domain="finding" value={finding.status} dot />
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="ml-auto text-destructive">
|
||||||
|
<Trash2Icon /> 删除
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认删除该漏洞?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
「{title}」将被永久删除,同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={deleteFinding}>删除</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||||
|
|||||||
@@ -7,10 +7,22 @@ import {
|
|||||||
ShieldAlertIcon,
|
ShieldAlertIcon,
|
||||||
ArrowUpRightIcon,
|
ArrowUpRightIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
|
Trash2Icon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
import { Markdown } from "@/components/markdown";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -231,6 +243,24 @@ export default function FindingsPage() {
|
|||||||
[edit],
|
[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 }[] = [
|
const statCards: { label: string; value: number; tone?: string }[] = [
|
||||||
{ label: "发现总数", value: stats.total },
|
{ label: "发现总数", value: stats.total },
|
||||||
{ label: "待处理", value: stats.pending, tone: "text-amber-500" },
|
{ label: "待处理", value: stats.pending, tone: "text-amber-500" },
|
||||||
@@ -375,6 +405,7 @@ export default function FindingsPage() {
|
|||||||
<TableHead className="w-28">状态</TableHead>
|
<TableHead className="w-28">状态</TableHead>
|
||||||
<TableHead className="w-36 max-w-[9rem]">所属任务</TableHead>
|
<TableHead className="w-36 max-w-[9rem]">所属任务</TableHead>
|
||||||
<TableHead className="w-32">时间</TableHead>
|
<TableHead className="w-32">时间</TableHead>
|
||||||
|
<TableHead className="w-16 text-right">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -490,12 +521,41 @@ export default function FindingsPage() {
|
|||||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||||
{fmtTime(f.ts)}
|
{fmtTime(f.ts)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{f.finding_id && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-7 text-muted-foreground hover:text-destructive"
|
||||||
|
aria-label="删除漏洞"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认删除该漏洞?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
「{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}」将被永久删除,
|
||||||
|
同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => deleteFinding(f)}>删除</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{open && (
|
{open && (
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
{/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字
|
{/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字
|
||||||
被强制单行、直接溢出单元格。 */}
|
被强制单行、直接溢出单元格。 */}
|
||||||
<TableCell colSpan={7} className="bg-muted/30 whitespace-normal">
|
<TableCell colSpan={8} className="bg-muted/30 whitespace-normal">
|
||||||
<div className="flex flex-col gap-2 px-2 py-1">
|
<div className="flex flex-col gap-2 px-2 py-1">
|
||||||
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */}
|
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */}
|
||||||
{f.finding_id && edit && (
|
{f.finding_id && edit && (
|
||||||
@@ -626,7 +686,7 @@ export default function FindingsPage() {
|
|||||||
{findings.length === 0 && (
|
{findings.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={7}
|
colSpan={8}
|
||||||
className="py-12 text-center text-sm text-muted-foreground"
|
className="py-12 text-center text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
没有匹配的发现。
|
没有匹配的发现。
|
||||||
|
|||||||
@@ -2,10 +2,22 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
|
import { ArrowUpRightIcon, ChevronRightIcon, Trash2Icon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -31,9 +43,11 @@ const FINDING_STATUSES: FindingStatus[] = [
|
|||||||
function Row({
|
function Row({
|
||||||
f,
|
f,
|
||||||
onStatus,
|
onStatus,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
f: Finding;
|
f: Finding;
|
||||||
onStatus: (f: Finding, next: FindingStatus) => void;
|
onStatus: (f: Finding, next: FindingStatus) => void;
|
||||||
|
onDelete: (f: Finding) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -116,8 +130,30 @@ function Row({
|
|||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="bg-muted/30 px-4 pb-4 pl-11">
|
<div className="bg-muted/30 px-4 pb-4 pl-11">
|
||||||
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
证据 / PoC
|
<span className="text-xs font-medium text-muted-foreground">证据 / PoC</span>
|
||||||
|
{f.finding_id && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-destructive">
|
||||||
|
<Trash2Icon /> 删除
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认删除该漏洞?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
「{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}」将被永久删除,
|
||||||
|
同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => onDelete(f)}>删除</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
|
<pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
|
||||||
{f.evidence}
|
{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
|
const items = findings
|
||||||
.filter((f) => f.task_id === taskId)
|
.filter((f) => f.task_id === taskId)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -180,7 +227,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
|||||||
<Card className="overflow-hidden py-0">
|
<Card className="overflow-hidden py-0">
|
||||||
<CardContent className="px-0">
|
<CardContent className="px-0">
|
||||||
{items.map((f) => (
|
{items.map((f) => (
|
||||||
<Row key={f.id} f={f} onStatus={onStatus} />
|
<Row key={f.id} f={f} onStatus={onStatus} onDelete={onDelete} />
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ export const api = {
|
|||||||
id: string,
|
id: string,
|
||||||
fields: { name?: string; vulnclass?: string; severity?: Severity; status?: FindingStatus },
|
fields: { name?: string; vulnclass?: string; severity?: Severity; status?: FindingStatus },
|
||||||
) => patch<Finding>(`/exploration/findings/${id}`, fields),
|
) => patch<Finding>(`/exploration/findings/${id}`, fields),
|
||||||
|
// 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。
|
||||||
|
deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`),
|
||||||
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
|
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
|
||||||
tokenStats: (task?: string) =>
|
tokenStats: (task?: string) =>
|
||||||
get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({
|
get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({
|
||||||
|
|||||||
Reference in new issue
Block a user