diff --git a/db/finding_assets.go b/db/finding_assets.go index f1cd90d..51bc994 100644 --- a/db/finding_assets.go +++ b/db/finding_assets.go @@ -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) diff --git a/web/src/app/(main)/function/findings/_components/asset-tree.tsx b/web/src/app/(main)/function/findings/_components/asset-tree.tsx index bfc9246..1bf2d6b 100644 --- a/web/src/app/(main)/function/findings/_components/asset-tree.tsx +++ b/web/src/app/(main)/function/findings/_components/asset-tree.tsx @@ -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"); diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index b3f64c1..60ef5d0 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -15,6 +15,7 @@ import type { DailyTokenBucket, Edge, Finding, + FindingAsset, InterceptApprovalRow, InterceptPending, InterceptRule, @@ -565,10 +566,24 @@ export const assetCounts: Record = assets.reduce 域名 > 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: [ { diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index efa66f5..d60cd63 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -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(); + for (const finding of list) { + for (const ref of finding.assets ?? []) hit.add(Number(ref.id)); + } + + // 收集命中的资产 + 逐层补齐祖先(宿主 service / 子域名 / IP / 根域名)。 + const picked = new Map(); + 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(); + const parentOf = new Map(); + 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(); + 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; 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(); + for (const node of nodes) { + if (!node.parent) continue; + children.set(node.parent, [...(children.get(node.parent) ?? []), node]); + } + const ids = new Set(); + 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(); 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)