diff --git a/internal/attackchain/builder.go b/internal/attackchain/builder.go index 1e0a1392..aba0dde0 100644 --- a/internal/attackchain/builder.go +++ b/internal/attackchain/builder.go @@ -305,6 +305,12 @@ func (b *Builder) buildChainGenerationPrompt(contextData *ContextData) (string, **目标:让不懂渗透测试的同学可以通过这个攻击链路学习到知识,而不是无数个节点看花眼。** **即便某些工具执行或漏洞挖掘没有成功,只要它们提供了关键线索、错误提示或下一步思路,也要被保留下来。** +**关键要求:** +1. **节点标签必须简洁明了**:每个节点标签控制在15-25个汉字以内,使用简洁的动宾结构(如"扫描端口"、"发现SQL注入"、"验证漏洞"),避免冗长描述 +2. **严格控制节点数量**:优先保留关键步骤,避免生成过多细碎节点。理想情况下,单个目标的攻击链应控制在8-15个节点以内 +3. **确保DAG结构**:生成的图必须是有向无环图(DAG),不允许出现循环。边的方向必须符合时间顺序和逻辑关系(从早期步骤指向后期步骤) +4. **层次清晰**:攻击链应该呈现清晰的层次结构:目标 → 信息收集 → 漏洞发现 → 漏洞利用 → 后续行动 + ## 任务要求 1. **节点类型(简化,只保留3种)**: @@ -314,7 +320,9 @@ func (b *Builder) buildChainGenerationPrompt(contextData *ContextData) (string, - 不同目标的action节点之间**不应该**建立关联关系 - **action(行动)**:**工具执行 + AI分析结果 = 一个action节点** - 将每个工具执行和AI对该工具结果的分析合并为一个action节点 - - 节点标签应该清晰描述"做了什么"、"得到了什么结果或线索"(例如:"使用Nmap扫描端口,发现22、80、443端口开放" 或 "尝试SQLmap,虽然失败但提示存在WAF拦截") + - **节点标签必须简洁**:控制在15-25个汉字,使用动宾结构,突出关键信息 + - 好的示例:"扫描端口发现22/80/443"、"SQL注入验证成功"、"WAF拦截暴露厂商" + - 避免的示例:"使用Nmap工具对目标192.168.1.1进行了全面的端口扫描,发现了22、80、443等多个端口开放"(过于冗长) - 默认关注成功的执行;但如果执行失败却提供了有价值的线索(错误信息、资产指纹、下一步建议等),也要保留,记为"带线索的失败"行动 - **重要:action节点必须关联到正确的target节点(通过工具执行参数判断目标)** - **vulnerability(漏洞)**:从工具执行结果和AI分析中提取的**真实漏洞**(不是所有发现都是漏洞)。若验证失败但能明确表明某个漏洞利用方向不可行,可作为行动节点的线索描述,而不是漏洞节点。 @@ -327,13 +335,16 @@ func (b *Builder) buildChainGenerationPrompt(contextData *ContextData) (string, - 用户特别关注的失败尝试 - **保留策略**:只要行动节点能给后续测试提供启发,就保留;否则忽略 -3. **建立清晰的关联关系**: +3. **建立清晰的关联关系(确保DAG结构)**: - target → action:目标指向属于它的所有行动(通过工具执行参数判断目标) - action → action:行动之间的逻辑顺序(按时间顺序,但只连接有逻辑关系的) - **重要:只连接属于同一目标的action节点,不同目标的action节点之间不应该连接** + - **必须确保无环**:只能从早期步骤指向后期步骤,不能形成循环(如A→B→C→A) + - 优先连接直接相关的步骤,避免过度连接导致图过于复杂 - action → vulnerability:行动发现的漏洞 - vulnerability → vulnerability:漏洞间的因果关系(如SQL注入 → 信息泄露) - **重要:只连接属于同一目标的漏洞,不同目标的漏洞之间不应该连接** + - **必须确保无环**:漏洞间的因果关系也必须是单向的,不能形成循环 4. **节点属性**: - 每个节点需要:id, type, label, risk_score, metadata @@ -488,10 +499,11 @@ func (b *Builder) buildChainGenerationPrompt(contextData *ContextData) (string, ## 重要要求 -1. **节点合并**: +1. **节点合并和标签优化**: - 每个工具执行和对应的AI分析必须合并为一个action节点 - - action节点的label要清晰描述"做了什么"、"结果/线索是什么" - - 例如:"使用Nmap扫描192.168.1.1,发现22、80、443端口开放" 或 "执行Sqlmap被WAF拦截,提示403并暴露防护厂商" + - **action节点的label必须简洁**:控制在15-25个汉字,使用动宾结构 + - 好的示例:"扫描端口发现22/80/443"、"验证SQL注入成功"、"WAF拦截暴露厂商" + - 避免冗长描述,关键信息放在metadata中详细说明 - 若为失败但有线索的行动,请在metadata.status中标记为"failed_insight",并在findings/hints里写清线索价值 2. **过滤无效节点**: @@ -504,13 +516,16 @@ func (b *Builder) buildChainGenerationPrompt(contextData *ContextData) (string, - 不要创建discovery、decision等节点 - 让攻击链清晰、有教育意义 -4. **关联关系**: +4. **关联关系(确保DAG结构)**: - target → action:目标指向属于它的所有行动(通过工具执行参数判断目标) - action → action:按时间顺序连接,但只连接有逻辑关系的 - **重要:只连接属于同一目标的action节点,不同目标的action节点之间不应该连接** + - **必须确保无环**:只能从早期步骤指向后期步骤,不能形成循环 + - 优先连接直接相关的步骤,避免过度连接 - action → vulnerability:行动发现的漏洞 - vulnerability → vulnerability:漏洞间的因果关系 - **重要:只连接属于同一目标的漏洞,不同目标的漏洞之间不应该连接** + - **必须确保无环**:漏洞间的因果关系也必须是单向的 5. **多目标处理(重要!)**: - 如果对话中测试了多个不同的目标(如先测试A网页,后测试B网页),必须: @@ -519,9 +534,16 @@ func (b *Builder) buildChainGenerationPrompt(contextData *ContextData) (string, - 不同目标的节点之间**不应该**建立任何关联关系 - 这样会形成多个独立的攻击链分支,每个分支对应一个测试目标 -6. **节点数量控制**: - - 如果节点太多(>20个),优先保留最重要的节点 +6. **节点数量控制和合并策略**: + - **严格控制节点数量**:单个目标的攻击链理想情况下应控制在8-15个节点以内 + - 如果节点太多(>20个),优先保留最重要的节点,合并或删除次要节点 - 合并相似的action节点(如同一工具的连续调用,如果结果相似) + - 对于同一类型的多个发现,考虑合并为一个节点(如"发现多个开放端口"而不是为每个端口创建节点) + +7. **DAG结构验证**: + - 生成后必须检查:确保图中不存在循环(即不存在路径A→B→...→A) + - 边的方向必须符合时间顺序:早期步骤指向后期步骤 + - 如果发现循环,必须断开形成循环的边,保留最重要的连接 只返回JSON,不要包含其他解释文字。`) @@ -1440,9 +1462,12 @@ func (b *Builder) parseChainJSON(chainJSON string, executions []*mcp.ToolExecuti } } + // 验证和优化DAG结构 + optimizedEdges := b.optimizeDAGStructure(nodes, filteredEdges) + return &Chain{ Nodes: nodes, - Edges: filteredEdges, + Edges: optimizedEdges, }, nil } @@ -1600,6 +1625,142 @@ func (b *Builder) shouldFilterNode(n struct { return false } +// optimizeDAGStructure 优化DAG结构,检测并修复循环 +func (b *Builder) optimizeDAGStructure(nodes []Node, edges []Edge) []Edge { + // 构建邻接表 + adjList := make(map[string][]string) // nodeID -> []targetNodeIDs + nodeSet := make(map[string]bool) + for _, node := range nodes { + nodeSet[node.ID] = true + } + + // 构建邻接表 + for _, edge := range edges { + if nodeSet[edge.Source] && nodeSet[edge.Target] { + adjList[edge.Source] = append(adjList[edge.Source], edge.Target) + } + } + + // 检测循环 + cycles := b.detectCycles(adjList, nodeSet) + if len(cycles) == 0 { + // 没有循环,直接返回 + return edges + } + + b.logger.Warn("检测到攻击链中存在循环,正在修复", + zap.Int("cycleCount", len(cycles))) + + // 构建边映射,方便删除 + edgeMap := make(map[string]Edge) // "source:target" -> Edge + for _, edge := range edges { + key := edge.Source + ":" + edge.Target + edgeMap[key] = edge + } + + // 删除形成循环的边(保留权重较低的边,通常权重低的边重要性较低) + removedEdges := make(map[string]bool) + for _, cycle := range cycles { + // 找到循环中权重最低的边并删除 + minWeight := 999 + var edgeToRemove string + for i := 0; i < len(cycle); i++ { + source := cycle[i] + target := cycle[(i+1)%len(cycle)] + key := source + ":" + target + if edge, exists := edgeMap[key]; exists { + if edge.Weight < minWeight { + minWeight = edge.Weight + edgeToRemove = key + } + } + } + if edgeToRemove != "" { + removedEdges[edgeToRemove] = true + b.logger.Info("删除形成循环的边", + zap.String("edge", edgeToRemove), + zap.Int("weight", minWeight)) + } + } + + // 重新构建边列表,排除已删除的边 + optimizedEdges := make([]Edge, 0, len(edges)) + for _, edge := range edges { + key := edge.Source + ":" + edge.Target + if !removedEdges[key] { + optimizedEdges = append(optimizedEdges, edge) + } + } + + // 再次验证(递归处理,最多3次) + if len(optimizedEdges) < len(edges) { + // 重新构建邻接表 + newAdjList := make(map[string][]string) + for _, edge := range optimizedEdges { + newAdjList[edge.Source] = append(newAdjList[edge.Source], edge.Target) + } + newCycles := b.detectCycles(newAdjList, nodeSet) + if len(newCycles) > 0 && len(removedEdges) < 10 { // 防止无限循环 + // 递归优化 + return b.optimizeDAGStructure(nodes, optimizedEdges) + } + } + + return optimizedEdges +} + +// detectCycles 检测图中的循环(使用DFS) +func (b *Builder) detectCycles(adjList map[string][]string, nodeSet map[string]bool) [][]string { + visited := make(map[string]bool) + recStack := make(map[string]bool) + cycles := make([][]string, 0) + + var dfs func(node string, path []string) bool + dfs = func(node string, path []string) bool { + if !nodeSet[node] { + return false + } + + visited[node] = true + recStack[node] = true + currentPath := append(path, node) + + for _, neighbor := range adjList[node] { + if !visited[neighbor] { + if dfs(neighbor, currentPath) { + return true + } + } else if recStack[neighbor] { + // 找到循环:从neighbor到node的路径 + cycleStart := -1 + for i, n := range currentPath { + if n == neighbor { + cycleStart = i + break + } + } + if cycleStart >= 0 { + cycle := append(currentPath[cycleStart:], neighbor) + cycles = append(cycles, cycle) + } + return true + } + } + + recStack[node] = false + return false + } + + // 对所有节点进行DFS + for node := range nodeSet { + if !visited[node] { + dfs(node, []string{}) + } + } + + return cycles +} + func hasInsightfulFailure(metadata map[string]interface{}) bool { if metadata == nil { return false diff --git a/web/static/js/chat.js b/web/static/js/chat.js index def5bc28..356a455a 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1592,7 +1592,30 @@ function renderAttackChain(chainData) { // 计算图的复杂度(用于动态调整布局和样式) const nodeCount = chainData.nodes.length; const edgeCount = chainData.edges.length; - const isComplexGraph = nodeCount > 20 || edgeCount > 30; + const isComplexGraph = nodeCount > 15 || edgeCount > 25; + + // 优化节点标签:智能截断和换行 + chainData.nodes.forEach(node => { + if (node.label) { + // 智能截断:优先在标点符号、空格处截断 + const maxLength = isComplexGraph ? 18 : 22; + if (node.label.length > maxLength) { + let truncated = node.label.substring(0, maxLength); + // 尝试在最后一个标点符号或空格处截断 + const lastPunct = Math.max( + truncated.lastIndexOf(','), + truncated.lastIndexOf('。'), + truncated.lastIndexOf('、'), + truncated.lastIndexOf(' '), + truncated.lastIndexOf('/') + ); + if (lastPunct > maxLength * 0.6) { // 如果标点符号位置合理 + truncated = truncated.substring(0, lastPunct + 1); + } + node.label = truncated + '...'; + } + } + }); // 准备Cytoscape数据 const elements = []; @@ -1667,8 +1690,8 @@ function renderAttackChain(chainData) { style: { 'label': 'data(label)', // 统一节点大小,减少布局混乱(根据复杂度调整) - 'width': nodeCount > 20 ? 60 : 'mapData(riskScore, 0, 100, 45, 75)', - 'height': nodeCount > 20 ? 60 : 'mapData(riskScore, 0, 100, 45, 75)', + 'width': isComplexGraph ? 70 : 'mapData(riskScore, 0, 100, 50, 80)', + 'height': isComplexGraph ? 70 : 'mapData(riskScore, 0, 100, 50, 80)', 'shape': function(ele) { const type = ele.data('type'); if (type === 'vulnerability') return 'diamond'; @@ -1685,12 +1708,13 @@ function renderAttackChain(chainData) { }, // 使用预计算的颜色数据 'color': 'data(textColor)', - 'font-size': nodeCount > 20 ? '11px' : '12px', // 复杂图使用更小字体 + 'font-size': isComplexGraph ? '11px' : '13px', // 复杂图使用更小字体 'font-weight': 'bold', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'wrap', - 'text-max-width': nodeCount > 20 ? '80px' : '100px', // 复杂图限制文本宽度 + 'text-max-width': isComplexGraph ? '90px' : '110px', // 复杂图限制文本宽度 + 'text-overflow-wrap': 'anywhere', // 允许在任何位置换行 'border-width': 2, 'border-color': 'data(borderColor)', 'overlay-padding': '4px', @@ -1746,25 +1770,29 @@ function renderAttackChain(chainData) { let layoutOptions = { name: 'breadthfirst', directed: true, - spacingFactor: isComplexGraph ? 2.5 : 2.0, - padding: 30 + spacingFactor: isComplexGraph ? 3.0 : 2.5, + padding: 40 }; if (typeof cytoscape !== 'undefined' && typeof cytoscapeDagre !== 'undefined') { try { cytoscape.use(cytoscapeDagre); layoutName = 'dagre'; - // 根据图的复杂度调整布局参数 + // 根据图的复杂度调整布局参数,优化可读性 layoutOptions = { name: 'dagre', rankDir: 'TB', // 从上到下 - spacingFactor: isComplexGraph ? 2.5 : 2.0, // 增加整体间距 - nodeSep: isComplexGraph ? 80 : 60, // 增加节点间距 - edgeSep: isComplexGraph ? 40 : 30, // 增加边间距 - rankSep: isComplexGraph ? 120 : 100, // 增加层级间距 + spacingFactor: isComplexGraph ? 3.0 : 2.5, // 增加整体间距 + nodeSep: isComplexGraph ? 100 : 80, // 增加节点间距,提高可读性 + edgeSep: isComplexGraph ? 50 : 40, // 增加边间距 + rankSep: isComplexGraph ? 140 : 120, // 增加层级间距,让层次更清晰 nodeDimensionsIncludeLabels: true, // 考虑标签大小 animate: false, - padding: 40 // 增加边距 + padding: 50, // 增加边距 + // 优化边的路由,减少交叉 + edgeRouting: 'polyline', + // 对齐方式:居中对齐,让图更整齐 + align: 'UL' // 上左对齐 }; } catch (e) { console.warn('dagre布局注册失败,使用默认布局:', e); @@ -1777,7 +1805,13 @@ function renderAttackChain(chainData) { attackChainCytoscape.layout(layoutOptions).run(); // 布局完成后,调整视图以适应所有节点 - attackChainCytoscape.fit(undefined, 50); // 50px padding + // 使用更大的padding,让图更易读 + attackChainCytoscape.fit(undefined, 60); // 60px padding + + // 如果图太复杂,稍微缩小视图以便看到全貌 + if (isComplexGraph && nodeCount > 20) { + attackChainCytoscape.zoom(0.85); + } // 添加点击事件 attackChainCytoscape.on('tap', 'node', function(evt) { @@ -1795,6 +1829,175 @@ function renderAttackChain(chainData) { const node = evt.target; node.style('opacity', 1); }); + + // 保存原始数据用于过滤 + window.attackChainOriginalData = chainData; +} + +// 过滤攻击链节点(按搜索关键词) +function filterAttackChainNodes(searchText) { + if (!attackChainCytoscape || !window.attackChainOriginalData) { + return; + } + + const searchLower = searchText.toLowerCase().trim(); + if (searchLower === '') { + // 重置所有节点可见性 + attackChainCytoscape.nodes().style('display', 'element'); + attackChainCytoscape.edges().style('display', 'element'); + // 恢复默认边框 + attackChainCytoscape.nodes().style('border-width', 2); + return; + } + + // 过滤节点 + attackChainCytoscape.nodes().forEach(node => { + const label = (node.data('label') || '').toLowerCase(); + const type = (node.data('type') || '').toLowerCase(); + const matches = label.includes(searchLower) || type.includes(searchLower); + + if (matches) { + node.style('display', 'element'); + // 高亮匹配的节点 + node.style('border-width', 4); + node.style('border-color', '#0066ff'); + } else { + node.style('display', 'none'); + } + }); + + // 隐藏没有可见源节点或目标节点的边 + attackChainCytoscape.edges().forEach(edge => { + const sourceVisible = edge.source().style('display') !== 'none'; + const targetVisible = edge.target().style('display') !== 'none'; + if (sourceVisible && targetVisible) { + edge.style('display', 'element'); + } else { + edge.style('display', 'none'); + } + }); + + // 重新调整视图 + attackChainCytoscape.fit(undefined, 60); +} + +// 按类型过滤攻击链节点 +function filterAttackChainByType(type) { + if (!attackChainCytoscape || !window.attackChainOriginalData) { + return; + } + + if (type === 'all') { + attackChainCytoscape.nodes().style('display', 'element'); + attackChainCytoscape.edges().style('display', 'element'); + attackChainCytoscape.nodes().style('border-width', 2); + attackChainCytoscape.fit(undefined, 60); + return; + } + + // 过滤节点 + attackChainCytoscape.nodes().forEach(node => { + const nodeType = node.data('type') || ''; + if (nodeType === type) { + node.style('display', 'element'); + } else { + node.style('display', 'none'); + } + }); + + // 隐藏没有可见源节点或目标节点的边 + attackChainCytoscape.edges().forEach(edge => { + const sourceVisible = edge.source().style('display') !== 'none'; + const targetVisible = edge.target().style('display') !== 'none'; + if (sourceVisible && targetVisible) { + edge.style('display', 'element'); + } else { + edge.style('display', 'none'); + } + }); + + // 重新调整视图 + attackChainCytoscape.fit(undefined, 60); +} + +// 按风险等级过滤攻击链节点 +function filterAttackChainByRisk(riskLevel) { + if (!attackChainCytoscape || !window.attackChainOriginalData) { + return; + } + + if (riskLevel === 'all') { + attackChainCytoscape.nodes().style('display', 'element'); + attackChainCytoscape.edges().style('display', 'element'); + attackChainCytoscape.nodes().style('border-width', 2); + attackChainCytoscape.fit(undefined, 60); + return; + } + + // 定义风险范围 + const riskRanges = { + 'high': [80, 100], + 'medium-high': [60, 79], + 'medium': [40, 59], + 'low': [0, 39] + }; + + const [minRisk, maxRisk] = riskRanges[riskLevel] || [0, 100]; + + // 过滤节点 + attackChainCytoscape.nodes().forEach(node => { + const riskScore = node.data('riskScore') || 0; + if (riskScore >= minRisk && riskScore <= maxRisk) { + node.style('display', 'element'); + } else { + node.style('display', 'none'); + } + }); + + // 隐藏没有可见源节点或目标节点的边 + attackChainCytoscape.edges().forEach(edge => { + const sourceVisible = edge.source().style('display') !== 'none'; + const targetVisible = edge.target().style('display') !== 'none'; + if (sourceVisible && targetVisible) { + edge.style('display', 'element'); + } else { + edge.style('display', 'none'); + } + }); + + // 重新调整视图 + attackChainCytoscape.fit(undefined, 60); +} + +// 重置攻击链筛选 +function resetAttackChainFilters() { + // 重置搜索框 + const searchInput = document.getElementById('attack-chain-search'); + if (searchInput) { + searchInput.value = ''; + } + + // 重置类型筛选 + const typeFilter = document.getElementById('attack-chain-type-filter'); + if (typeFilter) { + typeFilter.value = 'all'; + } + + // 重置风险筛选 + const riskFilter = document.getElementById('attack-chain-risk-filter'); + if (riskFilter) { + riskFilter.value = 'all'; + } + + // 重置所有节点可见性 + if (attackChainCytoscape) { + attackChainCytoscape.nodes().forEach(node => { + node.style('display', 'element'); + node.style('border-width', 2); // 恢复默认边框 + }); + attackChainCytoscape.edges().style('display', 'element'); + attackChainCytoscape.fit(undefined, 60); + } } // 显示节点详情 diff --git a/web/templates/index.html b/web/templates/index.html index bcc12301..523f92ce 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -445,6 +445,32 @@