From b741ee176c3e1bf7f074a68a12bece8bf65fc411 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:08:03 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=BC=8F=E6=B4=9E=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=E5=88=A0=E9=99=A4=E8=AF=81=E6=8D=AE/=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=20Tab,=E6=96=B0=E5=A2=9E=E3=80=8C=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E5=9B=BE=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 链路图展示该漏洞所属任务探索图里,从初始节点(任务根)追溯到本漏洞节点的 那条链路:漏洞节点 + 其全部祖先(沿边反向可达)+ 之间的关系边。 - db.ExplorationStore.FindingLineage(nodeID):递归 CTE 取祖先子图(节点+边), 下游(如 finding→proves→goal)与无关节点不纳入 - GET /api/exploration/findings/{id}/lineage:解析 finding 的 node_id/task_id → 返回 {nodes,edges};无节点/任务已删 → 空图 - 前端 api.findingLineage;详情页 Tab 改为「概览 / 链路图」 - lineage.tsx:按深度拓扑排序,自根到漏洞纵向展示节点卡片 + 入边关系标注, 漏洞节点高亮 - db 测试补 FindingLineage 断言(含祖先/排除下游 goal/排除无关意图) go build / db / server 测试通过。 --- db/exploration.go | 60 +++++ db/exploration_test.go | 21 ++ server/server.go | 42 +++- .../function/findings/detail/lineage.tsx | 218 ++++++++++++++++++ .../(main)/function/findings/detail/page.tsx | 48 +--- web/src/lib/api.ts | 4 + 6 files changed, 356 insertions(+), 37 deletions(-) create mode 100644 web/src/app/(main)/function/findings/detail/lineage.tsx diff --git a/db/exploration.go b/db/exploration.go index 00f6024..94f6086 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -372,6 +372,66 @@ func (s *ExplorationStore) Edges(limit int) ([]Edge, error) { return out, rows.Err() } +// FindingLineage returns the sub-DAG that leads FROM the exploration root TO the +// given node: the node itself plus all its ancestors (nodes reverse-reachable by +// following edges backward), and every edge whose both endpoints are in that set. +// Used to render "how this finding was reached" — origin → … → finding. Returns +// empty (not error) for a nonexistent node. +func (s *ExplorationStore) FindingLineage(nodeID int64) ([]*Node, []Edge, error) { + // anc = the node + everything that can reach it via edges (walk src<-dst). + nodeRows, err := s.db.Query(` +WITH RECURSIVE anc(id) AS ( + SELECT $2::bigint + UNION + SELECT e.src_id + FROM exploration_edges e + JOIN anc ON e.dst_id = anc.id + WHERE e.exploration_id = $1 +) +SELECT `+nodeCols+` +FROM exploration_nodes +WHERE exploration_id = $1 AND id IN (SELECT id FROM anc) +ORDER BY id`, s.expID, nodeID) + if err != nil { + return nil, nil, err + } + nodes, err := scanNodes(nodeRows) + if err != nil { + return nil, nil, err + } + if len(nodes) == 0 { + return nil, nil, nil + } + // edges among the ancestor set only (the lineage's internal relations). + edgeRows, err := s.db.Query(` +WITH RECURSIVE anc(id) AS ( + SELECT $2::bigint + UNION + SELECT e.src_id + FROM exploration_edges e + JOIN anc ON e.dst_id = anc.id + WHERE e.exploration_id = $1 +) +SELECT src_id, rel, dst_id +FROM exploration_edges +WHERE exploration_id = $1 + AND src_id IN (SELECT id FROM anc) + AND dst_id IN (SELECT id FROM anc)`, s.expID, nodeID) + if err != nil { + return nil, nil, err + } + defer edgeRows.Close() + var edges []Edge + for edgeRows.Next() { + var e Edge + if err := edgeRows.Scan(&e.From, &e.Rel, &e.To); err != nil { + return nil, nil, err + } + edges = append(edges, e) + } + return nodes, edges, edgeRows.Err() +} + // FindingIntents maps each finding id to the intent that produced it (the // intent --yields--> finding edge; report_finding links it). Findings with no // producing intent are absent. Precise (JOIN, no edge-scan limit). diff --git a/db/exploration_test.go b/db/exploration_test.go index 5d6154f..1c31d9f 100644 --- a/db/exploration_test.go +++ b/db/exploration_test.go @@ -63,6 +63,27 @@ func TestExplorationFlow(t *testing.T) { t.Fatal(err) } + // lineage: ancestors of the finding traced backward — here {i2, find} joined by + // the yields edge. The proves→goal edge is DOWNSTREAM (goal must be excluded), + // and the unrelated intent i1 is not on any path to the finding (excluded too). + lnNodes, lnEdges, err := es.FindingLineage(find) + if err != nil { + t.Fatal(err) + } + got := map[int64]bool{} + for _, n := range lnNodes { + got[n.ID] = true + } + if len(lnNodes) != 2 || !got[find] || !got[i2] { + t.Fatalf("lineage nodes: want {i2,find}, got %+v", lnNodes) + } + if got[goal] { + t.Fatalf("lineage must exclude the proved goal (it is downstream of the finding)") + } + if len(lnEdges) != 1 || lnEdges[0].From != i2 || lnEdges[0].To != find || lnEdges[0].Rel != "yields" { + t.Fatalf("lineage edges: want i2-yields->find, got %+v", lnEdges) + } + // activity poll by id cursor id1, err := es.AppendActivity(Activity{Worker: "worker-1", Kind: "tool_use", Tool: "Bash", Summary: "ran curl", Detail: "full output"}) if err != nil { diff --git a/server/server.go b/server/server.go index abe12f7..c53fea8 100644 --- a/server/server.go +++ b/server/server.go @@ -608,6 +608,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/findings", s.findings) mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats) 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("GET /api/exploration/intents", s.intents) mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph) @@ -1377,7 +1378,9 @@ func (s *Server) findings(w http.ResponseWriter, r *http.Request) { Severity: normFilter(q.Get("severity")), Status: normFilter(q.Get("status")), VulnClass: normFilter(q.Get("vulnclass")), - Sort: q.Get("sort"), + // task_id(独立于会切到「按任务节点」分支的 task 参数):全局表按任务筛选。 + TaskID: normFilter(q.Get("task_id")), + Sort: q.Get("sort"), } fs, total, err := s.m.pg.ListFindingsPage(filter, page, limit) if err != nil { @@ -1486,6 +1489,43 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, findingFromDB(f, assets)) } +// findingLineage returns the exploration sub-DAG from the task root to this +// finding's node — the finding node + all its ancestors + edges among them — so +// the detail page can show "how this finding was reached". Empty {nodes,edges} +// when the finding has no node/task (e.g. the originating task was deleted). +func (s *Server) findingLineage(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 + } + empty := map[string]any{"nodes": []any{}, "edges": []any{}} + if f.NodeID == nil || f.TaskID == nil { + writeJSON(w, 200, empty) + return + } + t := s.m.ResolveTask(i64s(*f.TaskID)) + if t == nil { + writeJSON(w, 200, empty) + return + } + nodes, edges, err := t.Store.FindingLineage(*f.NodeID) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"nodes": taskNodeDTOs(nodes), "edges": edgeDTOs(edges)}) +} + // 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 diff --git a/web/src/app/(main)/function/findings/detail/lineage.tsx b/web/src/app/(main)/function/findings/detail/lineage.tsx new file mode 100644 index 0000000..c6a112c --- /dev/null +++ b/web/src/app/(main)/function/findings/detail/lineage.tsx @@ -0,0 +1,218 @@ +"use client"; + +import * as React from "react"; +import { + BugIcon, + CompassIcon, + FlagIcon, + FlaskConicalIcon, + LightbulbIcon, + type LucideIcon, + MapPinIcon, + CornerDownRightIcon, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { api } from "@/lib/api"; +import type { Edge, ExploreKind, TaskNode } from "@/lib/types"; + +// viewKind renders the task-root fact (origin) as a distinct "起点". +type ViewKind = ExploreKind | "begin"; +function viewKind(n: TaskNode): ViewKind { + return n.type === "fact" && n.state === "origin" ? "begin" : n.type; +} + +const KIND_META: Record = { + begin: { label: "起点", icon: MapPinIcon, cls: "text-slate-500 border-slate-500/30 bg-slate-500/10" }, + task: { label: "任务", icon: MapPinIcon, cls: "text-slate-500 border-slate-500/30 bg-slate-500/10" }, + goal: { label: "目标", icon: FlagIcon, cls: "text-violet-600 border-violet-500/30 bg-violet-500/10 dark:text-violet-400" }, + intent: { label: "意图", icon: CompassIcon, cls: "text-blue-600 border-blue-500/30 bg-blue-500/10 dark:text-blue-400" }, + fact: { label: "事实", icon: FlaskConicalIcon, cls: "text-amber-600 border-amber-500/30 bg-amber-500/10 dark:text-amber-400" }, + finding: { label: "漏洞", icon: BugIcon, cls: "text-rose-600 border-rose-500/30 bg-rose-500/10 dark:text-rose-400" }, + hint: { label: "提示", icon: LightbulbIcon, cls: "text-emerald-600 border-emerald-500/30 bg-emerald-500/10 dark:text-emerald-400" }, +}; + +const REL_LABEL: Record = { + spawns: "派生", + derived_from: "意图链", + yields: "产出", + proves: "证明", +}; + +const SUMMARY_FIELDS: Record = { + task: ["summary", "description"], + begin: ["summary", "description"], + goal: ["text"], + hint: ["text"], + intent: ["summary"], + fact: ["summary"], + finding: ["name", "summary"], +}; + +// nodeSummary pulls the display text out of the node's JSON payload. +function nodeSummary(n: TaskNode): string { + const raw = n.payload ?? ""; + const fields = SUMMARY_FIELDS[viewKind(n)] ?? []; + if (fields.length && raw.trim()) { + try { + const obj: unknown = JSON.parse(raw); + if (obj && typeof obj === "object") { + for (const f of fields) { + const v = (obj as Record)[f]; + if (typeof v === "string" && v.trim()) return v; + } + } + } catch { + /* 非 JSON:原样显示 */ + } + } + return raw || "(无内容)"; +} + +// orderByDepth topologically layers the lineage nodes by their longest distance +// from a root (a node with no incoming edge inside the set), so the chain renders +// origin → … → finding top-down. Cycle-safe (caps iterations). +function orderByDepth(nodes: TaskNode[], edges: Edge[]): TaskNode[] { + const ids = new Set(nodes.map((n) => n.id)); + const parents = new Map(); + for (const e of edges) { + if (ids.has(e.src) && ids.has(e.dst)) { + parents.set(e.dst, [...(parents.get(e.dst) ?? []), e.src]); + } + } + const depth = new Map(); + const visiting = new Set(); + const calc = (id: string): number => { + const cached = depth.get(id); + if (cached !== undefined) return cached; + if (visiting.has(id)) return 0; // 回边:不再深入 + visiting.add(id); + const ps = parents.get(id) ?? []; + const d = ps.length === 0 ? 0 : 1 + Math.max(...ps.map(calc)); + visiting.delete(id); + depth.set(id, d); + return d; + }; + return [...nodes].sort((a, b) => { + const d = calc(a.id) - calc(b.id); + return d !== 0 ? d : Number(a.id) - Number(b.id); + }); +} + +export function FindingLineageView({ findingId }: { findingId: string }) { + const [nodes, setNodes] = React.useState([]); + const [edges, setEdges] = React.useState([]); + const [loaded, setLoaded] = React.useState(false); + + React.useEffect(() => { + let alive = true; + api + .findingLineage(findingId) + .then((g) => { + if (!alive) return; + setNodes(g.nodes); + setEdges(g.edges); + }) + .catch(() => {}) + .finally(() => { + if (alive) setLoaded(true); + }); + return () => { + alive = false; + }; + }, [findingId]); + + const ordered = React.useMemo(() => orderByDepth(nodes, edges), [nodes, edges]); + const byId = React.useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); + // 每个节点的入边(父节点 + 关系),用于在卡片上方标注"从哪来、什么关系"。 + const incoming = React.useMemo(() => { + const m = new Map(); + const ids = new Set(nodes.map((n) => n.id)); + for (const e of edges) { + if (ids.has(e.src) && ids.has(e.dst)) { + m.set(e.dst, [...(m.get(e.dst) ?? []), { from: e.src, rel: e.rel }]); + } + } + return m; + }, [nodes, edges]); + + if (!loaded) { + return

加载中…

; + } + if (nodes.length === 0) { + return ( +

+ 无链路可展示(该漏洞未关联探索节点,或所属任务已删除)。 +

+ ); + } + + return ( +
+

+ 从任务初始节点回溯到本漏洞节点的探索链路(共 {nodes.length} 个节点)。 +

+ {ordered.map((n, i) => { + const vk = viewKind(n); + const meta = KIND_META[vk] ?? KIND_META.fact; + const Icon = meta.icon; + const isFinding = vk === "finding"; + const ins = incoming.get(n.id) ?? []; + return ( +
+ {/* 入边关系(除根外):从哪个父节点、什么关系而来 */} + {i > 0 && ins.length > 0 && ( +
+ {ins.map((e, k) => { + const parent = byId.get(e.from); + const pKind = parent ? viewKind(parent) : "fact"; + return ( +
+ + + {REL_LABEL[e.rel] ?? e.rel} + + + 来自 {KIND_META[pKind]?.label ?? pKind} + {parent && ( + · {nodeSummary(parent).slice(0, 40)} + )} + +
+ ); + })} +
+ )} + {/* 节点卡片 */} +
+
+ + + {meta.label} + + {n.state && n.state !== "origin" && ( + {n.state} + )} + {isFinding && ( + ← 本漏洞 + )} + #{n.id} +
+

{nodeSummary(n)}

+
+
+ ); + })} +
+ ); +} diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index 50de463..4fd9b8f 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -19,6 +19,7 @@ import { } from "@/components/ui/select"; import { StatusBadge } from "@/components/status-badge"; import { Markdown } from "@/components/markdown"; +import { FindingLineageView } from "./lineage"; import { statusMeta } from "@/lib/status"; import { api } from "@/lib/api"; import type { Finding, FindingStatus, Severity } from "@/lib/types"; @@ -162,8 +163,7 @@ function FindingDetailInner() { 概览 - 证据 / PoC - 元数据 + 链路图 @@ -221,6 +221,13 @@ function FindingDetailInner() { 状态 + {/* 漏洞 ID */} + + + #{finding.id} + + + {/* 严重等级(可改) */}