feat: 漏洞详情页删除证据/元数据 Tab,新增「链路图」

链路图展示该漏洞所属任务探索图里,从初始节点(任务根)追溯到本漏洞节点的
那条链路:漏洞节点 + 其全部祖先(沿边反向可达)+ 之间的关系边。

- 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 测试通过。
This commit is contained in:
Autumn-27 committed 2026-08-18 13:08:03 -04:00
1 parent 33714a5ff0
commit b741ee176c
6 files changed
+356 -37

No files matched your search

+41 -1
View File
@@ -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