From 44924ec30b49a554a755b57f996d85f1232f238c Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:48:58 +0800 Subject: [PATCH] feat(web): add segmented asset topology paths --- agent/promptcatalog.go | 23 ++- agent/toolcatalog.go | 4 +- db/db.go | 2 + .../tasks/detail/_tabs/coverage-graph-tab.tsx | 160 ++++++++++++++++-- 4 files changed, 166 insertions(+), 23 deletions(-) diff --git a/agent/promptcatalog.go b/agent/promptcatalog.go index 67f1fa7..783dc20 100644 --- a/agent/promptcatalog.go +++ b/agent/promptcatalog.go @@ -24,6 +24,16 @@ const autoDefaultTmpl = `你是 **Auto**,这个渗透测试平台的「操作 - 用人话简洁汇报你做了什么、结果如何;只根据工具真实返回作答,不臆造。 - 只在授权范围内操作。` +// assetMapperDefaultTmpl is the read-only assistant behind the topology canvas. +const assetMapperDefaultTmpl = `你是授权渗透测试平台的「资产梳理」Agent。你的职责是根据资产库中的真实数据,整理公网、边界服务和内网业务资产之间的关系,并解释用户指定的入站路径。 + +工作规则: +- 先使用 list_assets 和 list_companies 获取事实,再给出结论;没有证据的关系标记为“待验证”,不要臆造。 +- 识别域名、子域名、IP、服务、应用和端点之间的关联,说明数据来源和最近更新时间。 +- 用户询问“从哪里进入内网”时,按“公网入口 → 边界服务 → 目标资产”的顺序回答;当前资产库没有网络流量或防火墙证据时,明确说明这是资产关系推导,不把它描述成已验证的网络通路。 +- 用简洁中文输出,优先给出资产清单、关系链、可信度和下一步验证建议。 +- 只读分析,不主动修改资产库,不执行探测。` + // pentestDefaultTmpl is the built-in "渗透测试" (solo pentest) agent's prompt. Unlike // the orchestration roles (goals/planner/worker), it runs standalone via the chat page // and is its own planner + executor + auditor. Default tools: list_assets / insert_assets @@ -112,11 +122,12 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏 // source while the same string stays as the in-code render fallback. func BuiltinPromptSeeds() map[string]string { return map[string]string{ - "goals": goalsDefaultTmpl, - "planner": plannerDefaultTmpl, - "mainagent": mainAgentDefaultTmpl, - "worker": workerDefaultTmpl, - "auto": autoDefaultTmpl, - "pentest": pentestDefaultTmpl, + "goals": goalsDefaultTmpl, + "planner": plannerDefaultTmpl, + "mainagent": mainAgentDefaultTmpl, + "worker": workerDefaultTmpl, + "auto": autoDefaultTmpl, + "asset_mapper": assetMapperDefaultTmpl, + "pentest": pentestDefaultTmpl, } } diff --git a/agent/toolcatalog.go b/agent/toolcatalog.go index 77b7cb2..32b732e 100644 --- a/agent/toolcatalog.go +++ b/agent/toolcatalog.go @@ -43,6 +43,8 @@ func builtinToolsByAgent() map[string][]actool.CoreTool { // auto 默认绑漏洞上报 + 资产管理工具,其他域工具可在 UI 按需勾选。 // 新库由此 seed 写入;老库由 seedAutoDefaultBindings 迁移。 "auto": {ts.addFinding(), ts.insertAssets(), ts.addCompanyScope(), ts.listAssets(), ts.listCompanies()}, + // asset_mapper 只读资产库,供资产梳理和拓扑解释使用。 + "asset_mapper": {ts.listAssets(), ts.listCompanies()}, // pentest(独立渗透 agent)默认绑:查资产 / 插资产 / 报漏洞 / 查漏洞 / 查企业。 // 新库由此 seed 写入;老库由 seedPentestDefaultBindings 迁移。 "pentest": {ts.listAssets(), ts.insertAssets(), ts.addFinding(), ts.listFindings(), ts.listCompanies()}, @@ -62,7 +64,7 @@ var defaultUnbound = map[string]bool{"goal_met": true} // 多个 agent 都有)合成一条,Agents 取并集;defaultUnbound 里的工具则强制绑定为空。 func BuiltinToolSeeds() []ToolSeed { byAgent := builtinToolsByAgent() - order := []string{"mainagent", "goals", "planner", "worker", "auto", "pentest"} + order := []string{"mainagent", "goals", "planner", "worker", "auto", "asset_mapper", "pentest"} type acc struct { tool actool.CoreTool diff --git a/db/db.go b/db/db.go index 90f9fcb..e468045 100644 --- a/db/db.go +++ b/db/db.go @@ -209,6 +209,8 @@ var builtinAgents = []builtinAgent{ }, false, nil}, // Auto:内置「平台操作」agent。不参与渗透编排循环,经对话页驱动,用工具操作平台。 {"auto", "Auto", "assistant", "平台操作助手:用工具管理任务(建/看/暂停/给提示)与资产,并可创建/修改 skill、自定义工具、MCP。", nil, false, nil}, + // Asset mapper: read-only assistant used by the topology canvas and asset views. + {"asset_mapper", "资产梳理", "assistant", "资产梳理助手:汇总公网、边界与内网资产关系,基于真实资产数据解释入站路径,不凭空推断连接。", nil, false, nil}, // 渗透测试:内置「独立渗透」agent。经对话页驱动,一人从侦察到收尾走完整条渗透链,自己规划自己执行自己验证。默认开启交互式 shell。 {"pentest", "渗透测试", "assistant", "独立渗透 agent:一人从侦察→找攻击面→深入利用→验证→收尾走完整条链,自己规划、自己执行、自己对抗式验证。", nil, true, intp(0)}, } diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx index d16d2e5..ec35766 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx @@ -34,6 +34,34 @@ const FOLD_STEP = 20; type Kind = CoverageGraphNode["kind"]; +type Zone = "internet" | "boundary" | "internal"; + +const zoneMeta: Record = { + internet: { + label: "互联网区", + description: "公网域名 / 外部入口", + className: "bg-sky-50/70 dark:bg-sky-950/20", + }, + boundary: { + label: "边界区", + description: "服务 / 网关 / 暴露面", + className: "bg-amber-50/70 dark:bg-amber-950/15", + }, + internal: { + label: "内网区", + description: "业务资产 / 应用 / 端点", + className: "bg-emerald-50/65 dark:bg-emerald-950/15", + }, +}; + +// 当前后端资产关系以域名、服务和业务资产为主,先用类型推导可视化分区。 +// 后续接入 VPC/网段/防火墙数据后,可以直接由 API 返回 zone 覆盖这里的推导结果。 +function zoneOf(node: CoverageGraphNode): Zone { + if (node.kind === "root_domain" || node.kind === "subdomain") return "internet"; + if (node.kind === "service") return "boundary"; + return "internal"; +} + type KindMeta = { label: string; icon: LucideIcon; iconBg: string; hex: string; size: number }; const kindMeta: Record = { @@ -185,8 +213,11 @@ type G6NodeDatum = { inScope: boolean; lbl: string; size: number; + active: boolean; }; +type G6EdgeDatum = { source: string; target: string; active: boolean }; + function trunc(s: string, n = 26): string { return s.length > n ? `${s.slice(0, n - 1)}…` : s; } @@ -211,7 +242,7 @@ function graphLabel(n: CoverageGraphNode): string { return n.label; } -function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] { +function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set): G6NodeDatum[] { return renderNodes.map((rn) => { if (rn.fold) { return { @@ -222,6 +253,7 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] { inScope: false, lbl: `还有 ${rn.hidden.length} 个${kindMeta[rn.kind].label}`, size: 24, + active: activeKeys.has(rn.key), }; } return { @@ -232,6 +264,7 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] { inScope: rn.node.in_scope, lbl: trunc(graphLabel(rn.node) || kindMeta[rn.kind].label), size: kindMeta[rn.kind].size, + active: activeKeys.has(rn.key), }; }); } @@ -248,6 +281,7 @@ function nodeFill(d: G6NodeDatum): string { return "#94a3b8"; } function nodeStroke(d: G6NodeDatum): string { + if (d.active) return "#0f766e"; if (d.fold || !d.inScope) return "#94a3b8"; if (d.tested) return "#0f766e"; return "#64748b"; @@ -464,16 +498,19 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera const [expanded, setExpanded] = React.useState>(new Map()); const [selectedAsset, setSelectedAsset] = React.useState(null); const [selectedFoldId, setSelectedFoldId] = React.useState(null); + const [activePath, setActivePath] = React.useState([]); const [loading, setLoading] = React.useState(true); + const [lastSyncedAt, setLastSyncedAt] = React.useState(null); const containerRef = React.useRef(null); const graphRef = React.useRef(null); // click 处理需要最新的 key→RenderNode 映射(G6 事件回调闭包外读 ref)。 const renderMapRef = React.useRef>(new Map()); - const gDataRef = React.useRef<{ nodes: G6NodeDatum[]; edges: { source: string; target: string }[] }>({ + const gDataRef = React.useRef<{ nodes: G6NodeDatum[]; edges: G6EdgeDatum[] }>({ nodes: [], edges: [], }); + const graphDataRef = React.useRef(null); const fetchGraph = React.useCallback(() => { setLoading(true); @@ -484,6 +521,8 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera // tested 状态——不显示测试进度、不做已测高亮。 const nodes = coverageEnabled ? (g.nodes ?? []) : (g.nodes ?? []).map((n) => ({ ...n, tested: false })); setData({ nodes, edges: g.edges ?? [] }); + graphDataRef.current = { nodes, edges: g.edges ?? [] }; + setLastSyncedAt(new Date()); }) .catch(() => { /* 保留上一次数据 */ @@ -506,14 +545,18 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera // 结构签名:只在可见节点/边集合变化时重建图 + 重跑布局,避免无谓抖动。 const sig = React.useMemo( () => - `${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}`, - [renderNodes, renderEdges], + `${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}|${activePath.join(",")}`, + [activePath, renderNodes, renderEdges], ); // 维护 gDataRef + renderMapRef(供事件与图数据应用读取)。 gDataRef.current = { - nodes: toG6Nodes(renderNodes), - edges: renderEdges.map((e) => ({ source: e.src, target: e.dst })), + nodes: toG6Nodes(renderNodes, new Set(activePath)), + edges: renderEdges.map((e) => ({ + source: e.src, + target: e.dst, + active: activePath.includes(e.src) && activePath.includes(e.dst), + })), }; const rmap = new Map(); for (const rn of renderNodes) rmap.set(rn.key, rn); @@ -542,7 +585,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera container: containerRef.current, autoResize: true, autoFit: "view", - background: "#f0f2f7", + background: "transparent", node: { style: { size: (d: unknown) => nd(d).size, @@ -561,10 +604,18 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera labelBackgroundFill: "rgba(255,255,255,0.75)", labelBackgroundRadius: 3, labelPadding: [1, 3], + opacity: (d: unknown) => + gDataRef.current.nodes.some((node) => node.active) && !nd(d).active ? 0.28 : 1, }, }, edge: { - style: { stroke: "#cbd5e1", lineWidth: 1, endArrow: false }, + style: { + stroke: (d: unknown) => ((d as G6EdgeDatum).active ? "#0f766e" : "#cbd5e1"), + lineWidth: (d: unknown) => ((d as G6EdgeDatum).active ? 2.5 : 1), + opacity: (d: unknown) => + gDataRef.current.edges.some((edge) => edge.active) && !(d as G6EdgeDatum).active ? 0.18 : 1, + endArrow: true, + }, }, layout: { type: "d3-force", @@ -590,8 +641,22 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera if (!id) return; const rn = renderMapRef.current.get(id); if (!rn) return; - if (rn.fold) setSelectedFoldId(rn.key); - else setSelectedAsset(rn.node); + if (rn.fold) { + setSelectedFoldId(rn.key); + return; + } + setSelectedAsset(rn.node); + const parents = new Map(); + for (const edge of graphDataRef.current?.edges ?? []) { + if (!parents.has(edge.src)) parents.set(edge.src, edge.dst); + } + const lineage: string[] = []; + let cursor: string | undefined = rn.node.key; + while (cursor && !lineage.includes(cursor)) { + lineage.push(cursor); + cursor = parents.get(cursor); + } + setActivePath(lineage); }); graphRef.current = graph; applyData(); @@ -636,14 +701,38 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera const inScope = data?.nodes.filter((n) => n.in_scope).length ?? 0; return ( -
-
+
+
+ {(Object.keys(zoneMeta) as Zone[]).map((zone, index) => ( +
+
+
{zoneMeta[zone].label}
+
{zoneMeta[zone].description}
+
+
+ ))} +
+
{/* 图例 + 统计 + 刷新(叠加层) */} -
+
+
+ + + +
+
资产梳理 Agent
+
+ {loading ? "正在更新资产关系…" : lastSyncedAt ? `最近更新 ${lastSyncedAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}` : "等待首次梳理"} +
+
+
{total > 0 ? ( - + 范围内 {inScope} {coverageEnabled && ( <> @@ -656,8 +745,9 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera ) : ( {loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"} )} -
@@ -689,11 +779,49 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera 范围外
+
+ {(Object.keys(zoneMeta) as Zone[]).map((zone) => ( + + + {zoneMeta[zone].label} + + ))} +

- 力导向布局,可拖拽节点、滚轮缩放;灰色「⋯」是折叠节点,点开可展开更多。 + 点击节点查看详情并高亮关系链;拖拽节点、滚轮缩放。灰色「⋯」是折叠节点,可展开更多资产。

+ {selectedAsset && activePath.length > 0 && data && ( +
+
+
+
关系路径
+
从当前资产向上追溯关联关系
+
+ +
+
+ {[...activePath].reverse().map((key, index, path) => { + const node = data.nodes.find((item) => item.key === key); + if (!node) return null; + return ( + +
+ + {zoneMeta[zoneOf(node)].label} + {node.label} +
+ {index < path.length - 1 &&
↓
} +
+ ); + })} +
+
+ )} + !o && setSelectedAsset(null)} />