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
@@ -372,6 +372,66 @@ func (s *ExplorationStore) Edges(limit int) ([]Edge, error) {
|
|||||||
return out, rows.Err()
|
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
|
// FindingIntents maps each finding id to the intent that produced it (the
|
||||||
// intent --yields--> finding edge; report_finding links it). Findings with no
|
// intent --yields--> finding edge; report_finding links it). Findings with no
|
||||||
// producing intent are absent. Precise (JOIN, no edge-scan limit).
|
// producing intent are absent. Precise (JOIN, no edge-scan limit).
|
||||||
|
|||||||
@@ -63,6 +63,27 @@ func TestExplorationFlow(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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
|
// activity poll by id cursor
|
||||||
id1, err := es.AppendActivity(Activity{Worker: "worker-1", Kind: "tool_use", Tool: "Bash", Summary: "ran curl", Detail: "full output"})
|
id1, err := es.AppendActivity(Activity{Worker: "worker-1", Kind: "tool_use", Tool: "Bash", Summary: "ran curl", Detail: "full output"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+41
-1
@@ -608,6 +608,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/exploration/findings", s.findings)
|
mux.HandleFunc("GET /api/exploration/findings", s.findings)
|
||||||
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
|
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}", s.getFinding)
|
||||||
|
mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage)
|
||||||
mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding)
|
mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding)
|
||||||
mux.HandleFunc("GET /api/exploration/intents", s.intents)
|
mux.HandleFunc("GET /api/exploration/intents", s.intents)
|
||||||
mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph)
|
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")),
|
Severity: normFilter(q.Get("severity")),
|
||||||
Status: normFilter(q.Get("status")),
|
Status: normFilter(q.Get("status")),
|
||||||
VulnClass: normFilter(q.Get("vulnclass")),
|
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)
|
fs, total, err := s.m.pg.ListFindingsPage(filter, page, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1486,6 +1489,43 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 200, findingFromDB(f, assets))
|
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}.
|
// patchFinding partially updates a finding: any subset of {status, severity}.
|
||||||
// The id is the standalone findings-table id (DTO finding_id). Severity edits are
|
// 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
|
// 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";
|
} from "@/components/ui/select";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
import { Markdown } from "@/components/markdown";
|
import { Markdown } from "@/components/markdown";
|
||||||
|
import { FindingLineageView } from "./lineage";
|
||||||
import { statusMeta } from "@/lib/status";
|
import { statusMeta } from "@/lib/status";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Finding, FindingStatus, Severity } from "@/lib/types";
|
import type { Finding, FindingStatus, Severity } from "@/lib/types";
|
||||||
@@ -162,8 +163,7 @@ function FindingDetailInner() {
|
|||||||
</div>
|
</div>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||||
<TabsTrigger value="evidence">证据 / PoC</TabsTrigger>
|
<TabsTrigger value="lineage">链路图</TabsTrigger>
|
||||||
<TabsTrigger value="meta">元数据</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -221,6 +221,13 @@ function FindingDetailInner() {
|
|||||||
<CardTitle className="text-sm">状态</CardTitle>
|
<CardTitle className="text-sm">状态</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="divide-y">
|
<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="严重等级">
|
<FieldRow label="严重等级">
|
||||||
<Select
|
<Select
|
||||||
@@ -322,42 +329,11 @@ function FindingDetailInner() {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 证据全文 */}
|
{/* 链路图:从任务初始节点回溯到本漏洞节点的探索链路 */}
|
||||||
<TabsContent value="evidence" className="mt-0">
|
<TabsContent value="lineage" className="mt-0">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
{finding.evidence ? (
|
<FindingLineageView findingId={finding.id} />
|
||||||
<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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -241,11 +241,15 @@ export const api = {
|
|||||||
if (q.severity && q.severity !== "all") p.set("severity", q.severity);
|
if (q.severity && q.severity !== "all") p.set("severity", q.severity);
|
||||||
if (q.status && q.status !== "all") p.set("status", q.status);
|
if (q.status && q.status !== "all") p.set("status", q.status);
|
||||||
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
|
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);
|
if (q.sort) p.set("sort", q.sort);
|
||||||
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
|
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
|
||||||
},
|
},
|
||||||
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
|
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
|
||||||
getFinding: (id: string) => get<Finding>(`/exploration/findings/${id}`),
|
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) =>
|
setFindingStatus: (id: string, status: FindingStatus) =>
|
||||||
patch<Finding>(`/exploration/findings/${id}`, { status }),
|
patch<Finding>(`/exploration/findings/${id}`, { status }),
|
||||||
setFindingSeverity: (id: string, severity: Severity) =>
|
setFindingSeverity: (id: string, severity: Severity) =>
|
||||||
|
|||||||
Reference in new issue
Block a user