From ef169ba307301d618b1a5c370c1a79364b29fa86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Wed, 24 Dec 2025 23:14:37 +0800 Subject: [PATCH] Add files via upload --- internal/attackchain/builder.go | 384 ++++++++++++++---------------- internal/config/config.go | 1 + internal/database/conversation.go | 29 ++- internal/handler/conversation.go | 3 +- internal/security/executor.go | 46 ++++ web/static/css/style.css | 55 +++++ web/static/js/chat.js | 244 +++++++++++++++++-- web/templates/index.html | 12 + 8 files changed, 545 insertions(+), 229 deletions(-) diff --git a/internal/attackchain/builder.go b/internal/attackchain/builder.go index 8be985ca..f85d75cf 100644 --- a/internal/attackchain/builder.go +++ b/internal/attackchain/builder.go @@ -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 保存攻击链到数据库 diff --git a/internal/config/config.go b/internal/config/config.go index 975e4fae..1db1a626 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 参数配置 diff --git a/internal/database/conversation.go b/internal/database/conversation.go index af17fe5f..31134b99 100644 --- a/internal/database/conversation.go +++ b/internal/database/conversation.go @@ -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) } diff --git a/internal/handler/conversation.go b/internal/handler/conversation.go index 7e2a1d22..db1629dc 100644 --- a/internal/handler/conversation.go +++ b/internal/handler/conversation.go @@ -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()}) diff --git a/internal/security/executor.go b/internal/security/executor.go index 95959645..e1f70640 100644 --- a/internal/security/executor.go +++ b/internal/security/executor.go @@ -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 +} diff --git a/web/static/css/style.css b/web/static/css/style.css index 8c7682ff..6db7058e 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -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; diff --git a/web/static/js/chat.js b/web/static/js/chat.js index b2384aac..746d1b68 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -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(); diff --git a/web/templates/index.html b/web/templates/index.html index d1c750c0..40133b49 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -142,6 +142,18 @@