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:
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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user