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

+60
View File
@@ -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).
+21
View File
@@ -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 {
+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
@@ -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<ViewKind, { label: string; icon: LucideIcon; cls: string }> = {
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<string, string> = {
spawns: "派生",
derived_from: "意图链",
yields: "产出",
proves: "证明",
};
const SUMMARY_FIELDS: Record<ViewKind, string[]> = {
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<string, unknown>)[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<string, string[]>();
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<string, number>();
const visiting = new Set<string>();
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<TaskNode[]>([]);
const [edges, setEdges] = React.useState<Edge[]>([]);
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<string, { from: string; rel: string }[]>();
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 <p className="text-muted-foreground p-6 text-sm">加载中…</p>;
}
if (nodes.length === 0) {
return (
<p className="text-muted-foreground p-6 text-sm">
无链路可展示(该漏洞未关联探索节点,或所属任务已删除)。
</p>
);
}
return (
<div className="flex flex-col gap-0">
<p className="text-muted-foreground mb-3 text-xs">
从任务初始节点回溯到本漏洞节点的探索链路(共 {nodes.length} 个节点)。
</p>
{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 (
<div key={n.id} className="flex flex-col">
{/* 入边关系(除根外):从哪个父节点、什么关系而来 */}
{i > 0 && ins.length > 0 && (
<div className="text-muted-foreground ml-4 flex flex-col gap-0.5 border-l-2 py-1.5 pl-4 text-xs">
{ins.map((e, k) => {
const parent = byId.get(e.from);
const pKind = parent ? viewKind(parent) : "fact";
return (
<div key={`${e.from}-${e.rel}-${k}`} className="flex items-center gap-1">
<CornerDownRightIcon className="size-3 shrink-0" />
<span className="rounded bg-muted px-1 py-0.5 font-medium">
{REL_LABEL[e.rel] ?? e.rel}
</span>
<span className="truncate">
来自 {KIND_META[pKind]?.label ?? pKind}
{parent && (
<span className="opacity-70"> · {nodeSummary(parent).slice(0, 40)}</span>
)}
</span>
</div>
);
})}
</div>
)}
{/* 节点卡片 */}
<div
className={cn(
"rounded-md border p-3",
isFinding ? "border-rose-500/60 bg-rose-500/5 ring-1 ring-rose-500/30" : "bg-card",
)}
>
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-xs font-medium",
meta.cls,
)}
>
<Icon className="size-3" />
{meta.label}
</span>
{n.state && n.state !== "origin" && (
<span className="text-muted-foreground text-[10px] uppercase">{n.state}</span>
)}
{isFinding && (
<span className="text-rose-600 dark:text-rose-400 text-xs font-medium">← 本漏洞</span>
)}
<span className="text-muted-foreground ml-auto font-mono text-[10px]">#{n.id}</span>
</div>
<p className="mt-1.5 text-sm whitespace-pre-wrap">{nodeSummary(n)}</p>
</div>
</div>
);
})}
</div>
);
}
@@ -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() {
</div>
<TabsList>
<TabsTrigger value="overview">概览</TabsTrigger>
<TabsTrigger value="evidence">证据 / PoC</TabsTrigger>
<TabsTrigger value="meta">元数据</TabsTrigger>
<TabsTrigger value="lineage">链路图</TabsTrigger>
</TabsList>
</header>
@@ -221,6 +221,13 @@ function FindingDetailInner() {
<CardTitle className="text-sm">状态</CardTitle>
</CardHeader>
<CardContent className="divide-y">
{/* 漏洞 ID */}
<FieldRow label="漏洞 ID">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
#{finding.id}
</code>
</FieldRow>
{/* 严重等级(可改) */}
<FieldRow label="严重等级">
<Select
@@ -322,42 +329,11 @@ function FindingDetailInner() {
</div>
</TabsContent>
{/* 证据全文 */}
<TabsContent value="evidence" className="mt-0">
{/* 链路图:从任务初始节点回溯到本漏洞节点的探索链路 */}
<TabsContent value="lineage" className="mt-0">
<Card>
<CardContent className="pt-6">
{finding.evidence ? (
<pre className="overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{finding.evidence}
</pre>
) : (
<p className="text-sm text-muted-foreground">本发现暂无证据文本。</p>
)}
</CardContent>
</Card>
</TabsContent>
{/* 元数据 */}
<TabsContent value="meta" className="mt-0">
<Card>
<CardContent className="pt-6">
<dl className="grid grid-cols-1 gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
{[
["发现 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]) => (
<div key={k} className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">{k}</dt>
<dd className="break-all">{v}</dd>
</div>
))}
</dl>
<FindingLineageView findingId={finding.id} />
</CardContent>
</Card>
</TabsContent>
+4
View File
@@ -241,11 +241,15 @@ export const api = {
if (q.severity && q.severity !== "all") p.set("severity", q.severity);
if (q.status && q.status !== "all") p.set("status", q.status);
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
if (q.task && q.task !== "all") p.set("task_id", q.task);
if (q.sort) p.set("sort", q.sort);
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
},
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
getFinding: (id: string) => get<Finding>(`/exploration/findings/${id}`),
// 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。
findingLineage: (id: string) =>
get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
setFindingStatus: (id: string, status: FindingStatus) =>
patch<Finding>(`/exploration/findings/${id}`, { status }),
setFindingSeverity: (id: string, severity: Severity) =>