diff --git a/backend/app/api/v1/endpoints/agent_tasks.py b/backend/app/api/v1/endpoints/agent_tasks.py
index 8f0582c..16501b4 100644
--- a/backend/app/api/v1/endpoints/agent_tasks.py
+++ b/backend/app/api/v1/endpoints/agent_tasks.py
@@ -511,7 +511,9 @@ async def _execute_agent_task(task_id: str):
if isinstance(f, dict):
logger.debug(f"[AgentTask] Finding {i+1}: {f.get('title', 'N/A')[:50]} - {f.get('severity', 'N/A')}")
- await _save_findings(db, task_id, findings)
+ # 🔥 v2.1: 传递 project_root 用于文件路径验证
+ saved_count = await _save_findings(db, task_id, findings, project_root=project_root)
+ logger.info(f"[AgentTask] Saved {saved_count}/{len(findings)} findings (filtered {len(findings) - saved_count} hallucinations)")
# 更新任务统计
# 🔥 CRITICAL FIX: 在设置完成前再次检查取消状态
@@ -523,7 +525,7 @@ async def _execute_agent_task(task_id: str):
task.status = AgentTaskStatus.COMPLETED
task.completed_at = datetime.now(timezone.utc)
task.current_phase = AgentTaskPhase.REPORTING
- task.findings_count = len(findings)
+ task.findings_count = saved_count # 🔥 v2.1: 使用实际保存的数量(排除幻觉)
task.total_iterations = result.iterations
task.tool_calls_count = result.tool_calls
task.tokens_used = result.tokens_used
@@ -982,8 +984,8 @@ async def _initialize_tools(
"run_code": RunCodeTool(sandbox_manager, project_root),
"extract_function": ExtractFunctionTool(project_root),
- # 报告工具
- "create_vulnerability_report": CreateVulnerabilityReportTool(),
+ # 报告工具 - 🔥 v2.1: 传递 project_root 用于文件验证
+ "create_vulnerability_report": CreateVulnerabilityReportTool(project_root),
}
# Orchestrator 工具(主要是思考工具)
@@ -1117,11 +1119,26 @@ async def _collect_project_info(
return info
-async def _save_findings(db: AsyncSession, task_id: str, findings: List[Dict]) -> None:
+async def _save_findings(
+ db: AsyncSession,
+ task_id: str,
+ findings: List[Dict],
+ project_root: Optional[str] = None,
+) -> int:
"""
保存发现到数据库
🔥 增强版:支持多种 Agent 输出格式,健壮的字段映射
+ 🔥 v2.1: 添加文件路径验证,过滤幻觉发现
+
+ Args:
+ db: 数据库会话
+ task_id: 任务ID
+ findings: 发现列表
+ project_root: 项目根目录(用于验证文件路径)
+
+ Returns:
+ int: 实际保存的发现数量
"""
from app.models.agent_task import VulnerabilityType
@@ -1129,7 +1146,7 @@ async def _save_findings(db: AsyncSession, task_id: str, findings: List[Dict]) -
if not findings:
logger.warning(f"[SaveFindings] No findings to save for task {task_id}")
- return
+ return 0
# 🔥 Case-insensitive mapping preparation
severity_map = {
@@ -1216,6 +1233,21 @@ async def _save_findings(db: AsyncSession, task_id: str, findings: List[Dict]) -
finding.get("location", "").split(":")[0] if ":" in finding.get("location", "") else finding.get("location")
)
+ # 🔥 v2.1: 文件路径验证 - 过滤幻觉发现
+ if project_root and file_path:
+ # 清理路径(移除可能的行号)
+ clean_path = file_path.split(":")[0].strip() if ":" in file_path else file_path.strip()
+ full_path = os.path.join(project_root, clean_path)
+
+ if not os.path.isfile(full_path):
+ # 尝试作为绝对路径
+ if not (os.path.isabs(clean_path) and os.path.isfile(clean_path)):
+ logger.warning(
+ f"[SaveFindings] 🚫 跳过幻觉发现: 文件不存在 '{file_path}' "
+ f"(title: {finding.get('title', 'N/A')[:50]})"
+ )
+ continue # 跳过这个发现
+
# 🔥 Handle line numbers (support multiple formats)
line_start = finding.get("line_start") or finding.get("line")
if not line_start and ":" in finding.get("location", ""):
@@ -1346,6 +1378,8 @@ async def _save_findings(db: AsyncSession, task_id: str, findings: List[Dict]) -
logger.error(f"Failed to commit findings: {e}")
await db.rollback()
+ return saved_count
+
def _calculate_security_score(findings: List[Dict]) -> float:
"""计算安全评分"""
@@ -3154,15 +3188,53 @@ async def generate_audit_report(
md_lines.append("")
if f.code_snippet:
- # Detect language from file extension
- lang = "python"
+ # 🔥 v2.1: 增强语言检测,避免默认 python 标记错误
+ lang = "text" # 默认使用 text 而非 python
if f.file_path:
ext = f.file_path.split('.')[-1].lower()
lang_map = {
- 'py': 'python', 'js': 'javascript', 'ts': 'typescript',
- 'jsx': 'jsx', 'tsx': 'tsx', 'java': 'java', 'go': 'go',
- 'rs': 'rust', 'rb': 'ruby', 'php': 'php', 'c': 'c',
- 'cpp': 'cpp', 'cs': 'csharp', 'sol': 'solidity'
+ # Python
+ 'py': 'python', 'pyw': 'python', 'pyi': 'python',
+ # JavaScript/TypeScript
+ 'js': 'javascript', 'mjs': 'javascript', 'cjs': 'javascript',
+ 'ts': 'typescript', 'mts': 'typescript',
+ 'jsx': 'jsx', 'tsx': 'tsx',
+ # Web
+ 'html': 'html', 'htm': 'html',
+ 'css': 'css', 'scss': 'scss', 'sass': 'sass', 'less': 'less',
+ 'vue': 'vue', 'svelte': 'svelte',
+ # Backend
+ 'java': 'java', 'kt': 'kotlin', 'kts': 'kotlin',
+ 'go': 'go', 'rs': 'rust',
+ 'rb': 'ruby', 'erb': 'erb',
+ 'php': 'php', 'phtml': 'php',
+ # C-family
+ 'c': 'c', 'h': 'c',
+ 'cpp': 'cpp', 'cc': 'cpp', 'cxx': 'cpp', 'hpp': 'cpp',
+ 'cs': 'csharp',
+ # Shell/Script
+ 'sh': 'bash', 'bash': 'bash', 'zsh': 'zsh',
+ 'ps1': 'powershell', 'psm1': 'powershell',
+ # Config
+ 'json': 'json', 'yaml': 'yaml', 'yml': 'yaml',
+ 'toml': 'toml', 'ini': 'ini', 'cfg': 'ini',
+ 'xml': 'xml', 'xhtml': 'xml',
+ # Database
+ 'sql': 'sql',
+ # Other
+ 'md': 'markdown', 'markdown': 'markdown',
+ 'sol': 'solidity',
+ 'swift': 'swift',
+ 'r': 'r', 'R': 'r',
+ 'lua': 'lua',
+ 'pl': 'perl', 'pm': 'perl',
+ 'ex': 'elixir', 'exs': 'elixir',
+ 'erl': 'erlang',
+ 'hs': 'haskell',
+ 'scala': 'scala', 'sc': 'scala',
+ 'clj': 'clojure', 'cljs': 'clojure',
+ 'dart': 'dart',
+ 'groovy': 'groovy', 'gradle': 'groovy',
}
lang = lang_map.get(ext, 'text')
md_lines.append("**漏洞代码:**")
diff --git a/backend/app/services/agent/agents/analysis.py b/backend/app/services/agent/agents/analysis.py
index ff94c38..a7eaa5f 100644
--- a/backend/app/services/agent/agents/analysis.py
+++ b/backend/app/services/agent/agents/analysis.py
@@ -155,6 +155,24 @@ Thought: [总结所有发现]
Final Answer: [JSON 格式的漏洞报告]
```
+## ⚠️ 输出格式要求(严格遵守)
+
+**禁止使用 Markdown 格式标记!** 你的输出必须是纯文本格式:
+
+✅ 正确:
+```
+Thought: 我需要使用 semgrep 扫描代码。
+Action: semgrep_scan
+Action Input: {"target_path": ".", "rules": "auto"}
+```
+
+❌ 错误(禁止):
+```
+**Thought:** 我需要扫描
+**Action:** semgrep_scan
+**Action Input:** {...}
+```
+
## Final Answer 格式
```json
{
@@ -265,13 +283,21 @@ class AnalysisAgent(BaseAgent):
"""解析 LLM 响应 - 增强版,更健壮地提取思考内容"""
step = AnalysisStep(thought="")
+ # 🔥 v2.1: 预处理 - 移除 Markdown 格式标记(LLM 有时会输出 **Action:** 而非 Action:)
+ cleaned_response = response
+ cleaned_response = re.sub(r'\*\*Action:\*\*', 'Action:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Action Input:\*\*', 'Action Input:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Thought:\*\*', 'Thought:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Final Answer:\*\*', 'Final Answer:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Observation:\*\*', 'Observation:', cleaned_response)
+
# 🔥 首先尝试提取明确的 Thought 标记
- thought_match = re.search(r'Thought:\s*(.*?)(?=Action:|Final Answer:|$)', response, re.DOTALL)
+ thought_match = re.search(r'Thought:\s*(.*?)(?=Action:|Final Answer:|$)', cleaned_response, re.DOTALL)
if thought_match:
step.thought = thought_match.group(1).strip()
# 🔥 检查是否是最终答案
- final_match = re.search(r'Final Answer:\s*(.*?)$', response, re.DOTALL)
+ final_match = re.search(r'Final Answer:\s*(.*?)$', cleaned_response, re.DOTALL)
if final_match:
step.is_final = True
answer_text = final_match.group(1).strip()
@@ -291,7 +317,7 @@ class AnalysisAgent(BaseAgent):
# 🔥 如果没有提取到 thought,使用 Final Answer 前的内容作为思考
if not step.thought:
- before_final = response[:response.find('Final Answer:')].strip()
+ before_final = cleaned_response[:cleaned_response.find('Final Answer:')].strip()
if before_final:
before_final = re.sub(r'^Thought:\s*', '', before_final)
step.thought = before_final[:500] if len(before_final) > 500 else before_final
@@ -299,21 +325,21 @@ class AnalysisAgent(BaseAgent):
return step
# 🔥 提取 Action
- action_match = re.search(r'Action:\s*(\w+)', response)
+ action_match = re.search(r'Action:\s*(\w+)', cleaned_response)
if action_match:
step.action = action_match.group(1).strip()
# 🔥 如果没有提取到 thought,提取 Action 之前的内容作为思考
if not step.thought:
- action_pos = response.find('Action:')
+ action_pos = cleaned_response.find('Action:')
if action_pos > 0:
- before_action = response[:action_pos].strip()
+ before_action = cleaned_response[:action_pos].strip()
before_action = re.sub(r'^Thought:\s*', '', before_action)
if before_action:
step.thought = before_action[:500] if len(before_action) > 500 else before_action
# 🔥 提取 Action Input
- input_match = re.search(r'Action Input:\s*(.*?)(?=Thought:|Action:|Observation:|$)', response, re.DOTALL)
+ input_match = re.search(r'Action Input:\s*(.*?)(?=Thought:|Action:|Observation:|$)', cleaned_response, re.DOTALL)
if input_match:
input_text = input_match.group(1).strip()
input_text = re.sub(r'```json\s*', '', input_text)
diff --git a/backend/app/services/agent/agents/orchestrator.py b/backend/app/services/agent/agents/orchestrator.py
index b12a407..d556d20 100644
--- a/backend/app/services/agent/agents/orchestrator.py
+++ b/backend/app/services/agent/agents/orchestrator.py
@@ -13,6 +13,7 @@ LLM 是真正的大脑,全程参与决策!
import asyncio
import json
import logging
+import os
import re
from typing import List, Dict, Any, Optional
from dataclasses import dataclass
@@ -534,32 +535,39 @@ Action Input: {{"参数": "值"}}
def _parse_llm_response(self, response: str) -> Optional[AgentStep]:
"""解析 LLM 响应"""
+ # 🔥 v2.1: 预处理 - 移除 Markdown 格式标记(LLM 有时会输出 **Action:** 而非 Action:)
+ cleaned_response = response
+ cleaned_response = re.sub(r'\*\*Action:\*\*', 'Action:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Action Input:\*\*', 'Action Input:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Thought:\*\*', 'Thought:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Observation:\*\*', 'Observation:', cleaned_response)
+
# 提取 Thought
- thought_match = re.search(r'Thought:\s*(.*?)(?=Action:|$)', response, re.DOTALL)
+ thought_match = re.search(r'Thought:\s*(.*?)(?=Action:|$)', cleaned_response, re.DOTALL)
thought = thought_match.group(1).strip() if thought_match else ""
-
+
# 提取 Action
- action_match = re.search(r'Action:\s*(\w+)', response)
+ action_match = re.search(r'Action:\s*(\w+)', cleaned_response)
if not action_match:
return None
action = action_match.group(1).strip()
-
+
# 提取 Action Input
- input_match = re.search(r'Action Input:\s*(.*?)(?=Thought:|Observation:|$)', response, re.DOTALL)
+ input_match = re.search(r'Action Input:\s*(.*?)(?=Thought:|Observation:|$)', cleaned_response, re.DOTALL)
if not input_match:
return None
-
+
input_text = input_match.group(1).strip()
# 移除 markdown 代码块
input_text = re.sub(r'```json\s*', '', input_text)
input_text = re.sub(r'```\s*', '', input_text)
-
+
# 使用增强的 JSON 解析器
action_input = AgentJsonParser.parse(
input_text,
default={"raw": input_text}
)
-
+
return AgentStep(
thought=thought,
action=action,
@@ -999,12 +1007,47 @@ Action Input: {{"参数": "值"}}
except Exception as e:
logger.error(f"Sub-agent dispatch failed: {e}", exc_info=True)
return f"## 调度失败\n\n错误: {str(e)}"
-
- def _normalize_finding(self, finding: Dict[str, Any]) -> Dict[str, Any]:
+
+ def _validate_file_path(self, file_path: str) -> bool:
+ """
+ 🔥 v2.1: 验证文件路径是否真实存在
+
+ Args:
+ file_path: 相对或绝对文件路径(可能包含行号,如 "app.py:36")
+
+ Returns:
+ bool: 文件是否存在
+ """
+ if not file_path or not file_path.strip():
+ return False
+
+ # 获取项目根目录
+ project_root = self._runtime_context.get("project_root", "")
+ if not project_root:
+ # 没有项目根目录时,无法验证,返回 True 以避免误判
+ return True
+
+ # 清理路径(移除可能的行号)
+ clean_path = file_path.split(":")[0].strip() if ":" in file_path else file_path.strip()
+
+ # 尝试相对路径
+ full_path = os.path.join(project_root, clean_path)
+ if os.path.isfile(full_path):
+ return True
+
+ # 尝试绝对路径
+ if os.path.isabs(clean_path) and os.path.isfile(clean_path):
+ return True
+
+ return False
+
+ def _normalize_finding(self, finding: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
标准化发现格式
不同 Agent 可能返回不同格式的发现,这个方法将它们标准化为统一格式
+
+ 🔥 v2.1: 添加文件路径验证,返回 None 表示发现无效(幻觉)
"""
normalized = dict(finding) # 复制原始数据
@@ -1086,6 +1129,15 @@ Action Input: {{"参数": "值"}}
if "impact" not in normalized["description"].lower():
normalized["description"] += f"\n\nImpact: {normalized['impact']}"
+ # 🔥 v2.1: 验证文件路径存在性
+ file_path = normalized.get("file_path", "")
+ if file_path and not self._validate_file_path(file_path):
+ logger.warning(
+ f"[Orchestrator] 🚫 过滤幻觉发现: 文件不存在 '{file_path}' "
+ f"(title: {normalized.get('title', 'N/A')[:50]})"
+ )
+ return None # 返回 None 表示发现无效
+
return normalized
def _summarize_findings(self) -> str:
diff --git a/backend/app/services/agent/agents/recon.py b/backend/app/services/agent/agents/recon.py
index ad47707..1244afa 100644
--- a/backend/app/services/agent/agents/recon.py
+++ b/backend/app/services/agent/agents/recon.py
@@ -80,6 +80,29 @@ Thought: [总结收集到的所有信息]
Final Answer: [JSON 格式的结果]
```
+## ⚠️ 输出格式要求(严格遵守)
+
+**禁止使用 Markdown 格式标记!** 你的输出必须是纯文本格式:
+
+✅ 正确格式:
+```
+Thought: 我需要查看项目结构来了解项目组成
+Action: list_files
+Action Input: {"directory": "."}
+```
+
+❌ 错误格式(禁止使用):
+```
+**Thought:** 我需要查看项目结构
+**Action:** list_files
+**Action Input:** {"directory": "."}
+```
+
+规则:
+1. 不要在 Thought:、Action:、Action Input:、Final Answer: 前后添加 `**`
+2. 不要使用其他 Markdown 格式(如 `###`、`*斜体*` 等)
+3. Action Input 必须是完整的 JSON 对象,不能为空或截断
+
## 输出格式
```
@@ -208,13 +231,21 @@ class ReconAgent(BaseAgent):
"""解析 LLM 响应 - 增强版,更健壮地提取思考内容"""
step = ReconStep(thought="")
+ # 🔥 v2.1: 预处理 - 移除 Markdown 格式标记(LLM 有时会输出 **Action:** 而非 Action:)
+ cleaned_response = response
+ cleaned_response = re.sub(r'\*\*Action:\*\*', 'Action:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Action Input:\*\*', 'Action Input:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Thought:\*\*', 'Thought:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Final Answer:\*\*', 'Final Answer:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Observation:\*\*', 'Observation:', cleaned_response)
+
# 🔥 首先尝试提取明确的 Thought 标记
- thought_match = re.search(r'Thought:\s*(.*?)(?=Action:|Final Answer:|$)', response, re.DOTALL)
+ thought_match = re.search(r'Thought:\s*(.*?)(?=Action:|Final Answer:|$)', cleaned_response, re.DOTALL)
if thought_match:
step.thought = thought_match.group(1).strip()
# 🔥 检查是否是最终答案
- final_match = re.search(r'Final Answer:\s*(.*?)$', response, re.DOTALL)
+ final_match = re.search(r'Final Answer:\s*(.*?)$', cleaned_response, re.DOTALL)
if final_match:
step.is_final = True
answer_text = final_match.group(1).strip()
@@ -234,7 +265,7 @@ class ReconAgent(BaseAgent):
# 🔥 如果没有提取到 thought,使用 Final Answer 前的内容作为思考
if not step.thought:
- before_final = response[:response.find('Final Answer:')].strip()
+ before_final = cleaned_response[:cleaned_response.find('Final Answer:')].strip()
if before_final:
# 移除可能的 Thought: 前缀
before_final = re.sub(r'^Thought:\s*', '', before_final)
@@ -243,22 +274,22 @@ class ReconAgent(BaseAgent):
return step
# 🔥 提取 Action
- action_match = re.search(r'Action:\s*(\w+)', response)
+ action_match = re.search(r'Action:\s*(\w+)', cleaned_response)
if action_match:
step.action = action_match.group(1).strip()
# 🔥 如果没有提取到 thought,提取 Action 之前的内容作为思考
if not step.thought:
- action_pos = response.find('Action:')
+ action_pos = cleaned_response.find('Action:')
if action_pos > 0:
- before_action = response[:action_pos].strip()
+ before_action = cleaned_response[:action_pos].strip()
# 移除可能的 Thought: 前缀
before_action = re.sub(r'^Thought:\s*', '', before_action)
if before_action:
step.thought = before_action[:500] if len(before_action) > 500 else before_action
# 🔥 提取 Action Input
- input_match = re.search(r'Action Input:\s*(.*?)(?=Thought:|Action:|Observation:|$)', response, re.DOTALL)
+ input_match = re.search(r'Action Input:\s*(.*?)(?=Thought:|Action:|Observation:|$)', cleaned_response, re.DOTALL)
if input_match:
input_text = input_match.group(1).strip()
input_text = re.sub(r'```json\s*', '', input_text)
diff --git a/backend/app/services/agent/agents/verification.py b/backend/app/services/agent/agents/verification.py
index 9a0de82..9bab09e 100644
--- a/backend/app/services/agent/agents/verification.py
+++ b/backend/app/services/agent/agents/verification.py
@@ -223,6 +223,29 @@ Thought: [总结验证结果]
Final Answer: [JSON 格式的验证报告]
```
+## ⚠️ 输出格式要求(严格遵守)
+
+**禁止使用 Markdown 格式标记!** 你的输出必须是纯文本格式:
+
+✅ 正确格式:
+```
+Thought: 我需要读取 search.php 文件来验证 SQL 注入漏洞。
+Action: read_file
+Action Input: {"file_path": "search.php"}
+```
+
+❌ 错误格式(禁止使用):
+```
+**Thought:** 我需要读取文件
+**Action:** read_file
+**Action Input:** {"file_path": "search.php"}
+```
+
+规则:
+1. 不要在 Thought:、Action:、Action Input:、Final Answer: 前后添加 `**`
+2. 不要使用其他 Markdown 格式(如 `###`、`*斜体*` 等)
+3. Action Input 必须是完整的 JSON 对象,不能为空或截断
+
## Final Answer 格式
```json
{
@@ -323,13 +346,21 @@ class VerificationAgent(BaseAgent):
"""解析 LLM 响应 - 增强版,更健壮地提取思考内容"""
step = VerificationStep(thought="")
+ # 🔥 v2.1: 预处理 - 移除 Markdown 格式标记(LLM 有时会输出 **Action:** 而非 Action:)
+ cleaned_response = response
+ cleaned_response = re.sub(r'\*\*Action:\*\*', 'Action:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Action Input:\*\*', 'Action Input:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Thought:\*\*', 'Thought:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Final Answer:\*\*', 'Final Answer:', cleaned_response)
+ cleaned_response = re.sub(r'\*\*Observation:\*\*', 'Observation:', cleaned_response)
+
# 🔥 首先尝试提取明确的 Thought 标记
- thought_match = re.search(r'Thought:\s*(.*?)(?=Action:|Final Answer:|$)', response, re.DOTALL)
+ thought_match = re.search(r'Thought:\s*(.*?)(?=Action:|Final Answer:|$)', cleaned_response, re.DOTALL)
if thought_match:
step.thought = thought_match.group(1).strip()
# 🔥 检查是否是最终答案
- final_match = re.search(r'Final Answer:\s*(.*?)$', response, re.DOTALL)
+ final_match = re.search(r'Final Answer:\s*(.*?)$', cleaned_response, re.DOTALL)
if final_match:
step.is_final = True
answer_text = final_match.group(1).strip()
@@ -349,7 +380,7 @@ class VerificationAgent(BaseAgent):
# 🔥 如果没有提取到 thought,使用 Final Answer 前的内容作为思考
if not step.thought:
- before_final = response[:response.find('Final Answer:')].strip()
+ before_final = cleaned_response[:cleaned_response.find('Final Answer:')].strip()
if before_final:
before_final = re.sub(r'^Thought:\s*', '', before_final)
step.thought = before_final[:500] if len(before_final) > 500 else before_final
@@ -357,30 +388,40 @@ class VerificationAgent(BaseAgent):
return step
# 🔥 提取 Action
- action_match = re.search(r'Action:\s*(\w+)', response)
+ action_match = re.search(r'Action:\s*(\w+)', cleaned_response)
if action_match:
step.action = action_match.group(1).strip()
# 🔥 如果没有提取到 thought,提取 Action 之前的内容作为思考
if not step.thought:
- action_pos = response.find('Action:')
+ action_pos = cleaned_response.find('Action:')
if action_pos > 0:
- before_action = response[:action_pos].strip()
+ before_action = cleaned_response[:action_pos].strip()
before_action = re.sub(r'^Thought:\s*', '', before_action)
if before_action:
step.thought = before_action[:500] if len(before_action) > 500 else before_action
- # 🔥 提取 Action Input
- input_match = re.search(r'Action Input:\s*(.*?)(?=Thought:|Action:|Observation:|$)', response, re.DOTALL)
+ # 🔥 提取 Action Input - 增强版,处理多种格式
+ input_match = re.search(r'Action Input:\s*(.*?)(?=Thought:|Action:|Observation:|$)', cleaned_response, re.DOTALL)
if input_match:
input_text = input_match.group(1).strip()
input_text = re.sub(r'```json\s*', '', input_text)
input_text = re.sub(r'```\s*', '', input_text)
- # 使用增强的 JSON 解析器
- step.action_input = AgentJsonParser.parse(
- input_text,
- default={"raw_input": input_text}
- )
+
+ # 🔥 v2.1: 如果 Action Input 为空或只有 **,记录警告
+ if not input_text or input_text == '**' or input_text.strip() == '':
+ logger.warning(f"[Verification] Action Input is empty or malformed: '{input_text}'")
+ step.action_input = {}
+ else:
+ # 使用增强的 JSON 解析器
+ step.action_input = AgentJsonParser.parse(
+ input_text,
+ default={"raw_input": input_text}
+ )
+ elif step.action:
+ # 🔥 v2.1: 有 Action 但没有 Action Input,记录警告
+ logger.warning(f"[Verification] Action '{step.action}' found but no Action Input")
+ step.action_input = {}
# 🔥 最后的 fallback:如果整个响应没有任何标记,整体作为思考
if not step.thought and not step.action and not step.is_final:
diff --git a/backend/app/services/agent/graph/runner.py b/backend/app/services/agent/graph/runner.py
index 031cad3..e0afd12 100644
--- a/backend/app/services/agent/graph/runner.py
+++ b/backend/app/services/agent/graph/runner.py
@@ -331,8 +331,8 @@ class AgentRunner:
self.verification_tools = {
**base_tools,
# 验证工具 - 移除旧的 vulnerability_validation 和 dataflow_analysis,强制使用沙箱
- # 🔥 新增:漏洞报告工具(仅Verification可用)
- "create_vulnerability_report": CreateVulnerabilityReportTool(),
+ # 🔥 新增:漏洞报告工具(仅Verification可用)- v2.1: 传递 project_root
+ "create_vulnerability_report": CreateVulnerabilityReportTool(self.project_root),
# 🔥 新增:反思工具
"reflect": ReflectTool(),
}
diff --git a/backend/app/services/agent/prompts/__init__.py b/backend/app/services/agent/prompts/__init__.py
index 975b837..73d611c 100644
--- a/backend/app/services/agent/prompts/__init__.py
+++ b/backend/app/services/agent/prompts/__init__.py
@@ -216,6 +216,7 @@ def build_specialized_prompt(
# 导入系统提示词
from .system_prompts import (
CORE_SECURITY_PRINCIPLES,
+ FILE_VALIDATION_RULES, # 🔥 v2.1
VULNERABILITY_PRIORITIES,
TOOL_USAGE_GUIDE,
MULTI_AGENT_RULES,
@@ -234,6 +235,7 @@ __all__ = [
"build_specialized_prompt",
# 系统提示词
"CORE_SECURITY_PRINCIPLES",
+ "FILE_VALIDATION_RULES", # 🔥 v2.1
"VULNERABILITY_PRIORITIES",
"TOOL_USAGE_GUIDE",
"MULTI_AGENT_RULES",
diff --git a/backend/app/services/agent/prompts/system_prompts.py b/backend/app/services/agent/prompts/system_prompts.py
index 5ec4fcc..0c387a2 100644
--- a/backend/app/services/agent/prompts/system_prompts.py
+++ b/backend/app/services/agent/prompts/system_prompts.py
@@ -36,6 +36,60 @@ CORE_SECURITY_PRINCIPLES = """
"""
+# 🔥 v2.1: 文件路径验证规则 - 防止幻觉
+FILE_VALIDATION_RULES = """
+
+## 🔒 文件路径验证规则(强制执行)
+
+### ⚠️ 严禁幻觉行为
+
+在报告任何漏洞之前,你**必须**遵守以下规则:
+
+1. **先验证文件存在**
+ - 在报告漏洞前,必须使用 `read_file` 或 `list_files` 工具确认文件存在
+ - 禁止基于"典型项目结构"或"常见框架模式"猜测文件路径
+ - 禁止假设 `config/database.py`、`app/api.py` 等文件存在
+
+2. **引用真实代码**
+ - `code_snippet` 必须来自 `read_file` 工具的实际输出
+ - 禁止凭记忆或推测编造代码片段
+ - 行号必须在文件实际行数范围内
+
+3. **验证行号准确性**
+ - 报告的 `line_start` 和 `line_end` 必须基于实际读取的文件
+ - 如果不确定行号,使用 `read_file` 重新确认
+
+4. **匹配项目技术栈**
+ - Rust 项目不会有 `.py` 文件(除非明确存在)
+ - 前端项目不会有后端数据库配置
+ - 仔细观察 Recon Agent 返回的技术栈信息
+
+### ✅ 正确做法示例
+
+```
+# 错误 ❌:直接报告未验证的文件
+Action: create_vulnerability_report
+Action Input: {"file_path": "config/database.py", ...}
+
+# 正确 ✅:先读取验证,再报告
+Action: read_file
+Action Input: {"file_path": "config/database.py"}
+# 如果文件存在且包含漏洞代码,再报告
+Action: create_vulnerability_report
+Action Input: {"file_path": "config/database.py", "code_snippet": "实际读取的代码", ...}
+```
+
+### 🚫 违规后果
+
+如果报告的文件路径不存在,系统会:
+1. 拒绝创建漏洞报告
+2. 记录违规行为
+3. 要求重新验证
+
+**记住:宁可漏报,不可误报。质量优于数量。**
+
+"""
+
# 漏洞优先级和检测策略
VULNERABILITY_PRIORITIES = """
@@ -313,6 +367,7 @@ def build_enhanced_prompt(
include_principles: bool = True,
include_priorities: bool = True,
include_tools: bool = True,
+ include_validation: bool = True, # 🔥 v2.1: 默认包含文件验证规则
) -> str:
"""
构建增强的提示词
@@ -322,6 +377,7 @@ def build_enhanced_prompt(
include_principles: 是否包含核心原则
include_priorities: 是否包含漏洞优先级
include_tools: 是否包含工具指南
+ include_validation: 是否包含文件验证规则
Returns:
增强后的提示词
@@ -331,6 +387,10 @@ def build_enhanced_prompt(
if include_principles:
parts.append(CORE_SECURITY_PRINCIPLES)
+ # 🔥 v2.1: 添加文件验证规则
+ if include_validation:
+ parts.append(FILE_VALIDATION_RULES)
+
if include_priorities:
parts.append(VULNERABILITY_PRIORITIES)
@@ -342,6 +402,7 @@ def build_enhanced_prompt(
__all__ = [
"CORE_SECURITY_PRINCIPLES",
+ "FILE_VALIDATION_RULES", # 🔥 v2.1
"VULNERABILITY_PRIORITIES",
"TOOL_USAGE_GUIDE",
"MULTI_AGENT_RULES",
diff --git a/backend/app/services/agent/tools/reporting_tool.py b/backend/app/services/agent/tools/reporting_tool.py
index d04f72e..e6ec944 100644
--- a/backend/app/services/agent/tools/reporting_tool.py
+++ b/backend/app/services/agent/tools/reporting_tool.py
@@ -5,6 +5,7 @@
"""
import logging
+import os
import uuid
from datetime import datetime, timezone
from typing import Optional, List, Dict, Any
@@ -44,20 +45,23 @@ class VulnerabilityReportInput(BaseModel):
class CreateVulnerabilityReportTool(AgentTool):
"""
创建漏洞报告工具
-
+
这是正式记录漏洞的唯一方式。只有通过这个工具创建的漏洞才会被计入最终报告。
这个设计确保了漏洞报告的规范性和完整性。
-
+
通常只有专门的报告Agent或验证Agent才会调用这个工具,
确保漏洞在被正式报告之前已经经过了充分的验证。
+
+ 🔥 v2.1: 添加文件路径验证,拒绝报告不存在的文件
"""
-
+
# 存储所有报告的漏洞
_vulnerability_reports: List[Dict[str, Any]] = []
-
- def __init__(self):
+
+ def __init__(self, project_root: Optional[str] = None):
super().__init__()
self._reports: List[Dict[str, Any]] = []
+ self.project_root = project_root # 🔥 v2.1: 用于文件验证
@property
def name(self) -> str:
@@ -125,7 +129,23 @@ class CreateVulnerabilityReportTool(AgentTool):
if not file_path or not file_path.strip():
return ToolResult(success=False, error="文件路径不能为空")
-
+
+ # 🔥 v2.1: 验证文件路径存在性 - 防止幻觉
+ if self.project_root:
+ # 清理路径(移除可能的行号,如 "app.py:36")
+ clean_path = file_path.split(":")[0].strip() if ":" in file_path else file_path.strip()
+ full_path = os.path.join(self.project_root, clean_path)
+
+ if not os.path.isfile(full_path):
+ # 尝试作为绝对路径
+ if not (os.path.isabs(clean_path) and os.path.isfile(clean_path)):
+ logger.warning(f"[ReportTool] 🚫 拒绝报告: 文件不存在 '{file_path}'")
+ return ToolResult(
+ success=False,
+ error=f"无法创建报告:文件 '{file_path}' 在项目中不存在。"
+ f"请先使用 read_file 工具验证文件存在,然后再报告漏洞。"
+ )
+
# 验证严重程度
valid_severities = ["critical", "high", "medium", "low", "info"]
severity = severity.lower()