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

@@ -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) =>