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
@@ -76,8 +76,15 @@ func (a *assetRow) coverageNode() CoverageGraphNode {
|
||||
}
|
||||
}
|
||||
|
||||
// label 复用覆盖图的标签规则(URL > domain > ip > app_name > root_domain)。
|
||||
// label 复用覆盖图的标签规则(URL > domain > ip > app_name > root_domain),但没有
|
||||
// URL 的服务(SMB、非 HTTP 端口等)要补上端口:否则它的标签会和宿主 IP/域名那行
|
||||
// 一模一样,树上父子两行看起来完全重复。
|
||||
func (a *assetRow) label() string {
|
||||
if a.kind == "service" && a.url == "" {
|
||||
if host, port := a.hostPort(); host != "" && port > 0 {
|
||||
return host + ":" + strconv.Itoa(port)
|
||||
}
|
||||
}
|
||||
n := a.coverageNode()
|
||||
n.Key = assetKey(a.id)
|
||||
return coverageNodeLabel(&n)
|
||||
|
||||
@@ -66,6 +66,15 @@ function parseAssetURL(raw: string): URL | null {
|
||||
}
|
||||
}
|
||||
|
||||
// hostOf 取一个节点代表的宿主:URL 取 hostname,「host:port」取 host,其余就是
|
||||
// 标签本身(根域名 / 子域名 / IP)。
|
||||
function hostOf(label: string): string {
|
||||
const url = parseAssetURL(label);
|
||||
if (url) return stripBrackets(url.hostname);
|
||||
const hostPort = label.match(/^(.+):(\d+)$/);
|
||||
return stripBrackets(hostPort ? hostPort[1] : label);
|
||||
}
|
||||
|
||||
// shortLabel 去掉与父节点重复的前缀。service / endpoint 的 label 是完整 URL,而
|
||||
// 宿主域名/IP 上一行已经写过了 —— 深层节点本来就窄,再把 host 重复一遍,真正有
|
||||
// 信息量的端口和路径就全被截断掉了。完整值仍在 title 与面包屑里。
|
||||
@@ -78,16 +87,17 @@ function shortLabel(node: FindingAssetNode, parent?: FindingAssetNode): string {
|
||||
}
|
||||
if (node.kind !== "service" && node.kind !== "endpoint") return node.label;
|
||||
|
||||
// 接口挂在服务下:直接砍掉服务那段前缀,剩下的就是路径。
|
||||
// 父标签正好是自己的前缀(接口挂在同 URL 的服务下、服务挂在同 IP 下):直接砍掉。
|
||||
if (node.label.startsWith(parent.label)) {
|
||||
return node.label.slice(parent.label.length) || node.label;
|
||||
}
|
||||
|
||||
// 否则只有父节点确实就是这个 URL 的宿主时才简写,不然会丢掉辨识信息
|
||||
// (比如服务因为缺子域名资产行而直接挂在根域名下,那就得显示完整 URL)。
|
||||
if (hostOf(node.label) !== hostOf(parent.label)) return node.label;
|
||||
|
||||
const url = parseAssetURL(node.label);
|
||||
if (!url) return node.label;
|
||||
// 只有父节点确实就是这个 URL 的宿主时才简写,否则会丢掉辨识信息。
|
||||
if (stripBrackets(url.hostname) !== parent.label) return node.label;
|
||||
|
||||
if (node.kind === "endpoint") return `${url.pathname}${url.search}` || "/";
|
||||
const scheme = url.protocol.replace(":", "");
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
DailyTokenBucket,
|
||||
Edge,
|
||||
Finding,
|
||||
FindingAsset,
|
||||
InterceptApprovalRow,
|
||||
InterceptPending,
|
||||
InterceptRule,
|
||||
@@ -565,10 +566,24 @@ export const assetCounts: Record<string, number> = assets.reduce<Record<string,
|
||||
return m;
|
||||
}, {});
|
||||
|
||||
// assetRef 把资产 id 变成 finding 上挂的资产引用(label 与后端 coverageNodeLabel
|
||||
// 的取值顺序一致:URL > 域名 > IP > 应用名)。「按资产」视图的树就是靠这些引用
|
||||
// 把发现挂到资产上的。
|
||||
function assetRef(id: number): FindingAsset {
|
||||
const asset = assets.find((candidate) => candidate.id === id);
|
||||
if (!asset) throw new Error(`mock assetRef: unknown asset ${id}`);
|
||||
return {
|
||||
id: String(id),
|
||||
type: asset.type,
|
||||
label: asset.url || asset.domain || asset.ip || asset.app_name || String(id),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Findings ─────────────────────────────────────────────────────────────────
|
||||
export const findings: Finding[] = [
|
||||
{
|
||||
id: "f-1",
|
||||
assets: [assetRef(18)],
|
||||
vulnclass: "SQL Injection",
|
||||
name: "官网搜索接口报错型 SQL 注入",
|
||||
severity: "high",
|
||||
@@ -584,6 +599,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-2",
|
||||
assets: [assetRef(15)],
|
||||
vulnclass: "IDOR",
|
||||
severity: "high",
|
||||
status: "pending",
|
||||
@@ -596,6 +612,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-3",
|
||||
assets: [assetRef(3)],
|
||||
vulnclass: "Weak JWT",
|
||||
severity: "high",
|
||||
status: "pending",
|
||||
@@ -607,6 +624,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-4",
|
||||
assets: [assetRef(18)],
|
||||
vulnclass: "Reflected XSS",
|
||||
severity: "medium",
|
||||
status: "pending",
|
||||
@@ -618,6 +636,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-5",
|
||||
assets: [assetRef(2)],
|
||||
vulnclass: "Exposed .git",
|
||||
severity: "medium",
|
||||
status: "pending",
|
||||
@@ -629,6 +648,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-6",
|
||||
assets: [assetRef(17)],
|
||||
vulnclass: "Default Credentials",
|
||||
severity: "high",
|
||||
status: "pending",
|
||||
@@ -641,6 +661,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-7",
|
||||
assets: [assetRef(17)],
|
||||
vulnclass: "Open Redirect",
|
||||
severity: "low",
|
||||
status: "pending",
|
||||
@@ -652,6 +673,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-8",
|
||||
assets: [assetRef(17)],
|
||||
vulnclass: "Missing Rate Limit",
|
||||
severity: "medium",
|
||||
status: "pending",
|
||||
@@ -663,6 +685,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-9",
|
||||
assets: [assetRef(16)],
|
||||
vulnclass: "Verbose Error",
|
||||
severity: "low",
|
||||
status: "pending",
|
||||
@@ -674,6 +697,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-10",
|
||||
assets: [assetRef(4)],
|
||||
vulnclass: "Outdated Component",
|
||||
severity: "medium",
|
||||
status: "pending",
|
||||
@@ -698,6 +722,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-12",
|
||||
assets: [assetRef(4)],
|
||||
vulnclass: "Deserialization RCE",
|
||||
name: "shop 商城 Fastjson 反序列化远程命令执行",
|
||||
severity: "critical",
|
||||
@@ -714,6 +739,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-13",
|
||||
assets: [assetRef(19)],
|
||||
vulnclass: "Privilege Escalation",
|
||||
severity: "high",
|
||||
status: "pending",
|
||||
@@ -727,6 +753,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-14",
|
||||
assets: [assetRef(19), assetRef(21)],
|
||||
vulnclass: "Network Segmentation",
|
||||
severity: "medium",
|
||||
status: "pending",
|
||||
@@ -739,6 +766,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-15",
|
||||
assets: [assetRef(26)],
|
||||
vulnclass: "Unauthenticated RCE",
|
||||
severity: "high",
|
||||
status: "pending",
|
||||
@@ -752,6 +780,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-16",
|
||||
assets: [assetRef(21)],
|
||||
vulnclass: "Kerberoasting",
|
||||
severity: "high",
|
||||
status: "pending",
|
||||
@@ -764,6 +793,7 @@ export const findings: Finding[] = [
|
||||
},
|
||||
{
|
||||
id: "f-17",
|
||||
assets: [assetRef(20)],
|
||||
vulnclass: "Domain Compromise",
|
||||
name: "内网域控 DC01 完全控制(Domain Admin)",
|
||||
severity: "critical",
|
||||
@@ -1291,7 +1321,8 @@ export const activity: Activity[] = [
|
||||
kind: "tool_result",
|
||||
tool: "bash",
|
||||
tool_use_id: "p0-probe",
|
||||
summary: "acme.com → 302 → www;Server: nginx/1.24.0\nadmin.acme.com → 200,X-Powered-By 缺省,body 命中 element-ui/vue 特征",
|
||||
summary:
|
||||
"acme.com → 302 → www;Server: nginx/1.24.0\nadmin.acme.com → 200,X-Powered-By 缺省,body 命中 element-ui/vue 特征",
|
||||
detail: "admin 是独立后台登录页;确认五入口有效,可把初始意图描述写精准。",
|
||||
},
|
||||
{
|
||||
@@ -1426,7 +1457,8 @@ export const activity: Activity[] = [
|
||||
tool: "prove_goal",
|
||||
tool_use_id: "p2-pg",
|
||||
summary: "prove_goal(g3, evidence=fa6)",
|
||||
detail: "reason:i7 RCE 拿到 DMZ 立足点(fa4)、fa5 提权 root、fa6 证明第二网卡直连内网并 ping 通 DC01——『突破 DMZ 建立内网立足点』验收达成。",
|
||||
detail:
|
||||
"reason:i7 RCE 拿到 DMZ 立足点(fa4)、fa5 提权 root、fa6 证明第二网卡直连内网并 ping 通 DC01——『突破 DMZ 建立内网立足点』验收达成。",
|
||||
},
|
||||
{
|
||||
seq: 1205,
|
||||
@@ -1503,7 +1535,8 @@ export const activity: Activity[] = [
|
||||
tool: "prove_goal",
|
||||
tool_use_id: "p3-pg",
|
||||
summary: "prove_goal(g4, evidence=F-09)",
|
||||
detail: "reason:Jenkins 泄露 svc_deploy(Domain Admin) → psexec 控制 DC01 → secretsdump 全域哈希,F-09 已确认,『拿下内部靶标域控 DC01』达成。",
|
||||
detail:
|
||||
"reason:Jenkins 泄露 svc_deploy(Domain Admin) → psexec 控制 DC01 → secretsdump 全域哈希,F-09 已确认,『拿下内部靶标域控 DC01』达成。",
|
||||
},
|
||||
{
|
||||
seq: 1305,
|
||||
@@ -1524,7 +1557,13 @@ export const activity: Activity[] = [
|
||||
},
|
||||
|
||||
// ── 第 4 轮 · 后台达成,收敛 g2(2026-07-26 03:55)──
|
||||
{ seq: 1400, worker: "planner", ts: T("2026-07-26T03:55:00Z"), kind: "round", summary: "第 4 轮 · 后台达成,收敛 g2" },
|
||||
{
|
||||
seq: 1400,
|
||||
worker: "planner",
|
||||
ts: T("2026-07-26T03:55:00Z"),
|
||||
kind: "round",
|
||||
summary: "第 4 轮 · 后台达成,收敛 g2",
|
||||
},
|
||||
{
|
||||
seq: 1401,
|
||||
worker: "planner",
|
||||
@@ -1541,7 +1580,8 @@ export const activity: Activity[] = [
|
||||
tool: "prove_goal",
|
||||
tool_use_id: "p4-pg",
|
||||
summary: "prove_goal(g1, evidence=fa1)",
|
||||
detail: "reason:i2 用外网泄露的硬编码口令 Acme@2021 成功登入 admin 后台(Element-UI),获取后台管理权限,g1 达成。",
|
||||
detail:
|
||||
"reason:i2 用外网泄露的硬编码口令 Acme@2021 成功登入 admin 后台(Element-UI),获取后台管理权限,g1 达成。",
|
||||
},
|
||||
{
|
||||
seq: 1403,
|
||||
@@ -4038,7 +4078,9 @@ export function llmRecordDetail(id: number, records = llmRecords) {
|
||||
model: item.model,
|
||||
max_tokens: 8192,
|
||||
stream: true,
|
||||
system: [{ type: "text", text: "你是一个授权渗透测试系统的「执行者」…(省略)", cache_control: { type: "ephemeral" } }],
|
||||
system: [
|
||||
{ type: "text", text: "你是一个授权渗透测试系统的「执行者」…(省略)", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
messages: [{ role: "user", content: "开始执行 system 提示里的这条意图:只做它、只产生事实、做完即停。" }],
|
||||
tools: [
|
||||
{
|
||||
|
||||
+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