fix(findings): 补齐「按资产」视图的 mock,修两处标签重复

demo 模式(NEXT_PUBLIC_MOCK=1)下资产树接口没有实现,点开是空白。

- mock findings 关联到 fixture 里已有的资产:一条挂两个资产(验证父节点不重复
  计数)、一条不挂(演示「未关联资产」桶)
- mock handler 实现 asset-tree,并在平铺/分组两个列表分支接上 asset_scope。
  父子优先级、祖先补齐、去重计数、企业层出现条件都与 db/finding_assets.go
  一致;顺带把三处重复的筛选链抽成 mockFilterFindings
- 没有 URL 的服务(SMB、非 HTTP 端口)标签回退成裸 IP,与宿主那行显示得一模
  一样 —— 后端与 mock 都改为补端口(10.10.10.20:8080)
- 接口挂在 host:port 形式的服务下时没触发简写,仍显示完整 URL。宿主判断统一
  成解析后的 host,现在显示 /script

跑 mock handler 实测:17 个节点逐个对账「树上 total == 该节点子树查出的条数」,
无不一致。
This commit is contained in:
Autumn-27 committed 2026-08-31 11:10:47 -04:00
1 parent 23cfb9ed4e
commit 48e14e8cf2
4 files changed
+352 -33

No files matched your search

+282 -22
View File
@@ -300,6 +300,22 @@ function mockAssetMatchesDSL(asset: Asset, dsl: string): boolean {
return query.split(/\s+/).every((term) => haystack.includes(term));
}
// mockFilterFindings 应用发现页的公共筛选(严重度/状态/类型/任务/关键词),资产
// 子树筛选另走 mockApplyAssetScope —— 与后端 FindingFilter.where() 的分工一致。
function mockFilterFindings(q: URLSearchParams): (typeof mockFindings)[number][] {
let list = mockFindings.filter((finding) => mockFindingMatchesQuery(finding, q.get("q")));
const severity = q.get("severity");
const status = q.get("status");
const vulnclass = q.get("vulnclass");
const taskID = q.get("task_id");
if (severity) list = list.filter((finding) => finding.severity === severity);
if (status) list = list.filter((finding) => finding.status === status);
if (vulnclass) list = list.filter((finding) => finding.vulnclass === vulnclass);
if (taskID === "__unassigned__") list = list.filter((finding) => !finding.task_id);
else if (taskID) list = list.filter((finding) => finding.task_id === taskID);
return list;
}
function mockFindingMatchesQuery(finding: (typeof mockFindings)[number], query: string | null): boolean {
const needle = query?.trim().toLowerCase();
if (!needle) return true;
@@ -310,6 +326,266 @@ function mockFindingMatchesQuery(finding: (typeof mockFindings)[number], query:
);
}
// ── 「按资产」视图 ────────────────────────────────────────────────────────────
// 后端把树建在 db/finding_assets.go 里(只收有发现的资产 + 逐层补齐祖先,计数沿
// 祖先链去重累加)。这里用同一套父子优先级在内存里重放一遍,让 demo 模式的层级、
// 计数、子树筛选与真后端保持一致。
const UNASSIGNED_ASSET = "__none__";
function mockAssetLabel(asset: (typeof mockAssets)[number]): string {
// 没有 URL 的服务补端口,否则标签会和宿主 IP/域名那行完全一样(与后端一致)。
if (asset.type === "service" && !asset.url) {
const host = asset.domain || asset.ip;
if (host && asset.port) return `${host}:${asset.port}`;
}
return asset.url || asset.domain || asset.ip || asset.app_name || `#${asset.id}`;
}
// mockAssetHost 与后端 hostPortOf 一致:优先 domain,其次 URL 里的 host,最后 ip。
function mockAssetHost(asset: (typeof mockAssets)[number]): { host: string; port: number } {
let host = asset.domain ?? "";
let port = asset.port ?? 0;
if (!host && asset.url) {
try {
const url = new URL(asset.url);
host = url.hostname.replace(/^\[|\]$/g, "");
if (!port) port = Number(url.port) || (url.protocol === "https:" ? 443 : 80);
} catch {
// 非法 URL 就退回 ip。
}
}
if (!host) host = asset.ip ?? "";
return { host, port };
}
interface MockAssetTreeNode {
key: string;
parent?: string;
kind: string;
label: string;
asset_id?: number;
company_id?: number;
self: number;
total: number;
critical: number;
high: number;
medium: number;
low: number;
last_found_at: string;
}
// mockBuildAssetTree 从一批(已按其它条件筛过的)发现构建资产树。
function mockBuildAssetTree(list: (typeof mockFindings)[number][]): MockAssetTreeNode[] {
const hit = new Set<number>();
for (const finding of list) {
for (const ref of finding.assets ?? []) hit.add(Number(ref.id));
}
// 收集命中的资产 + 逐层补齐祖先(宿主 service / 子域名 / IP / 根域名)。
const picked = new Map<number, (typeof mockAssets)[number]>();
for (const asset of mockAssets) if (hit.has(asset.id)) picked.set(asset.id, asset);
for (let round = 0; round < 4; round++) {
const before = picked.size;
for (const asset of [...picked.values()]) {
const { host } = mockAssetHost(asset);
const wanted = mockAssets.filter((candidate) => {
if (picked.has(candidate.id)) return false;
if (asset.type === "endpoint" && candidate.type === "service") {
return mockAssetHost(candidate).host === host;
}
if (asset.type === "service" || asset.type === "endpoint") {
return (
(candidate.type === "subdomain" && candidate.domain === host) ||
(candidate.type === "ip" && candidate.ip === host)
);
}
if (asset.type === "subdomain") {
return candidate.type === "root_domain" && candidate.domain === asset.root_domain;
}
return false;
});
for (const candidate of wanted) picked.set(candidate.id, candidate);
}
if (picked.size === before) break;
}
const nodes = new Map<string, MockAssetTreeNode>();
const parentOf = new Map<string, string>();
const key = (id: number) => `a:${id}`;
for (const asset of picked.values()) {
nodes.set(key(asset.id), {
key: key(asset.id),
kind: asset.type,
label: mockAssetLabel(asset),
asset_id: asset.id,
company_id: asset.company_id,
self: 0,
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
last_found_at: "",
});
}
// 父子关系:与 db/finding_assets.go 的 firstOf 优先级顺序一致。
const find = (predicate: (a: (typeof mockAssets)[number]) => boolean) => {
const asset = [...picked.values()].find(predicate);
return asset ? key(asset.id) : "";
};
for (const asset of picked.values()) {
const { host, port } = mockAssetHost(asset);
let parent = "";
if (asset.type === "subdomain") {
parent = find((a) => a.type === "root_domain" && a.domain === asset.root_domain);
} else if (asset.type === "service") {
parent =
find((a) => a.type === "subdomain" && (a.domain === asset.domain || a.domain === host)) ||
find((a) => a.type === "ip" && (a.ip === asset.ip || a.ip === host)) ||
find((a) => a.type === "root_domain" && a.domain === asset.root_domain);
} else if (asset.type === "endpoint") {
parent =
find((a) => {
if (a.type !== "service") return false;
const svc = mockAssetHost(a);
return svc.host === host && svc.port === port;
}) ||
find((a) => a.type === "service" && mockAssetHost(a).host === host) ||
find((a) => a.type === "subdomain" && (a.domain === host || a.domain === asset.domain)) ||
find((a) => a.type === "ip" && (a.ip === host || a.ip === asset.ip)) ||
find((a) => a.type === "root_domain" && a.domain === asset.root_domain);
}
if (parent && parent !== key(asset.id)) {
parentOf.set(key(asset.id), parent);
const node = nodes.get(key(asset.id));
if (node) node.parent = parent;
}
}
// 企业层:只给确实有归属的顶层资产(根域名 / IP / 应用)补,没有归属就自己是顶层。
for (const node of [...nodes.values()]) {
if (node.parent || !node.company_id) continue;
if (!["root_domain", "ip", "app"].includes(node.kind)) continue;
const company = mockCompanies.find((candidate) => candidate.id === node.company_id);
if (!company) continue;
const companyKey = `c:${company.id}`;
if (!nodes.has(companyKey)) {
nodes.set(companyKey, {
key: companyKey,
kind: "company",
label: company.name,
company_id: company.id,
self: 0,
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
last_found_at: "",
});
}
node.parent = companyKey;
parentOf.set(node.key, companyKey);
}
// 计数:一条发现沿它每个资产的祖先链向上,收集去重后的 key 集合再逐个 +1。
const unassigned: MockAssetTreeNode = {
key: UNASSIGNED_ASSET,
kind: "none",
label: "未关联资产",
self: 0,
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
last_found_at: "",
};
const bump = (node: MockAssetTreeNode, finding: (typeof mockFindings)[number]) => {
node.total++;
if (finding.severity === "critical") node.critical++;
else if (finding.severity === "high") node.high++;
else if (finding.severity === "medium") node.medium++;
else if (finding.severity === "low") node.low++;
if (finding.ts > node.last_found_at) node.last_found_at = finding.ts;
};
for (const finding of list) {
const direct = (finding.assets ?? []).map((ref) => nodes.get(`a:${ref.id}`)).filter((n) => n !== undefined);
if (direct.length === 0) {
bump(unassigned, finding);
unassigned.self++;
continue;
}
const touched = new Set<string>();
for (const node of direct) {
node.self++;
for (let cur: string | undefined = node.key; cur; cur = parentOf.get(cur)) touched.add(cur);
}
for (const k of touched) {
const node = nodes.get(k);
if (node) bump(node, finding);
}
}
const out = [...nodes.values()].filter((node) => node.total > 0);
if (unassigned.total > 0) out.push(unassigned);
out.sort((left, right) => {
if ((left.kind === "none") !== (right.kind === "none")) return left.kind === "none" ? 1 : -1;
return right.total - left.total || left.label.localeCompare(right.label);
});
return out;
}
// mockAssetScopeIds 把资产树节点 key 展开成整棵子树的资产 id 集合,与后端
// applyAssetScope 同义。miss=true 表示该节点在当前筛选下不存在 → 结果恒空。
function mockAssetScopeIds(
scope: string,
list: (typeof mockFindings)[number][],
): { ids: Set<string>; none: boolean; miss: boolean } {
if (scope === UNASSIGNED_ASSET) return { ids: new Set(), none: true, miss: false };
const nodes = mockBuildAssetTree(list);
if (!nodes.some((node) => node.key === scope)) return { ids: new Set(), none: false, miss: true };
const children = new Map<string, MockAssetTreeNode[]>();
for (const node of nodes) {
if (!node.parent) continue;
children.set(node.parent, [...(children.get(node.parent) ?? []), node]);
}
const ids = new Set<string>();
const queue = [scope];
const seen = new Set(queue);
while (queue.length > 0) {
const cur = queue.shift() as string;
const node = nodes.find((candidate) => candidate.key === cur);
if (node?.asset_id) ids.add(String(node.asset_id));
for (const child of children.get(cur) ?? []) {
if (seen.has(child.key)) continue;
seen.add(child.key);
queue.push(child.key);
}
}
return { ids, none: false, miss: ids.size === 0 };
}
// mockApplyAssetScope 按 asset_scope 收窄一批发现。「未关联」同时收 assets 为空
// 与指向已删资产的发现,和树上那个桶的口径一致。
function mockApplyAssetScope(
list: (typeof mockFindings)[number][],
scope: string | null,
): (typeof mockFindings)[number][] {
if (!scope) return list;
const { ids, none, miss } = mockAssetScopeIds(scope, list);
if (none) {
return list.filter((finding) => {
const refs = finding.assets ?? [];
return refs.length === 0 || refs.every((ref) => !mockAssets.some((a) => String(a.id) === ref.id));
});
}
if (miss) return [];
return list.filter((finding) => (finding.assets ?? []).some((ref) => ids.has(ref.id)));
}
function mockTaskCategorySnapshot(): TaskCategory[] {
return mockTaskCategories.map((category) => ({
...category,
@@ -1383,19 +1659,13 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
tasks,
};
}
if (path === "/exploration/findings/asset-tree") {
const list = mockApplyAssetScope(mockFilterFindings(q), null);
return { nodes: mockBuildAssetTree(list), finding_total: list.length, truncated: false };
}
if (path === "/exploration/findings/groups") {
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 } as const;
let list = mockFindings.slice();
const filterSeverity = q.get("severity");
const filterStatus = q.get("status");
const filterVulnclass = q.get("vulnclass");
const filterTask = q.get("task_id");
list = list.filter((finding) => mockFindingMatchesQuery(finding, q.get("q")));
if (filterSeverity) list = list.filter((finding) => finding.severity === filterSeverity);
if (filterStatus) list = list.filter((finding) => finding.status === filterStatus);
if (filterVulnclass) list = list.filter((finding) => finding.vulnclass === filterVulnclass);
if (filterTask === "__unassigned__") list = list.filter((finding) => !finding.task_id);
else if (filterTask) list = list.filter((finding) => finding.task_id === filterTask);
const list = mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope"));
const grouped = new Map<string, typeof list>();
for (const finding of list) {
@@ -1496,17 +1766,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
// 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。
if (!q.has("page") && !q.has("limit")) return mockFindings.map(withFid);
const sev = { critical: 4, high: 3, medium: 2, low: 1 } as const;
let list = mockFindings.slice();
const fSev = q.get("severity");
const fStatus = q.get("status");
const fVuln = q.get("vulnclass");
const fTask = q.get("task_id");
list = list.filter((finding) => mockFindingMatchesQuery(finding, q.get("q")));
if (fSev) list = list.filter((f) => f.severity === fSev);
if (fStatus) list = list.filter((f) => f.status === fStatus);
if (fVuln) list = list.filter((f) => f.vulnclass === fVuln);
if (fTask === "__unassigned__") list = list.filter((f) => !f.task_id);
else if (fTask) list = list.filter((f) => f.task_id === fTask);
const list = mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope"));
list.sort((a, b) =>
q.get("sort") === "severity"
? sev[b.severity] - sev[a.severity] || +new Date(b.ts) - +new Date(a.ts)