feat(findings): 发现页新增「按资产」视图
左侧资产树 + 右侧该子树下的发现,与另外两个视图共用同一张表和同一批筛选。 后端 - db/finding_assets.go:按当前筛选构建资产树。只收有发现的资产,祖先链逐层 补齐(endpoint→service→subdomain/ip→root_domain),父子优先级与 BuildCoverageGraph 同源;计数沿祖先链去重累加,一条发现挂多个资产不会让 父节点重复计数 - FindingFilter 加 AssetScope:选中节点=选中整棵子树,解析成资产 id 集合后 下推 SQL,列表 / 分组 / 导出三个入口共用,导出「当前筛选」自动带上 - 「未关联资产」同时收 asset_ids 为空与指向已删资产的发现,与树上桶的口径 一致(只判空数组会让桶上的数字大于点开后的条数) - findingFilterFromQuery 统一四处的筛选解析;新增 GIN 索引 idx_findings_asset_ids,按资产反查发现不再全表扫 前端 - 树默认展开一层,支持过滤(匹配项自动展开)、面包屑下钻、手动刷新 - 每层只显示相对上一层的增量:子域名去根域名后缀、服务显示 `https :443`、 接口只留路径。完整值在 title 与面包屑,搜索仍按完整值匹配 - 不轮询:进入视图 / 改筛选 / 页内改动发现 / 点刷新时才查 - 节点超上限时整层丢弃接口/服务并提示,计数已在上层不会丢
This commit is contained in:
1 parent
796dbf5600
commit
23cfb9ed4e
11 files changed
+1582
-82
No files matched your search
+26
-21
@@ -29,6 +29,7 @@ import type {
|
||||
DeleteTaskResult,
|
||||
Edge,
|
||||
Finding,
|
||||
FindingAssetTree,
|
||||
FindingDeepenResponse,
|
||||
FindingGroupsPage,
|
||||
FindingQuery,
|
||||
@@ -178,6 +179,20 @@ const del = <T>(p: string, body?: unknown) =>
|
||||
const arr = <T>(x: T[] | null | undefined): T[] => x ?? [];
|
||||
const tq = (task?: string, sep: "?" | "&" = "?") => (task ? `${sep}task=${encodeURIComponent(task)}` : "");
|
||||
|
||||
// findingFilterParams 把发现页的筛选条件序列化成 query string。列表 / 分组 /
|
||||
// 资产树 / 导出共用同一份,新增筛选项只改这里(后端也只解析这一份)。
|
||||
function findingFilterParams(q: Omit<FindingQuery, "page" | "pageSize">): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
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.query?.trim()) p.set("q", q.query.trim());
|
||||
if (q.sort) p.set("sort", q.sort);
|
||||
if (q.assetScope) p.set("asset_scope", q.assetScope);
|
||||
return p;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。
|
||||
health: () => get<{ ok: boolean; service: string; version: string }>("/health"),
|
||||
@@ -456,25 +471,21 @@ export const api = {
|
||||
frontier: (task?: string) => get<TaskNode[]>(`/exploration/frontier${tq(task)}`).then(arr),
|
||||
findings: (task?: string) => get<Finding[]>(`/exploration/findings${tq(task)}`).then(arr),
|
||||
findingsPage: (q: FindingQuery) => {
|
||||
const p = new URLSearchParams({ page: String(q.page), limit: String(q.pageSize) });
|
||||
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.query?.trim()) p.set("q", q.query.trim());
|
||||
if (q.sort) p.set("sort", q.sort);
|
||||
const p = findingFilterParams(q);
|
||||
p.set("page", String(q.page));
|
||||
p.set("limit", String(q.pageSize));
|
||||
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
|
||||
},
|
||||
findingGroups: (q: FindingQuery) => {
|
||||
const p = new URLSearchParams({ page: String(q.page), limit: String(q.pageSize) });
|
||||
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.query?.trim()) p.set("q", q.query.trim());
|
||||
if (q.sort) p.set("sort", q.sort);
|
||||
const p = findingFilterParams(q);
|
||||
p.set("page", String(q.page));
|
||||
p.set("limit", String(q.pageSize));
|
||||
return get<FindingGroupsPage>(`/exploration/findings/groups?${p.toString()}`);
|
||||
},
|
||||
// findingAssetTree 取「按资产」视图的左侧树:只含有发现的资产及其祖先,
|
||||
// 节点带子树聚合计数。不分页——树是导航结构,一次取完。
|
||||
findingAssetTree: (q: Omit<FindingQuery, "page" | "pageSize">) =>
|
||||
get<FindingAssetTree>(`/exploration/findings/asset-tree?${findingFilterParams(q).toString()}`),
|
||||
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
|
||||
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
|
||||
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
|
||||
@@ -488,13 +499,7 @@ export const api = {
|
||||
if (opts.scope === "selected") {
|
||||
p.set("ids", (opts.ids ?? []).join(","));
|
||||
} else if (opts.scope === "filtered" && opts.filters) {
|
||||
const f = opts.filters;
|
||||
if (f.severity && f.severity !== "all") p.set("severity", f.severity);
|
||||
if (f.status && f.status !== "all") p.set("status", f.status);
|
||||
if (f.vulnclass && f.vulnclass !== "all") p.set("vulnclass", f.vulnclass);
|
||||
if (f.task && f.task !== "all") p.set("task_id", f.task);
|
||||
if (f.query?.trim()) p.set("q", f.query.trim());
|
||||
if (f.sort) p.set("sort", f.sort);
|
||||
for (const [k, v] of findingFilterParams(opts.filters)) p.set(k, v);
|
||||
}
|
||||
const token = getToken();
|
||||
const r = await fetch(`/api/exploration/findings/export?${p.toString()}`, {
|
||||
|
||||
Reference in new issue
Block a user