mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
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:
1 parent
23cfb9ed4e
commit
48e14e8cf2
4 files changed
+352
-33
No files matched your search
+282
-22
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user