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()
|
||||
}
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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