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()}`, {
|
||||
|
||||
@@ -548,8 +548,41 @@ export interface FindingQuery {
|
||||
task?: string; // 任务 id;"all"/空 = 不按任务筛选
|
||||
query?: string;
|
||||
sort?: "severity" | "time";
|
||||
// 资产树节点 key;选中一个节点 = 选中它的整棵子树。空 = 不按资产筛选。
|
||||
assetScope?: string;
|
||||
}
|
||||
|
||||
// ---- Findings by asset (资产视图) ----
|
||||
export type FindingAssetKind = "company" | "root_domain" | "subdomain" | "ip" | "service" | "app" | "endpoint" | "none";
|
||||
|
||||
// FindingAssetNode 是资产树的一个节点。key 形如 a:<id>(资产)、c:<id>(企业)、
|
||||
// r:<domain>(库里没有资产行的根域名)、__none__(未关联资产)。
|
||||
export interface FindingAssetNode {
|
||||
key: string;
|
||||
parent?: string;
|
||||
kind: FindingAssetKind;
|
||||
label: string;
|
||||
asset_id?: number;
|
||||
company_id?: number;
|
||||
self: number; // 直接挂在该资产上的发现数
|
||||
total: number; // 含子孙、按发现去重
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
last_found_at: string;
|
||||
}
|
||||
|
||||
export interface FindingAssetTree {
|
||||
nodes: FindingAssetNode[];
|
||||
finding_total: number;
|
||||
truncated: boolean;
|
||||
dropped_kinds?: string[];
|
||||
}
|
||||
|
||||
// FINDING_UNASSIGNED_ASSET 与后端 db.FindingUnassignedAsset 对应。
|
||||
export const FINDING_UNASSIGNED_ASSET = "__none__";
|
||||
|
||||
// ---- Activity / sessions ----
|
||||
export type ActivityKind =
|
||||
| "tool_use"
|
||||
|
||||
Reference in new issue
Block a user