mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-15 21:08:01 +02:00
Add files via upload
This commit is contained in:
+185
-199
@@ -330,161 +330,162 @@ func (b *Builder) formatReActInputFromJSON(reactInputJSON string) string {
|
||||
|
||||
// buildSimplePrompt 构建简化的prompt
|
||||
func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
|
||||
return fmt.Sprintf(`你是一个专业的安全测试分析师。请根据以下对话和工具执行记录,生成清晰、有教育意义的攻击链图。
|
||||
return fmt.Sprintf(`你是专业的安全测试分析师和攻击链构建专家。你的任务是根据对话记录和工具执行结果,构建一个逻辑清晰、有教育意义的攻击链图,完整展现渗透测试的思维过程和执行路径。
|
||||
|
||||
## 核心原则
|
||||
## 核心目标
|
||||
|
||||
**目标:让不懂渗透测试的同学可以通过这个攻击链路学习到知识,而不是无数个节点看花眼。**
|
||||
**⚠️ 特别重要:失败路径和错误经验同样具有重要价值!**
|
||||
构建一个能够讲述完整攻击故事的攻击链(通常8-15个节点,必要时可适当增加),让学习者能够:
|
||||
1. 理解渗透测试的完整流程和思维逻辑(从目标识别到漏洞发现的每一步)
|
||||
2. 学习如何从失败中获取线索并调整策略
|
||||
3. 掌握工具使用的实际效果和局限性
|
||||
4. 理解漏洞发现和利用的因果关系
|
||||
|
||||
**失败路径的价值:**
|
||||
- **指引作用**:失败的尝试往往揭示了系统的防御机制、配置信息或攻击面边界
|
||||
- **学习价值**:展示"为什么这条路走不通"、"遇到了什么障碍"、"如何绕过或解决"
|
||||
- **完整还原**:真实的渗透测试过程包含大量失败尝试,只展示成功路径会误导学习者
|
||||
- **关键线索**:即使工具执行失败,错误信息、超时、拒绝连接等都可能包含重要信息(如WAF类型、防护策略、端口状态等)
|
||||
**关键原则**:完整性优先。必须包含所有有意义的工具执行和关键步骤,不要为了控制节点数量而遗漏重要信息。
|
||||
|
||||
**必须保留的失败路径类型:**
|
||||
1. **工具执行失败但提供了线索**:如"工具未安装"、"权限不足"、"连接被拒绝"、"超时"等,这些信息有助于理解环境限制
|
||||
2. **漏洞验证失败但指明了方向**:如"SQL注入尝试失败,但暴露了数据库类型"、"XSS尝试被WAF拦截,但暴露了WAF规则"
|
||||
3. **扫描失败但揭示了防护**:如"端口扫描被防火墙拦截"、"目录枚举被限制"、"暴力破解被锁定"
|
||||
4. **配置错误或环境问题**:如"工具配置错误"、"目标不可达"、"证书验证失败"等,这些可能揭示系统配置问题
|
||||
5. **AI分析中的失败尝试**:如果AI在对话中明确提到"尝试了X但失败了,因为Y",这也应该被记录
|
||||
## 构建流程(按此顺序思考)
|
||||
|
||||
**关键要求:**
|
||||
1. **节点标签必须简洁明了**:每个节点标签控制在15-25个汉字以内,使用简洁的动宾结构
|
||||
- action节点要描述"做了什么"和"发现了什么"(如"扫描端口发现22/80/443"、"验证SQL注入成功"、"WAF拦截暴露厂商"、"SQLMap扫描失败(工具未安装)")
|
||||
- 失败路径的标签应明确标注失败原因(如"尝试SQL注入(被WAF拦截)"、"端口扫描(连接超时)")
|
||||
- 避免冗长描述,关键信息放在metadata中详细说明
|
||||
2. **严格控制节点数量**:优先保留关键步骤,避免生成过多细碎节点。理想情况下,单个目标的攻击链应控制在8-15个节点以内
|
||||
- 如果节点太多(>20个),优先保留最重要的节点(包括重要的失败路径),合并或删除次要节点
|
||||
- 合并相似的action节点(如同一工具的连续调用,如果结果相似)
|
||||
- 对于同一类型的多个发现,考虑合并为一个节点(如"发现多个开放端口"而不是为每个端口创建节点)
|
||||
- **但不要因为节点数量限制而删除有价值的失败路径**
|
||||
3. **确保DAG结构**:生成的图必须是有向无环图(DAG),不允许出现循环。边的方向必须符合时间顺序和逻辑关系(从早期步骤指向后期步骤)
|
||||
- 生成后必须检查:确保图中不存在循环(即不存在路径A→B→...→A)
|
||||
- 如果发现循环,必须断开形成循环的边,保留最重要的连接
|
||||
- **失败路径也应该正确连接到后续的成功路径**(如"尝试A失败" → "改用B方法成功")
|
||||
4. **层次清晰**:攻击链应该呈现清晰的层次结构:目标 → 信息收集(包括失败的尝试) → 漏洞发现 → 漏洞利用 → 后续行动
|
||||
### 第一步:理解上下文
|
||||
仔细分析ReAct输入中的工具调用序列和大模型输出,识别:
|
||||
- 测试目标(IP、域名、URL等)
|
||||
- 实际执行的工具和参数
|
||||
- 工具返回的关键信息(成功结果、错误信息、超时等)
|
||||
- AI的分析和决策过程
|
||||
|
||||
## ⚠️ 重要原则 - 严禁杜撰
|
||||
### 第二步:提取关键节点
|
||||
从工具执行记录中提取有意义的节点,**确保不遗漏任何关键步骤**:
|
||||
- **target节点**:每个独立的测试目标创建一个target节点
|
||||
- **action节点**:每个有意义的工具执行创建一个action节点(包括提供线索的失败、成功的信息收集、漏洞验证等)
|
||||
- **vulnerability节点**:每个真实确认的漏洞创建一个vulnerability节点
|
||||
- **完整性检查**:对照ReAct输入中的工具调用序列,确保每个有意义的工具执行都被包含在攻击链中
|
||||
|
||||
**严格禁止编造或推测任何内容!** 你必须:
|
||||
1. **只使用实际发生的信息**:仅基于ReAct输入中实际执行的工具调用和实际返回的结果
|
||||
2. **不要推测**:如果没有实际执行工具或发现漏洞,不要编造
|
||||
3. **不要假设**:不能仅根据URL、目标名称等推断漏洞类型
|
||||
4. **基于事实**:每个节点和边都必须有实际依据,来自工具执行结果或模型的实际输出
|
||||
### 第三步:构建逻辑关系
|
||||
按照时间顺序和因果关系连接节点:
|
||||
- 识别哪些action是基于前面action的结果而执行的
|
||||
- 识别哪些vulnerability是由哪些action发现的
|
||||
- 识别失败节点如何为后续成功提供线索
|
||||
|
||||
如果ReAct输入中没有实际的工具执行记录,或者模型输出中明确表示任务未完成/被取消,必须返回空的攻击链(空的nodes和edges数组)。
|
||||
### 第四步:优化和精简
|
||||
- **完整性检查**:确保所有有意义的工具执行都被包含,不要遗漏关键步骤
|
||||
- **合并规则**:只合并真正相似或重复的action节点(如多次相同工具的相似调用)
|
||||
- **删除规则**:只删除完全无价值的失败节点(完全无输出、纯系统错误、重复的相同失败)
|
||||
- **重要提醒**:宁可保留更多节点,也不要遗漏关键步骤。攻击链必须完整展现渗透测试过程
|
||||
- 确保攻击链逻辑连贯,能够讲述完整故事
|
||||
|
||||
## 节点类型详解
|
||||
|
||||
### target(目标节点)
|
||||
- **用途**:标识测试目标
|
||||
- **创建规则**:每个独立目标(不同IP/域名)创建一个target节点
|
||||
- **多目标处理**:不同目标的节点不相互连接,各自形成独立的子图
|
||||
- **metadata.target**:精确记录目标标识(IP地址、域名、URL等)
|
||||
|
||||
### action(行动节点)
|
||||
- **用途**:记录工具执行和AI分析结果
|
||||
- **标签规则**:
|
||||
* 15-25个汉字,动宾结构
|
||||
* 成功节点:描述执行结果(如"扫描端口发现80/443/8080"、"目录扫描发现/admin路径")
|
||||
* 失败节点:描述失败原因(如"尝试SQL注入(被WAF拦截)"、"端口扫描超时(目标不可达)")
|
||||
- **ai_analysis要求**:
|
||||
* 成功节点:总结工具执行的关键发现,说明这些发现的意义
|
||||
* 失败节点:必须说明失败原因、获得的线索、这些线索如何指引后续行动
|
||||
* 不超过150字,要具体、有信息量
|
||||
- **findings要求**:
|
||||
* 提取工具返回结果中的关键信息点
|
||||
* 每个finding应该是独立的、有价值的信息片段
|
||||
* 成功节点:列出关键发现(如["80端口开放", "443端口开放", "HTTP服务为Apache 2.4"])
|
||||
* 失败节点:列出失败线索(如["WAF拦截", "返回403", "检测到Cloudflare"])
|
||||
- **status标记**:
|
||||
* 成功节点:不设置或设为"success"
|
||||
* 提供线索的失败节点:必须设为"failed_insight"
|
||||
- **risk_score**:始终为0(action节点不评估风险)
|
||||
|
||||
### vulnerability(漏洞节点)
|
||||
- **用途**:记录真实确认的安全漏洞
|
||||
- **创建规则**:
|
||||
* 必须是真实确认的漏洞,不是所有发现都是漏洞
|
||||
* 需要明确的漏洞证据(如SQL注入返回数据库错误、XSS成功执行等)
|
||||
- **risk_score规则**:
|
||||
* critical(90-100):可导致系统完全沦陷(RCE、SQL注入导致数据泄露等)
|
||||
* high(80-89):可导致敏感信息泄露或权限提升
|
||||
* medium(60-79):存在安全风险但影响有限
|
||||
* low(40-59):轻微安全问题
|
||||
- **metadata要求**:
|
||||
* vulnerability_type:漏洞类型(SQL注入、XSS、RCE等)
|
||||
* description:详细描述漏洞位置、原理、影响
|
||||
* severity:critical/high/medium/low
|
||||
* location:精确的漏洞位置(URL、参数、文件路径等)
|
||||
|
||||
## 节点过滤和合并规则
|
||||
|
||||
### 必须保留的失败节点
|
||||
以下失败情况必须创建节点,因为它们提供了有价值的线索:
|
||||
- 工具返回明确的错误信息(权限错误、连接拒绝、认证失败等)
|
||||
- 超时或连接失败(可能表明防火墙、网络隔离等)
|
||||
- WAF/防火墙拦截(返回403、406等,表明存在防护机制)
|
||||
- 工具未安装或配置错误(但执行了调用)
|
||||
- 目标不可达(DNS解析失败、网络不通等)
|
||||
|
||||
### 应该删除的失败节点
|
||||
以下情况不应创建节点:
|
||||
- 完全无输出的工具调用
|
||||
- 纯系统错误(与目标无关,如本地环境问题)
|
||||
- 重复的相同失败(多次相同错误只保留第一次)
|
||||
|
||||
### 节点合并规则
|
||||
以下情况应合并节点:
|
||||
- 同一工具的多次相似调用(如多次nmap扫描不同端口范围,合并为一个"端口扫描"节点)
|
||||
- 同一目标的多个相似探测(如多个目录扫描工具,合并为一个"目录扫描"节点)
|
||||
|
||||
### 节点数量控制
|
||||
- **完整性优先**:必须包含所有有意义的工具执行和关键步骤,不要为了控制数量而删除重要节点
|
||||
- **建议范围**:单目标通常8-15个节点,但如果实际执行步骤较多,可以适当增加(最多20个节点)
|
||||
- **优先保留**:关键成功步骤、提供线索的失败、发现的漏洞、重要的信息收集步骤
|
||||
- **可以合并**:同一工具的多次相似调用(如多次nmap扫描不同端口范围,合并为一个"端口扫描"节点)
|
||||
- **可以删除**:完全无输出的工具调用、纯系统错误、重复的相同失败(多次相同错误只保留第一次)
|
||||
- **重要原则**:宁可节点稍多,也不要遗漏关键步骤。攻击链必须能够完整展现渗透测试的完整过程
|
||||
|
||||
## 边的类型和权重
|
||||
|
||||
### 边的类型
|
||||
- **leads_to**:表示"导致"或"引导到",用于action→action、target→action
|
||||
* 例如:端口扫描 → 目录扫描(因为发现了80端口,所以进行目录扫描)
|
||||
- **discovers**:表示"发现",用于action→vulnerability
|
||||
* 例如:SQL注入测试 → SQL注入漏洞
|
||||
- **enables**:表示"使能"或"促成",用于vulnerability→vulnerability、action→action(当后续行动依赖前面结果时)
|
||||
* 例如:信息泄露漏洞 → 权限提升漏洞(通过信息泄露获得的信息促成了权限提升)
|
||||
|
||||
### 边的权重
|
||||
- **权重1-2**:弱关联(如初步探测到进一步探测)
|
||||
- **权重3-4**:中等关联(如发现端口到服务识别)
|
||||
- **权重5-7**:强关联(如发现漏洞、关键信息泄露)
|
||||
- **权重8-10**:极强关联(如漏洞利用成功、权限提升)
|
||||
|
||||
### DAG结构要求
|
||||
- 所有边的source节点id必须小于target节点id(确保无环)
|
||||
- 节点id从"node_1"开始递增
|
||||
- 确保无孤立节点(每个节点至少有一条边连接)
|
||||
|
||||
## 攻击链逻辑连贯性要求
|
||||
|
||||
构建的攻击链应该能够回答以下问题:
|
||||
1. **起点**:测试从哪里开始?(target节点)
|
||||
2. **探索过程**:如何逐步收集信息?(action节点序列)
|
||||
3. **失败与调整**:遇到障碍时如何调整策略?(failed_insight节点)
|
||||
4. **关键发现**:发现了哪些重要信息?(action的findings)
|
||||
5. **漏洞确认**:如何确认漏洞存在?(action→vulnerability)
|
||||
6. **攻击路径**:完整的攻击路径是什么?(从target到vulnerability的路径)
|
||||
|
||||
## 最后一轮ReAct输入
|
||||
|
||||
## 最后一轮ReAct的输入(历史对话上下文)
|
||||
%s
|
||||
|
||||
## 大模型最后的输出
|
||||
## 大模型输出
|
||||
|
||||
%s
|
||||
|
||||
## 任务要求
|
||||
|
||||
### 1. 节点类型(简化,只保留3种)
|
||||
|
||||
**target(目标)**:从用户输入中提取测试目标(IP、域名、URL等)
|
||||
- **重要:如果对话中测试了多个不同的目标(如先测试A网页,后测试B网页),必须:**
|
||||
- 为每个不同的目标创建独立的target节点
|
||||
- 每个target节点只关联属于它的action和vulnerability节点
|
||||
- 不同目标的节点之间**不应该**建立任何关联关系
|
||||
- 这样会形成多个独立的攻击链分支,每个分支对应一个测试目标
|
||||
|
||||
**action(行动)**:**工具执行 + AI分析结果 = 一个action节点**
|
||||
- 将每个工具执行和AI对该工具结果的分析合并为一个action节点
|
||||
- **节点标签必须简洁**:控制在15-25个汉字,使用动宾结构,描述"做了什么"和"发现了什么"
|
||||
- 成功示例:"扫描端口发现22/80/443"、"验证SQL注入成功"、"WAF拦截暴露厂商"
|
||||
- **失败示例(必须保留)**:"尝试SQL注入(被WAF拦截)"、"端口扫描(连接超时)"、"SQLMap扫描(工具未安装)"、"目录枚举(权限不足)"
|
||||
- 避免冗长描述,关键信息放在metadata中详细说明
|
||||
- **⚠️ 失败路径处理规则**:
|
||||
- **必须创建节点**:如果工具执行失败但提供了任何线索、错误信息或指引,必须创建action节点
|
||||
- **标记失败状态**:在metadata.status中标记为"failed_insight",并在findings中详细说明失败原因和获得的线索
|
||||
- **说明线索价值**:在ai_analysis中明确说明"为什么这个失败很重要"、"提供了什么信息"、"如何指引了后续行动"
|
||||
- **连接后续节点**:失败路径应该连接到后续的成功路径,展示"失败 → 调整策略 → 成功"的完整过程
|
||||
- **重要:action节点必须关联到正确的target节点(通过工具执行参数判断目标)**
|
||||
- **risk_score**:**action节点没有风险,risk_score必须设置为0**(只有vulnerability节点才有风险等级)
|
||||
|
||||
**vulnerability(漏洞)**:从工具执行结果和AI分析中提取的**真实漏洞**(不是所有发现都是漏洞)
|
||||
- 若验证失败但能明确表明某个漏洞利用方向不可行,可作为行动节点的线索描述,而不是漏洞节点
|
||||
- **risk_score**:反映实际发现的漏洞的风险等级(高危80-100,中危60-80,低危40-60)
|
||||
|
||||
### 2. 简化结构
|
||||
|
||||
- 只创建target、action、vulnerability三种节点
|
||||
- 不要创建discovery、decision等节点
|
||||
- 让攻击链清晰、有教育意义
|
||||
|
||||
### 3. 过滤规则(重要!)
|
||||
|
||||
**必须忽略的失败执行(可以删除):**
|
||||
- 完全没有输出、没有任何错误信息的失败
|
||||
- 纯粹的系统错误(如"内存不足"、"磁盘满"等),且与测试目标无关
|
||||
- 重复的、完全相同的失败尝试(只保留第一次)
|
||||
|
||||
**必须保留的失败执行(必须创建节点):**
|
||||
- **工具执行失败但提供了线索**:如错误信息、超时、拒绝连接、权限错误等
|
||||
- **漏洞验证失败但指明了方向**:如"SQL注入尝试失败,但暴露了数据库类型"、"XSS被WAF拦截,但暴露了WAF规则"
|
||||
- **扫描失败但揭示了防护**:如"端口扫描被防火墙拦截"、"目录枚举被限制"、"暴力破解被锁定"
|
||||
- **配置或环境问题**:如"工具未安装"、"目标不可达"、"证书验证失败"等,这些可能揭示系统配置问题
|
||||
- **AI明确分析的失败尝试**:如果AI在对话中明确提到"尝试了X但失败了,因为Y",必须记录
|
||||
|
||||
**判断标准:**
|
||||
- 如果失败提供了**任何**有助于理解系统、调整策略或学习的信息,就必须保留
|
||||
- 如果失败揭示了**任何**关于目标系统、防护机制或环境配置的信息,就必须保留
|
||||
- 如果失败指引了**后续的成功尝试**,就必须保留并建立连接关系
|
||||
- **宁可多保留一些失败路径,也不要遗漏有价值的线索**
|
||||
|
||||
**只保留对学习或溯源有帮助的节点**:包括成功路径和重要的失败路径
|
||||
|
||||
### 4. 关联关系(确保DAG结构)
|
||||
|
||||
- target → action:目标指向属于它的所有行动(通过工具执行参数判断目标)
|
||||
- action → action:按时间顺序连接,但只连接有逻辑关系的
|
||||
- **重要:只连接属于同一目标的action节点,不同目标的action节点之间不应该连接**
|
||||
- **必须确保无环**:只能从早期步骤指向后期步骤,不能形成循环
|
||||
- 优先连接直接相关的步骤,避免过度连接
|
||||
- action → vulnerability:行动发现的漏洞
|
||||
- vulnerability → vulnerability:漏洞间的因果关系
|
||||
- **重要:只连接属于同一目标的漏洞,不同目标的漏洞之间不应该连接**
|
||||
- **必须确保无环**:漏洞间的因果关系也必须是单向的
|
||||
|
||||
### 5. 节点属性
|
||||
|
||||
每个节点需要:id, type, label, risk_score, metadata
|
||||
|
||||
**重要:risk_score规则**
|
||||
- **target节点**:可以设置适当的risk_score(如40),表示目标本身的风险
|
||||
- **action节点**:**必须设置为0**,因为行动本身没有风险,只有漏洞才有风险
|
||||
- **vulnerability节点**:必须根据漏洞严重程度设置risk_score(高危80-100,中危60-80,低危40-60)
|
||||
|
||||
**action节点metadata必须包含:**
|
||||
- tool_name: 工具名称(必须与ReAct中的tool_calls一致)
|
||||
- tool_intent: 工具调用意图(如"端口扫描"、"漏洞扫描"、"目录枚举"等)
|
||||
- ai_analysis: AI对工具结果的分析总结(不超过150字)
|
||||
- **成功节点**:总结关键发现和结果
|
||||
- **失败节点**:**必须详细说明**:①失败的具体原因 ②获得了什么线索或信息 ③这些线索如何指引了后续行动 ④为什么这个失败很重要
|
||||
- findings: 关键发现列表(数组)
|
||||
- 成功节点:如["发现80端口开放", "检测到WAF"]
|
||||
- **失败节点**:必须包含失败原因和获得的线索,如["WAF拦截SQL注入尝试", "返回403错误", "目标部署了Web应用防火墙"]
|
||||
- status:
|
||||
- 成功节点:可以不设置或设置为"success"
|
||||
- **失败节点:必须标记为"failed_insight"**,表示失败但提供了有价值的线索
|
||||
|
||||
**target节点metadata必须包含:**
|
||||
- target: 测试目标(URL、IP、域名等)
|
||||
|
||||
**vulnerability节点metadata必须包含:**
|
||||
- vulnerability_type: 漏洞类型
|
||||
- description: 实际发现的漏洞描述(必须与模型输出中明确提及的漏洞一致)
|
||||
- severity: 严重程度("critical"|"high"|"medium"|"low")
|
||||
- location: 漏洞位置
|
||||
|
||||
## 输出格式
|
||||
|
||||
请以JSON格式返回攻击链,严格按照以下格式:
|
||||
严格按照以下JSON格式输出,不要添加任何其他文字:
|
||||
|
||||
{
|
||||
"nodes": [
|
||||
@@ -500,64 +501,50 @@ func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
|
||||
{
|
||||
"id": "node_2",
|
||||
"type": "action",
|
||||
"label": "扫描端口发现80/443",
|
||||
"label": "扫描端口发现80/443/8080",
|
||||
"risk_score": 0,
|
||||
"metadata": {
|
||||
"tool_name": "nmap",
|
||||
"tool_intent": "端口扫描",
|
||||
"ai_analysis": "使用nmap扫描发现80和443端口开放,目标运行标准Web服务",
|
||||
"findings": ["80端口开放", "443端口开放"]
|
||||
"ai_analysis": "使用nmap对目标进行端口扫描,发现80、443、8080端口开放。80端口运行HTTP服务,443端口运行HTTPS服务,8080端口可能为管理后台。这些开放端口为后续Web应用测试提供了入口。",
|
||||
"findings": ["80端口开放", "443端口开放", "8080端口开放", "HTTP服务为Apache 2.4"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node_3",
|
||||
"type": "action",
|
||||
"label": "SQLMap扫描(工具未安装)",
|
||||
"risk_score": 0,
|
||||
"metadata": {
|
||||
"tool_name": "sqlmap",
|
||||
"tool_intent": "SQL注入测试",
|
||||
"ai_analysis": "sqlmap工具未安装,无法进行SQL注入测试。这个失败揭示了测试环境的工具配置情况,需要先安装工具才能继续测试。",
|
||||
"findings": ["工具未安装,需要先安装sqlmap工具", "环境配置限制:缺少SQL注入测试工具"],
|
||||
"status": "failed_insight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node_5",
|
||||
"type": "action",
|
||||
"label": "尝试SQL注入(被WAF拦截)",
|
||||
"risk_score": 0,
|
||||
"metadata": {
|
||||
"tool_name": "manual_test",
|
||||
"tool_intent": "SQL注入验证",
|
||||
"ai_analysis": "尝试SQL注入攻击时被WAF拦截,返回403错误。这个失败提供了重要线索:目标部署了WAF防护,且WAF规则较为严格。后续可以尝试绕过WAF或寻找其他攻击面。",
|
||||
"findings": ["WAF拦截SQL注入尝试", "返回403错误", "目标部署了Web应用防火墙"],
|
||||
"status": "failed_insight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node_6",
|
||||
"type": "action",
|
||||
"label": "端口扫描(连接超时)",
|
||||
"risk_score": 0,
|
||||
"metadata": {
|
||||
"tool_name": "nmap",
|
||||
"tool_intent": "端口扫描",
|
||||
"ai_analysis": "对目标进行端口扫描时,多个端口连接超时。这个失败表明目标可能部署了防火墙或IDS,对扫描行为进行了检测和拦截。超时信息有助于了解目标的防护策略。",
|
||||
"findings": ["多个端口连接超时", "可能部署了防火墙或IDS", "目标对扫描行为有防护"],
|
||||
"tool_name": "sqlmap",
|
||||
"tool_intent": "SQL注入检测",
|
||||
"ai_analysis": "对/login.php进行SQL注入测试时被WAF拦截,返回403错误。错误信息显示检测到Cloudflare防护。这表明目标部署了WAF,需要调整测试策略,可尝试绕过技术或寻找其他未受保护的攻击面。",
|
||||
"findings": ["WAF拦截", "返回403", "检测到Cloudflare", "目标部署WAF"],
|
||||
"status": "failed_insight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node_4",
|
||||
"type": "action",
|
||||
"label": "目录扫描发现/admin后台",
|
||||
"risk_score": 0,
|
||||
"metadata": {
|
||||
"tool_name": "dirsearch",
|
||||
"tool_intent": "目录扫描",
|
||||
"ai_analysis": "使用dirsearch对目标进行目录扫描,发现/admin目录存在且可访问。该目录可能为管理后台,是重要的测试目标。结合之前发现的WAF防护,可以尝试对/admin目录进行绕过测试。",
|
||||
"findings": ["/admin目录存在", "返回200状态码", "疑似管理后台"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node_5",
|
||||
"type": "vulnerability",
|
||||
"label": "SQL注入漏洞",
|
||||
"risk_score": 85,
|
||||
"metadata": {
|
||||
"vulnerability_type": "SQL注入",
|
||||
"description": "在/admin/login.php发现SQL注入漏洞",
|
||||
"description": "在/admin/login.php的username参数发现SQL注入漏洞,可通过注入payload绕过登录验证,直接获取管理员权限。漏洞返回数据库错误信息,确认存在注入点。",
|
||||
"severity": "high",
|
||||
"location": "/admin/login.php"
|
||||
"location": "/admin/login.php?username="
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -570,37 +557,36 @@ func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
|
||||
},
|
||||
{
|
||||
"source": "node_2",
|
||||
"target": "node_3",
|
||||
"type": "leads_to",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"source": "node_3",
|
||||
"target": "node_4",
|
||||
"type": "leads_to",
|
||||
"weight": 4
|
||||
},
|
||||
{
|
||||
"source": "node_4",
|
||||
"target": "node_5",
|
||||
"type": "discovers",
|
||||
"weight": 5
|
||||
"weight": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
**关键要求:**
|
||||
- 节点id必须从"node_1"开始,按顺序递增(node_1, node_2, node_3, ...)
|
||||
- 所有边的source节点id必须小于target节点id(确保DAG无环)
|
||||
- target节点必须是node_1(如果是多目标,第一个target是node_1,第二个target是node_2,以此类推)
|
||||
- 节点之间必须形成清晰的路径,不能有孤立节点
|
||||
- 如果有vulnerability节点,必须展示从target到vulnerability的完整路径
|
||||
- 边的类型只能是:leads_to、discovers、enables
|
||||
## 重要提醒
|
||||
|
||||
**再次强调:如果没有实际数据,返回空的nodes和edges数组。严禁杜撰!**
|
||||
1. **严禁杜撰**:只使用ReAct输入中实际执行的工具和实际返回的结果。如无实际数据,返回空的nodes和edges数组。
|
||||
2. **完整性优先**:必须包含所有有意义的工具执行和关键步骤,不要为了控制节点数量而删除重要节点。攻击链必须能够完整展现从目标识别到漏洞发现的完整过程。
|
||||
3. **逻辑连贯**:确保攻击链能够讲述一个完整、连贯的渗透测试故事,包括所有关键步骤和决策点。
|
||||
4. **教育价值**:优先保留有教育意义的节点,帮助学习者理解渗透测试思维和完整流程。
|
||||
5. **准确性**:所有节点信息必须基于实际数据,不要推测或假设。
|
||||
6. **完整性检查**:确保每个节点都有必要的metadata字段,每条边都有正确的source和target,没有孤立节点。
|
||||
7. **不要过度精简**:如果实际执行步骤较多,可以适当增加节点数量(最多20个),确保不遗漏关键步骤。
|
||||
|
||||
## ⚠️ 关于失败路径的最后提醒
|
||||
|
||||
**请特别注意:在生成攻击链时,不要只关注成功的路径,也要仔细检查ReAct输入中的所有失败尝试。**
|
||||
|
||||
**检查清单(在生成节点前,请逐一检查):**
|
||||
1. ✅ 是否所有工具执行失败都被检查过了?
|
||||
2. ✅ 每个失败是否提供了线索、错误信息或指引?
|
||||
3. ✅ 失败路径是否连接到了后续的成功路径?
|
||||
4. ✅ 失败节点的metadata是否详细说明了线索价值?
|
||||
5. ✅ 是否因为节点数量限制而误删了重要的失败路径?
|
||||
|
||||
**记住:一个完整的攻击链应该展示真实的渗透测试过程,包括成功和重要的失败尝试。失败路径不是噪音,而是宝贵的经验和学习材料!**
|
||||
|
||||
只返回JSON,不要包含其他解释文字。`, reactInput, modelOutput)
|
||||
现在开始分析并构建攻击链:`, reactInput, modelOutput)
|
||||
}
|
||||
|
||||
// saveChain 保存攻击链到数据库
|
||||
|
||||
@@ -105,6 +105,7 @@ type ToolConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Parameters []ParameterConfig `yaml:"parameters,omitempty"` // 参数定义(可选)
|
||||
ArgMapping string `yaml:"arg_mapping,omitempty"` // 参数映射方式: "auto", "manual", "template"(可选)
|
||||
AllowedExitCodes []int `yaml:"allowed_exit_codes,omitempty"` // 允许的退出码列表(某些工具在成功时也返回非零退出码)
|
||||
}
|
||||
|
||||
// ParameterConfig 参数配置
|
||||
|
||||
@@ -129,11 +129,30 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
||||
}
|
||||
|
||||
// ListConversations 列出所有对话
|
||||
func (db *DB) ListConversations(limit, offset int) ([]*Conversation, error) {
|
||||
rows, err := db.Query(
|
||||
"SELECT id, title, created_at, updated_at FROM conversations ORDER BY updated_at DESC LIMIT ? OFFSET ?",
|
||||
limit, offset,
|
||||
)
|
||||
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if search != "" {
|
||||
// 使用LIKE进行模糊搜索,搜索标题和消息内容
|
||||
searchPattern := "%" + search + "%"
|
||||
// 使用DISTINCT避免重复,因为一个对话可能有多条消息匹配
|
||||
rows, err = db.Query(
|
||||
`SELECT DISTINCT c.id, c.title, c.created_at, c.updated_at
|
||||
FROM conversations c
|
||||
LEFT JOIN messages m ON c.id = m.conversation_id
|
||||
WHERE c.title LIKE ? OR m.content LIKE ?
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
searchPattern, searchPattern, limit, offset,
|
||||
)
|
||||
} else {
|
||||
rows, err = db.Query(
|
||||
"SELECT id, title, created_at, updated_at FROM conversations ORDER BY updated_at DESC LIMIT ? OFFSET ?",
|
||||
limit, offset,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询对话列表失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ func (h *ConversationHandler) CreateConversation(c *gin.Context) {
|
||||
func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "50")
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
search := c.Query("search") // 获取搜索参数
|
||||
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
offset, _ := strconv.Atoi(offsetStr)
|
||||
@@ -63,7 +64,7 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
conversations, err := h.db.ListConversations(limit, offset)
|
||||
conversations, err := h.db.ListConversations(limit, offset, search)
|
||||
if err != nil {
|
||||
h.logger.Error("获取对话列表失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -145,9 +145,33 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// 检查退出码是否在允许列表中
|
||||
exitCode := getExitCode(err)
|
||||
if exitCode != nil && toolConfig.AllowedExitCodes != nil {
|
||||
for _, allowedCode := range toolConfig.AllowedExitCodes {
|
||||
if *exitCode == allowedCode {
|
||||
e.logger.Info("工具执行完成(退出码在允许列表中)",
|
||||
zap.String("tool", toolName),
|
||||
zap.Int("exitCode", *exitCode),
|
||||
zap.String("output", string(output)),
|
||||
)
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{
|
||||
{
|
||||
Type: "text",
|
||||
Text: string(output),
|
||||
},
|
||||
},
|
||||
IsError: false,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e.logger.Error("工具执行失败",
|
||||
zap.String("tool", toolName),
|
||||
zap.Error(err),
|
||||
zap.Int("exitCode", getExitCodeValue(err)),
|
||||
zap.String("output", string(output)),
|
||||
)
|
||||
return &mcp.ToolResult{
|
||||
@@ -1217,3 +1241,25 @@ func (e *Executor) convertToOpenAIType(configType string) string {
|
||||
return configType
|
||||
}
|
||||
}
|
||||
|
||||
// getExitCode 从错误中提取退出码,如果不是ExitError则返回nil
|
||||
func getExitCode(err error) *int {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
if exitError.ProcessState != nil {
|
||||
exitCode := exitError.ExitCode()
|
||||
return &exitCode
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getExitCodeValue 从错误中提取退出码值,如果不是ExitError则返回-1
|
||||
func getExitCodeValue(err error) int {
|
||||
if code := getExitCode(err); code != nil {
|
||||
return *code
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -748,6 +748,61 @@ header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.conversation-search-box {
|
||||
position: relative;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.conversation-search-box input {
|
||||
width: 100%;
|
||||
padding: 8px 32px 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.conversation-search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1);
|
||||
}
|
||||
|
||||
.conversation-search-box input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.conversation-search-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.conversation-search-clear:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.conversation-search-clear svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+220
-24
@@ -1185,9 +1185,13 @@ function startNewConversation() {
|
||||
}
|
||||
|
||||
// 加载对话列表(按时间分组)
|
||||
async function loadConversations() {
|
||||
async function loadConversations(searchQuery = '') {
|
||||
try {
|
||||
const response = await apiFetch('/api/conversations?limit=50');
|
||||
let url = '/api/conversations?limit=50';
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
url += '&search=' + encodeURIComponent(searchQuery.trim());
|
||||
}
|
||||
const response = await apiFetch(url);
|
||||
const conversations = await response.json();
|
||||
|
||||
const listContainer = document.getElementById('conversations-list');
|
||||
@@ -1315,6 +1319,45 @@ function createConversationListItem(conversation) {
|
||||
return item;
|
||||
}
|
||||
|
||||
// 处理历史记录搜索
|
||||
let conversationSearchTimer = null;
|
||||
function handleConversationSearch(query) {
|
||||
// 防抖处理,避免频繁请求
|
||||
if (conversationSearchTimer) {
|
||||
clearTimeout(conversationSearchTimer);
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById('conversation-search-input');
|
||||
const clearBtn = document.getElementById('conversation-search-clear');
|
||||
|
||||
if (clearBtn) {
|
||||
if (query && query.trim()) {
|
||||
clearBtn.style.display = 'block';
|
||||
} else {
|
||||
clearBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
conversationSearchTimer = setTimeout(() => {
|
||||
loadConversations(query);
|
||||
}, 300); // 300ms防抖延迟
|
||||
}
|
||||
|
||||
// 清除搜索
|
||||
function clearConversationSearch() {
|
||||
const searchInput = document.getElementById('conversation-search-input');
|
||||
const clearBtn = document.getElementById('conversation-search-clear');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
}
|
||||
if (clearBtn) {
|
||||
clearBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
loadConversations('');
|
||||
}
|
||||
|
||||
function formatConversationTimestamp(dateObj, todayStart, yesterdayStart) {
|
||||
if (!(dateObj instanceof Date) || isNaN(dateObj.getTime())) {
|
||||
return '';
|
||||
@@ -2070,8 +2113,10 @@ function renderAttackChain(chainData) {
|
||||
'target-arrow-size': 8,
|
||||
// 使用bezier曲线,更美观
|
||||
'curve-style': 'bezier',
|
||||
'control-point-step-size': 50,
|
||||
'control-point-distance': 40,
|
||||
'control-point-step-size': 60, // 增加步长,让控制点分布更均匀
|
||||
// 大幅增加控制点距离,避免多条边指向同一节点时箭头重叠
|
||||
// 使用更大的值确保箭头之间有足够的间距
|
||||
'control-point-distance': isComplexGraph ? 180 : 150,
|
||||
'opacity': 0.7,
|
||||
// 根据边类型设置线条样式:targets使用虚线,其他使用实线
|
||||
'line-style': function(ele) {
|
||||
@@ -2153,43 +2198,47 @@ function renderAttackChain(chainData) {
|
||||
const estimatedDepth = Math.ceil(Math.log2(Math.max(nodeCount, 2))) + 1;
|
||||
|
||||
// 动态计算节点水平间距:基于容器宽度和节点数量
|
||||
// 目标:使用容器宽度的85-90%,让图充分展开
|
||||
// 目标:使用容器宽度的95%,让图充分展开
|
||||
const maxLevelWidth = Math.max(1, Math.ceil(nodeCount / estimatedDepth));
|
||||
const targetGraphWidth = containerWidth * 0.88; // 使用88%的容器宽度
|
||||
const minNodeSep = avgNodeWidth * 0.8; // 最小间距为节点宽度的80%(从60%增加到80%,增加水平间距)
|
||||
const targetGraphWidth = containerWidth * 0.95; // 使用95%的容器宽度,让图更宽
|
||||
// 大幅增加最小间距,确保节点不重叠(考虑节点宽度和标签)
|
||||
const minNodeSep = avgNodeWidth * 1.5; // 最小间距为节点宽度的1.5倍,确保节点之间有足够空间
|
||||
// 优化间距计算:确保即使节点很多时也有足够的间距
|
||||
const availableWidth = targetGraphWidth - avgNodeWidth * maxLevelWidth;
|
||||
const calculatedNodeSep = Math.max(
|
||||
minNodeSep,
|
||||
Math.min(
|
||||
(targetGraphWidth - avgNodeWidth * maxLevelWidth) / Math.max(1, maxLevelWidth - 1),
|
||||
avgNodeWidth * 2.0 // 最大间距不超过节点宽度的2.0倍(从1.5增加到2.0)
|
||||
availableWidth / Math.max(1, maxLevelWidth - 1),
|
||||
avgNodeWidth * 3.0 // 最大间距不超过节点宽度的3.0倍,让图更宽
|
||||
)
|
||||
);
|
||||
|
||||
// 动态计算层级间距:基于容器高度和层级数
|
||||
// 增加最小间距,避免节点重合
|
||||
// 大幅增加最小间距,避免节点重合
|
||||
const targetGraphHeight = containerHeight * 0.85;
|
||||
const calculatedRankSep = Math.max(
|
||||
avgNodeHeight * 1.8, // 最小为节点高度的1.8倍(从1.2增加到1.8,增加层级间距)
|
||||
avgNodeHeight * 2.5, // 最小为节点高度的2.5倍,确保垂直方向有足够间距
|
||||
Math.min(
|
||||
targetGraphHeight / Math.max(estimatedDepth - 1, 1),
|
||||
avgNodeHeight * 3.5 // 最大不超过节点高度的3.5倍(从2.5增加到3.5)
|
||||
avgNodeHeight * 4.0 // 最大不超过节点高度的4.0倍
|
||||
)
|
||||
);
|
||||
|
||||
// 边间距:基于节点间距的合理比例
|
||||
const calculatedEdgeSep = Math.max(40, calculatedNodeSep * 0.3); // 增加边间距(从30增加到40,从0.25增加到0.3)
|
||||
// 增加边间距,确保边之间有足够的空间,避免视觉混乱
|
||||
const calculatedEdgeSep = Math.max(50, calculatedNodeSep * 0.4);
|
||||
|
||||
// 根据图的复杂度调整布局参数,优化可读性和空间利用率
|
||||
layoutOptions = {
|
||||
name: 'dagre',
|
||||
rankDir: 'TB', // 从上到下
|
||||
spacingFactor: 1.0, // 使用1.0,因为我们已经动态计算了具体间距
|
||||
spacingFactor: 1.2, // 增加间距因子,让图更宽
|
||||
nodeSep: Math.round(calculatedNodeSep), // 动态计算的节点间距
|
||||
edgeSep: Math.round(calculatedEdgeSep), // 动态计算的边间距
|
||||
rankSep: Math.round(calculatedRankSep), // 动态计算的层级间距
|
||||
nodeDimensionsIncludeLabels: true, // 考虑标签大小
|
||||
animate: false,
|
||||
padding: Math.min(40, containerWidth * 0.02), // 动态边距,不超过容器宽度的2%
|
||||
padding: Math.max(40, Math.min(60, containerWidth * 0.03)), // 减少边距,让图更宽
|
||||
// 优化边的路由,减少交叉
|
||||
edgeRouting: 'polyline',
|
||||
// 对齐方式:使用上左对齐,然后手动居中
|
||||
@@ -2205,11 +2254,14 @@ function renderAttackChain(chainData) {
|
||||
// 应用布局,等待布局完成后再平衡和居中
|
||||
const layout = attackChainCytoscape.layout(layoutOptions);
|
||||
layout.one('layoutstop', () => {
|
||||
// 布局完成后,先平衡分支,再居中显示
|
||||
// 布局完成后,先平衡分支,再修复重叠,最后居中显示
|
||||
setTimeout(() => {
|
||||
balanceBranches();
|
||||
setTimeout(() => {
|
||||
centerAttackChain();
|
||||
fixNodeOverlaps();
|
||||
setTimeout(() => {
|
||||
centerAttackChain();
|
||||
}, 50);
|
||||
}, 50);
|
||||
}, 100);
|
||||
});
|
||||
@@ -2228,13 +2280,15 @@ function renderAttackChain(chainData) {
|
||||
const avgNodeWidth = isComplexGraph ? 230 : 250;
|
||||
const estimatedDepth = Math.ceil(Math.log2(Math.max(nodeCount, 2))) + 1;
|
||||
const maxLevelWidth = Math.max(1, Math.ceil(nodeCount / estimatedDepth));
|
||||
const targetGraphWidth = containerWidth * 0.88;
|
||||
const minNodeSep = avgNodeWidth * 0.8; // 与布局计算保持一致
|
||||
const targetGraphWidth = containerWidth * 0.95; // 与布局计算保持一致,使用95%宽度
|
||||
// 与布局计算保持一致,使用更大的间距避免节点重叠
|
||||
const minNodeSep = avgNodeWidth * 1.5; // 与布局计算保持一致
|
||||
const availableWidth = targetGraphWidth - avgNodeWidth * maxLevelWidth;
|
||||
const spacing = Math.max(
|
||||
minNodeSep,
|
||||
Math.min(
|
||||
(targetGraphWidth - avgNodeWidth * maxLevelWidth) / Math.max(1, maxLevelWidth - 1),
|
||||
avgNodeWidth * 2.0 // 与布局计算保持一致
|
||||
availableWidth / Math.max(1, maxLevelWidth - 1),
|
||||
avgNodeWidth * 3.0 // 与布局计算保持一致
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2334,9 +2388,9 @@ function renderAttackChain(chainData) {
|
||||
const leftTotalWidth = leftGroup.length > 0 ? leftTotal + (leftGroup.length - 1) * spacing : 0;
|
||||
const rightTotalWidth = rightGroup.length > 0 ? rightTotal + (rightGroup.length - 1) * spacing : 0;
|
||||
// 根据容器宽度动态调整,充分利用水平空间
|
||||
// 使用更大的宽度系数,让图充分利用容器空间(使用88%的容器宽度以匹配布局算法)
|
||||
// 使用更大的宽度系数,让图充分利用容器空间(使用95%的容器宽度以匹配布局算法)
|
||||
const maxSideWidth = Math.max(leftTotalWidth, rightTotalWidth);
|
||||
const targetWidth = Math.max(maxSideWidth * 1.2, containerWidth * 0.88); // 使用88%的容器宽度以匹配布局
|
||||
const targetWidth = Math.max(maxSideWidth * 1.2, containerWidth * 0.95); // 使用95%的容器宽度以匹配布局
|
||||
const maxWidth = Math.max(targetWidth, avgNodeWidth * 2);
|
||||
|
||||
// 递归调整子树位置
|
||||
@@ -2437,6 +2491,148 @@ function renderAttackChain(chainData) {
|
||||
}
|
||||
}
|
||||
|
||||
// 修复节点重叠的函数
|
||||
function fixNodeOverlaps() {
|
||||
try {
|
||||
if (!attackChainCytoscape) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = attackChainCytoscape.nodes();
|
||||
const minSpacing = 40; // 节点之间的最小间距(像素),增加以确保不重叠
|
||||
const overlapThreshold = 0.05; // 重叠阈值(5%),更敏感地检测重叠
|
||||
|
||||
// 按Y坐标分组节点(同一层级的节点)
|
||||
const nodesByLevel = new Map();
|
||||
nodes.forEach(node => {
|
||||
const pos = node.position();
|
||||
const y = Math.round(pos.y / 30) * 30; // 将相近的Y坐标归为同一层级(更精细的分组)
|
||||
|
||||
if (!nodesByLevel.has(y)) {
|
||||
nodesByLevel.set(y, []);
|
||||
}
|
||||
nodesByLevel.get(y).push(node);
|
||||
});
|
||||
|
||||
// 检查并修复同一层级内的重叠
|
||||
nodesByLevel.forEach((levelNodes, levelY) => {
|
||||
// 按X坐标排序
|
||||
levelNodes.sort((a, b) => a.position().x - b.position().x);
|
||||
|
||||
// 检查相邻节点是否重叠
|
||||
for (let i = 0; i < levelNodes.length - 1; i++) {
|
||||
const node1 = levelNodes[i];
|
||||
const node2 = levelNodes[i + 1];
|
||||
|
||||
const pos1 = node1.position();
|
||||
const pos2 = node2.position();
|
||||
const width1 = node1.width();
|
||||
const width2 = node2.width();
|
||||
const height1 = node1.height();
|
||||
const height2 = node2.height();
|
||||
|
||||
// 计算节点边界
|
||||
const left1 = pos1.x - width1 / 2;
|
||||
const right1 = pos1.x + width1 / 2;
|
||||
const top1 = pos1.y - height1 / 2;
|
||||
const bottom1 = pos1.y + height1 / 2;
|
||||
|
||||
const left2 = pos2.x - width2 / 2;
|
||||
const right2 = pos2.x + width2 / 2;
|
||||
const top2 = pos2.y - height2 / 2;
|
||||
const bottom2 = pos2.y + height2 / 2;
|
||||
|
||||
// 检查是否重叠
|
||||
const horizontalOverlap = Math.max(0, Math.min(right1, right2) - Math.max(left1, left2));
|
||||
const verticalOverlap = Math.max(0, Math.min(bottom1, bottom2) - Math.max(top1, top2));
|
||||
|
||||
const overlapArea = horizontalOverlap * verticalOverlap;
|
||||
const node1Area = width1 * height1;
|
||||
const node2Area = width2 * height2;
|
||||
const minArea = Math.min(node1Area, node2Area);
|
||||
|
||||
// 如果重叠面积超过阈值,调整位置
|
||||
if (overlapArea > minArea * overlapThreshold) {
|
||||
// 计算需要的间距
|
||||
const requiredSpacing = (width1 + width2) / 2 + minSpacing;
|
||||
const currentSpacing = pos2.x - pos1.x;
|
||||
const spacingDiff = requiredSpacing - currentSpacing;
|
||||
|
||||
if (spacingDiff > 0) {
|
||||
// 向右移动第二个节点及其后续节点
|
||||
const moveDistance = spacingDiff;
|
||||
for (let j = i + 1; j < levelNodes.length; j++) {
|
||||
const node = levelNodes[j];
|
||||
const currentPos = node.position();
|
||||
node.position({
|
||||
x: currentPos.x + moveDistance,
|
||||
y: currentPos.y
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 检查不同层级之间的重叠(垂直方向)- 简化处理
|
||||
// 只处理明显的垂直重叠,通过增加层级间距来解决
|
||||
const sortedLevels = Array.from(nodesByLevel.keys()).sort((a, b) => a - b);
|
||||
for (let i = 0; i < sortedLevels.length - 1; i++) {
|
||||
const level1Y = sortedLevels[i];
|
||||
const level2Y = sortedLevels[i + 1];
|
||||
const level1Nodes = nodesByLevel.get(level1Y);
|
||||
const level2Nodes = nodesByLevel.get(level2Y);
|
||||
|
||||
// 检查两个层级之间的最小垂直间距
|
||||
let minVerticalSpacing = Infinity;
|
||||
level1Nodes.forEach(node1 => {
|
||||
const pos1 = node1.position();
|
||||
const height1 = node1.height();
|
||||
const bottom1 = pos1.y + height1 / 2;
|
||||
|
||||
level2Nodes.forEach(node2 => {
|
||||
const pos2 = node2.position();
|
||||
const height2 = node2.height();
|
||||
const top2 = pos2.y - height2 / 2;
|
||||
|
||||
const spacing = top2 - bottom1;
|
||||
if (spacing < minVerticalSpacing) {
|
||||
minVerticalSpacing = spacing;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 如果垂直间距太小,向下移动第二个层级的所有节点
|
||||
if (minVerticalSpacing < minSpacing) {
|
||||
const moveDistance = minSpacing - minVerticalSpacing;
|
||||
level2Nodes.forEach(node => {
|
||||
const currentPos = node.position();
|
||||
node.position({
|
||||
x: currentPos.x,
|
||||
y: currentPos.y + moveDistance
|
||||
});
|
||||
});
|
||||
|
||||
// 更新后续层级的Y坐标
|
||||
for (let j = i + 2; j < sortedLevels.length; j++) {
|
||||
const laterLevelY = sortedLevels[j];
|
||||
const laterLevelNodes = nodesByLevel.get(laterLevelY);
|
||||
laterLevelNodes.forEach(node => {
|
||||
const currentPos = node.position();
|
||||
node.position({
|
||||
x: currentPos.x,
|
||||
y: currentPos.y + moveDistance
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('修复节点重叠时出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 居中攻击链的函数
|
||||
function centerAttackChain() {
|
||||
try {
|
||||
@@ -2472,7 +2668,7 @@ function renderAttackChain(chainData) {
|
||||
// 根据图的宽度和容器宽度,调整缩放以更好地利用水平空间
|
||||
const graphWidth = extent.x2 - extent.x1;
|
||||
const graphHeight = extent.y2 - extent.y1;
|
||||
const availableWidth = containerWidth * 0.88; // 使用88%的容器宽度(与布局算法一致)
|
||||
const availableWidth = containerWidth * 0.95; // 使用95%的容器宽度(与布局算法一致)
|
||||
const availableHeight = containerHeight * 0.85; // 使用85%的容器高度
|
||||
const currentZoom = attackChainCytoscape.zoom();
|
||||
|
||||
|
||||
@@ -142,6 +142,18 @@
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-title">历史对话</div>
|
||||
<div class="conversation-search-box">
|
||||
<input type="text" id="conversation-search-input" placeholder="搜索历史记录..."
|
||||
oninput="handleConversationSearch(this.value)"
|
||||
onkeypress="if(event.key === 'Enter') handleConversationSearch(this.value)" />
|
||||
<button class="conversation-search-clear" id="conversation-search-clear"
|
||||
onclick="clearConversationSearch()" style="display: none;" title="清除搜索">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="conversations-list" class="conversations-list"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user