From 64d9c66f6aef119ae2943dc9b3180c21956db62c Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:43:50 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=8F=91=E7=8E=B0?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5(=E5=A4=9A=20Tab=20+=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=8C=BA=E5=8F=AF=E6=94=B9=E4=B8=A5=E9=87=8D=E5=BA=A6?= =?UTF-8?q?/=E5=A4=84=E7=BD=AE=E7=8A=B6=E6=80=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 详情页 /function/findings/detail?id=: - 概览 Tab:左栏摘要 + 证据/PoC,右栏状态区(严重等级、漏洞类型、涉及资产、处理状态、所属任务、发现时间) - 严重等级与处理状态用下拉,选择后直接调后端接口即时修正 - 另有「证据/PoC」「元数据」两个 Tab - 发现列表页漏洞名称改为链接进入详情 后端: - GET /api/exploration/findings/{id} 取单条(含资产解析) - PATCH /api/exploration/findings/{id} 扩展为部分更新 {status?, severity?} - db.GetFinding / db.SetFindingSeverity(同步探索节点 payload,保持任务内视图一致) - db.ValidSeverity 白名单;api 增加 getFinding / setFindingSeverity - mock 支持单条 GET / PATCH,并回填 finding_id 供 demo 链接 db 测试补充 GetFinding / SetFindingSeverity 往返断言;next build 通过。 --- db/findings.go | 63 +++ db/findings_test.go | 24 ++ server/server.go | 75 +++- .../(main)/function/findings/detail/page.tsx | 360 ++++++++++++++++++ web/src/app/(main)/function/findings/page.tsx | 17 +- web/src/lib/api.ts | 7 +- web/src/lib/mock/handler.ts | 24 +- 7 files changed, 552 insertions(+), 18 deletions(-) create mode 100644 web/src/app/(main)/function/findings/detail/page.tsx diff --git a/db/findings.go b/db/findings.go index c6903ec..d0fe731 100644 --- a/db/findings.go +++ b/db/findings.go @@ -1,6 +1,7 @@ package db import ( + "database/sql" "encoding/json" "fmt" "strings" @@ -46,6 +47,23 @@ func ValidFindingStatus(s string) bool { return false } +// Finding severity levels (findings.severity). +const ( + SeverityCritical = "critical" // 严重 + SeverityHigh = "high" // 高 + SeverityMedium = "medium" // 中 + SeverityLow = "low" // 低 +) + +// ValidSeverity reports whether s is a known severity level. +func ValidSeverity(s string) bool { + switch s { + case SeverityCritical, SeverityHigh, SeverityMedium, SeverityLow: + return true + } + return false +} + // AddFinding inserts a finding into the standalone findings table. taskID and // nodeID may be 0 (stored as NULL). name may be "" (frontend falls back to // vulnclass). Returns the new finding id. @@ -226,6 +244,27 @@ func (d *DB) FindingStats() (*FindingStats, error) { return st, rows.Err() } +// GetFinding returns a single finding row (with task_description joined), or nil +// when no row has that id. +func (d *DB) GetFinding(id int64) (*DBFinding, error) { + rows, err := d.Query(`SELECT `+findingSelectCols+` + FROM findings f + LEFT JOIN tasks t ON f.task_id = t.id + WHERE f.id = $1`, id) + if err != nil { + return nil, err + } + defer rows.Close() + out, err := scanFindings(rows) + if err != nil { + return nil, err + } + if len(out) == 0 { + return nil, nil + } + return out[0], 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) @@ -235,6 +274,30 @@ func (d *DB) SetFindingStatus(id int64, status string) (int64, error) { return res.RowsAffected() } +// SetFindingSeverity updates one finding's severity in the standalone table AND +// keeps the originating exploration node's payload in sync (the per-task view +// reads severity from the node payload, not this table). Returns rows affected +// (0 when no finding has that id). The node sync is best-effort. +func (d *DB) SetFindingSeverity(id int64, severity string) (int64, error) { + var nodeID *int64 + err := d.QueryRow(`UPDATE findings SET severity=$1 WHERE id=$2 RETURNING node_id`, severity, id).Scan(&nodeID) + if err == sql.ErrNoRows { + return 0, nil + } + if err != nil { + return 0, err + } + if nodeID != nil { + // jsonb_set the {severity} key so the finding node (read by the per-task + // 发现 Tab) shows the same level. Ignore errors — the standalone row is the + // source of truth for the global 发现 page. + _, _ = d.Exec(`UPDATE exploration_nodes + SET payload = jsonb_set(payload, '{severity}', to_jsonb($1::text)) + WHERE id = $2`, severity, *nodeID) + } + return 1, nil +} + // FindingMeta is the standalone-row data (id, triage state, anchored assets) the // per-task view grafts onto its exploration-node findings. type FindingMeta struct { diff --git a/db/findings_test.go b/db/findings_test.go index 68b93f6..de5cd1b 100644 --- a/db/findings_test.go +++ b/db/findings_test.go @@ -100,4 +100,28 @@ func TestFindingsPageAndStats(t *testing.T) { if !slices.Contains(st.VulnClasses, vc) { t.Fatalf("stats vulnclasses missing %q", vc) } + + // GetFinding: single-row fetch round-trips id/name/severity. + one, err := d.GetFinding(ids[0]) + if err != nil { + t.Fatal(err) + } + if one == nil || one.ID != ids[0] || one.Severity != "critical" || one.Name != "严重漏洞标题" { + t.Fatalf("GetFinding mismatch: %+v", one) + } + if miss, err := d.GetFinding(-1); err != nil || miss != nil { + t.Fatalf("GetFinding(-1): want nil,nil got %+v,%v", miss, err) + } + + // SetFindingSeverity: standalone row updates; 0 rows for unknown id. + if n, err := d.SetFindingSeverity(ids[0], "high"); err != nil || n != 1 { + t.Fatalf("SetFindingSeverity: want 1,nil got %d,%v", n, err) + } + one, _ = d.GetFinding(ids[0]) + if one.Severity != "high" { + t.Fatalf("severity not updated: %q", one.Severity) + } + if n, err := d.SetFindingSeverity(-1, "low"); err != nil || n != 0 { + t.Fatalf("SetFindingSeverity(-1): want 0,nil got %d,%v", n, err) + } } diff --git a/server/server.go b/server/server.go index fe901cf..abe12f7 100644 --- a/server/server.go +++ b/server/server.go @@ -607,7 +607,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/frontier", s.frontier) mux.HandleFunc("GET /api/exploration/findings", s.findings) mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats) - mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.setFindingStatus) + mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding) + mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding) mux.HandleFunc("GET /api/exploration/intents", s.intents) mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph) mux.HandleFunc("GET /api/exploration/activity", s.activity) @@ -1464,35 +1465,89 @@ func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, st) } -// setFindingStatus updates one finding's triage state (pending / false_positive / -// ignored / resolved). The id is the standalone findings-table id (DTO finding_id). -func (s *Server) setFindingStatus(w http.ResponseWriter, r *http.Request) { +// getFinding returns one finding by its standalone-table id (DTO finding_id), +// with anchored assets resolved for display. +func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) { + id := int64(atoiDefault(r.PathValue("id"), 0)) + if id <= 0 { + writeErr(w, 400, "bad finding id") + return + } + f, err := s.m.pg.GetFinding(id) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if f == nil { + writeErr(w, 404, "finding not found") + return + } + assets := s.resolveAssetIDs(f.AssetIDs) + writeJSON(w, 200, findingFromDB(f, assets)) +} + +// patchFinding partially updates a finding: any subset of {status, severity}. +// The id is the standalone findings-table id (DTO finding_id). Severity edits are +// mirrored onto the originating exploration node so the per-task view stays in +// sync. Returns the updated finding DTO. +func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) { id := int64(atoiDefault(r.PathValue("id"), 0)) if id <= 0 { writeErr(w, 400, "bad finding id") return } var body struct { - Status string `json:"status"` + Status *string `json:"status"` + Severity *string `json:"severity"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeErr(w, 400, "bad json: "+err.Error()) return } - if !db.ValidFindingStatus(body.Status) { - writeErr(w, 400, "bad status: "+body.Status) + if body.Status == nil && body.Severity == nil { + writeErr(w, 400, "nothing to update: provide status and/or severity") return } - n, err := s.m.pg.SetFindingStatus(id, body.Status) + if body.Status != nil { + if !db.ValidFindingStatus(*body.Status) { + writeErr(w, 400, "bad status: "+*body.Status) + return + } + n, err := s.m.pg.SetFindingStatus(id, *body.Status) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if n == 0 { + writeErr(w, 404, "finding not found") + return + } + } + if body.Severity != nil { + if !db.ValidSeverity(*body.Severity) { + writeErr(w, 400, "bad severity: "+*body.Severity) + return + } + n, err := s.m.pg.SetFindingSeverity(id, *body.Severity) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if n == 0 { + writeErr(w, 404, "finding not found") + return + } + } + f, err := s.m.pg.GetFinding(id) if err != nil { writeErr(w, 500, err.Error()) return } - if n == 0 { + if f == nil { writeErr(w, 404, "finding not found") return } - writeJSON(w, 200, map[string]any{"id": i64s(id), "status": body.Status}) + writeJSON(w, 200, findingFromDB(f, s.resolveAssetIDs(f.AssetIDs))) } func (s *Server) intents(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx new file mode 100644 index 0000000..8bb3a5e --- /dev/null +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -0,0 +1,360 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { toast } from "sonner"; +import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react"; + +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@/components/ui/select"; +import { StatusBadge } from "@/components/status-badge"; +import { statusMeta } from "@/lib/status"; +import { api } from "@/lib/api"; +import type { Finding, FindingStatus, Severity } from "@/lib/types"; + +const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"]; +const FINDING_STATUSES: FindingStatus[] = [ + "pending", + "in_progress", + "confirmed", + "resolved", + "false_positive", + "ignored", + "duplicate", + "risk_accepted", +]; + +function fmtTime(ts: string) { + return new Date(ts).toLocaleString("zh-CN"); +} + +// FieldRow is one label/value line in the right-hand status panel. +function FieldRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + +
+ {children} +
+
+ ); +} + +function FindingDetailInner() { + const searchParams = useSearchParams(); + const id = searchParams.get("id") ?? ""; + const [finding, setFinding] = React.useState(null); + const [loaded, setLoaded] = React.useState(false); + const [tab, setTab] = React.useState("overview"); + + const load = React.useCallback(() => { + if (!id) { + setLoaded(true); + return; + } + api + .getFinding(id) + .then((f) => setFinding(f)) + .catch(() => setFinding(null)) + .finally(() => setLoaded(true)); + }, [id]); + React.useEffect(() => { + load(); + }, [load]); + + const changeSeverity = React.useCallback( + async (next: Severity) => { + if (!finding || next === finding.severity) return; + const prev = finding.severity; + setFinding({ ...finding, severity: next }); + try { + const updated = await api.setFindingSeverity(id, next); + setFinding(updated); + toast.success(`严重等级已改为「${statusMeta("severity", next).label}」`); + } catch (e) { + setFinding((cur) => (cur ? { ...cur, severity: prev } : cur)); + toast.error("更新失败:" + (e as Error).message); + } + }, + [finding, id], + ); + + const changeStatus = React.useCallback( + async (next: FindingStatus) => { + if (!finding || next === finding.status) return; + const prev = finding.status; + setFinding({ ...finding, status: next }); + try { + const updated = await api.setFindingStatus(id, next); + setFinding(updated); + toast.success(`处理状态已改为「${statusMeta("finding", next).label}」`); + } catch (e) { + setFinding((cur) => (cur ? { ...cur, status: prev } : cur)); + toast.error("更新失败:" + (e as Error).message); + } + }, + [finding, id], + ); + + if (!finding) { + return ( +
+

+ {loaded ? `未找到发现 ${id}` : "加载中…"} +

+ {loaded && ( + + )} +
+ ); + } + + const title = finding.name || finding.vulnclass || "未分类"; + + return ( + + {/* Sticky header */} +
+
+ + + +

+ {title} +

+ + #{finding.id} + + + + +
+ + 概览 + 证据 / PoC + 元数据 + +
+ + {/* Tab content */} +
+ {/* 概览:左(摘要 + 证据)/ 右(状态区) */} + +
+ {/* 左栏 */} +
+ + + 摘要 + + +

+ {finding.summary || "(无摘要)"} +

+
+
+ + + 证据 / PoC + + + {finding.evidence ? ( +
+                      {finding.evidence}
+                    
+ ) : ( +

(无证据)

+ )} +
+
+
+ + {/* 右栏:状态区 */} + + + 状态 + + + {/* 严重等级(可改) */} + + + + + {/* 处理状态(可改) */} + + + + + {/* 漏洞类型 */} + + {finding.vulnclass ? ( + + {finding.vulnclass} + + ) : ( + — + )} + + + {/* 涉及资产 */} + + {finding.assets && finding.assets.length > 0 ? ( +
+ {finding.assets.map((a) => ( + + {a.label} + + ))} +
+ ) : ( + — + )} +
+ + {/* 所属任务 */} + + {finding.task_id ? ( + + + {finding.task_description || `#${finding.task_id}`} + + + + ) : ( + —(任务已删除) + )} + + + {/* 发现时间 */} + + {fmtTime(finding.ts)} + +
+
+
+
+ + {/* 证据全文 */} + + + + {finding.evidence ? ( +
+                  {finding.evidence}
+                
+ ) : ( +

本发现暂无证据文本。

+ )} +
+
+
+ + {/* 元数据 */} + + + +
+ {[ + ["发现 ID", finding.id], + ["漏洞名称", finding.name || "(空,回退漏洞类型)"], + ["漏洞类型", finding.vulnclass || "—"], + ["严重等级", statusMeta("severity", finding.severity).label], + ["处理状态", statusMeta("finding", finding.status).label], + ["所属任务", finding.task_description || finding.task_id || "—"], + ["涉及资产数", String(finding.assets?.length ?? 0)], + ["发现时间", fmtTime(finding.ts)], + ].map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+
+
+
+
+ ); +} + +// useSearchParams must sit under a Suspense boundary for static export. +export default function FindingDetailPage() { + return ( + + + + ); +} diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index 4b856b9..035c194 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -295,9 +295,20 @@ export default function FindingsPage() {
- - {f.name || f.vulnclass || "未分类"} - + {f.finding_id ? ( + e.stopPropagation()} + className="truncate font-medium hover:text-primary hover:underline" + title="查看发现详情" + > + {f.name || f.vulnclass || "未分类"} + + ) : ( + + {f.name || f.vulnclass || "未分类"} + + )} {f.summary} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 92f04ca..4ca7ee9 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -6,7 +6,7 @@ import { MOCK } from "@/lib/mock/enabled"; import { mockHandle } from "@/lib/mock/handler"; import type { - Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, FindingStatus, + Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus, FindingsPage, FindingStats, FindingQuery, Activity, Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar, PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket, @@ -245,8 +245,11 @@ export const api = { return get(`/exploration/findings?${p.toString()}`); }, findingStats: () => get("/exploration/findings/stats"), + getFinding: (id: string) => get(`/exploration/findings/${id}`), setFindingStatus: (id: string, status: FindingStatus) => - patch<{ id: string; status: FindingStatus }>(`/exploration/findings/${id}`, { status }), + patch(`/exploration/findings/${id}`, { status }), + setFindingSeverity: (id: string, severity: Severity) => + patch(`/exploration/findings/${id}`, { severity }), intents: (task?: string) => get(`/exploration/intents${tq(task)}`).then(arr), tokenStats: (task?: string) => get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({ diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index ced95cb..6d8585d 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -85,10 +85,28 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re vulnclasses, }; } + // 单条 finding:GET 详情 / PATCH 改状态或严重度(demo 直接改内存对象)。 + if ( + seg[0] === "exploration" && + seg[1] === "findings" && + seg.length === 3 && + seg[2] !== "stats" + ) { + const f = D.findings.find((x) => x.id === seg[2]); + if (!f) return {}; + if (m === "PATCH") { + if (typeof b.status === "string") f.status = b.status as typeof f.status; + if (typeof b.severity === "string") + f.severity = b.severity as typeof f.severity; + } + return { ...f, finding_id: f.id }; + } if (path === "/exploration/findings") { - if (task) return D.findings.filter((f) => f.task_id === task); + // finding_id=id:真后端用独立表行 id 作为状态/详情句柄,mock 里用自身 id 顶上。 + const withFid = (f: (typeof D.findings)[number]) => ({ ...f, finding_id: f.id }); + if (task) return D.findings.filter((f) => f.task_id === task).map(withFid); // 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。 - if (!q.has("page") && !q.has("limit")) return D.findings; + if (!q.has("page") && !q.has("limit")) return D.findings.map(withFid); const sev = { critical: 4, high: 3, medium: 2, low: 1 } as const; let list = D.findings.slice(); const fSev = q.get("severity"); @@ -105,7 +123,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re const page = Number(q.get("page") ?? 1); const pageSize = Number(q.get("limit") ?? 20); return { - items: list.slice((page - 1) * pageSize, page * pageSize), + items: list.slice((page - 1) * pageSize, page * pageSize).map(withFid), total: list.length, page, page_size: pageSize,