Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions backend/alembic/versions/009_add_verdict_to_agent_findings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Add verdict column to agent_findings

Revision ID: 009_add_verdict_to_agent_findings
Revises: 008_add_files_with_findings
Create Date: 2026-05-04

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '009_add_verdict_to_agent_findings'
down_revision = '008_add_files_with_findings'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column('agent_findings', sa.Column('verdict', sa.String(length=30), nullable=True))
op.create_index('ix_agent_findings_verdict', 'agent_findings', ['verdict'])


def downgrade() -> None:
op.drop_index('ix_agent_findings_verdict', table_name='agent_findings')
op.drop_column('agent_findings', 'verdict')
76 changes: 49 additions & 27 deletions backend/app/api/v1/endpoints/agent_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ class AgentFindingResponse(BaseModel):
status: str

suggestion: Optional[str] = None
verdict: Optional[str] = None
poc: Optional[dict] = None

created_at: datetime
Expand Down Expand Up @@ -566,35 +567,43 @@ def check_global_cancel():
# files_with_findings = 有漏洞发现的唯一文件数
task.analyzed_files = task.total_files # Agent 扫描了所有符合条件的文件

# 🔥 FIX: 先过滤 findings,再用过滤后的列表做统计
# 与 _save_findings 的过滤逻辑保持一致(排除无 file_path 的 finding)
filtered_findings = []
files_with_findings_set = set()
for f in findings:
if isinstance(f, dict):
file_path = f.get("file_path") or f.get("file") or f.get("location", "").split(":")[0]
raw_file_path = f.get("file_path") or f.get("file")
file_path = raw_file_path if isinstance(raw_file_path, str) else ""
if not file_path:
raw_location = f.get("location", "")
location = raw_location if isinstance(raw_location, str) else ""
file_path = location.split(":")[0]
if file_path:
files_with_findings_set.add(file_path)
filtered_findings.append(f)
task.files_with_findings = len(files_with_findings_set)

# 统计严重程度和验证状态
# 统计严重程度和验证状态(使用过滤后的列表)
verified_count = 0
for f in findings:
if isinstance(f, dict):
sev = str(f.get("severity", "low")).lower()
if sev == "critical":
task.critical_count += 1
elif sev == "high":
task.high_count += 1
elif sev == "medium":
task.medium_count += 1
elif sev == "low":
task.low_count += 1
# 🔥 统计已验证的发现
if f.get("is_verified") or f.get("verdict") == "confirmed":
verified_count += 1
for f in filtered_findings:
sev = str(f.get("severity", "low")).lower()
if sev == "critical":
task.critical_count += 1
elif sev == "high":
task.high_count += 1
elif sev == "medium":
task.medium_count += 1
elif sev == "low":
task.low_count += 1
# 🔥 统计已验证的发现
if f.get("is_verified") or f.get("verdict") == "confirmed":
verified_count += 1
task.verified_count = verified_count

# 计算安全评分
task.security_score = _calculate_security_score(findings)
task.quality_score = _calculate_security_score(findings)
# 计算安全评分(使用过滤后的列表)
task.security_score = _calculate_security_score(filtered_findings)
task.quality_score = _calculate_security_score(filtered_findings)
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
# 🔥 注意: progress_percentage 是计算属性,不需要手动设置
# 当 status = COMPLETED 时会自动返回 100.0

Expand Down Expand Up @@ -1261,11 +1270,20 @@ async def _save_findings(
type_enum = VulnerabilityType.DESERIALIZATION

# 🔥 Handle file path (support multiple field names)
file_path = (
finding.get("file_path") or
finding.get("file") or
finding.get("location", "").split(":")[0] if ":" in finding.get("location", "") else finding.get("location")
)
raw_file_path = finding.get("file_path") or finding.get("file")
file_path = raw_file_path if isinstance(raw_file_path, str) else ""
if not file_path:
raw_location = finding.get("location", "")
location = raw_location if isinstance(raw_location, str) else ""
file_path = location.split(":")[0] if ":" in location else location

# 🔥 v2.2: file_path 为空直接跳过
if not file_path or not file_path.strip():
logger.warning(
f"[SaveFindings] 🚫 跳过无 file_path 的 finding: "
f"title={finding.get('title', 'N/A')[:50]}, type={finding.get('vulnerability_type', '?')}"
)
continue
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

# 🔥 v2.1: 文件路径验证 - 过滤幻觉发现
if project_root and file_path:
Expand All @@ -1284,9 +1302,11 @@ async def _save_findings(

# 🔥 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", ""):
raw_location = finding.get("location", "")
location = raw_location if isinstance(raw_location, str) else ""
if not line_start and ":" in location:
try:
line_start = int(finding.get("location", "").split(":")[1])
line_start = int(location.split(":")[1])
except (ValueError, IndexError):
line_start = None

Expand Down Expand Up @@ -1336,7 +1356,8 @@ async def _save_findings(

# 🔥 Handle verification status
is_verified = finding.get("is_verified", False)
if finding.get("verdict") == "confirmed":
verdict = finding.get("verdict") # confirmed / likely / uncertain / false_positive
if verdict == "confirmed":
is_verified = True

# 🔥 Handle PoC information
Expand Down Expand Up @@ -1381,6 +1402,7 @@ async def _save_findings(
code_snippet=code_snippet[:10000] if code_snippet else None,
suggestion=suggestion[:5000] if suggestion else None,
is_verified=is_verified,
verdict=verdict, # 🔥 新增:保存 verdict 到数据库
ai_confidence=confidence, # 🔥 FIX: Use ai_confidence, not confidence
Comment on lines 1402 to 1406

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Verdict migration missing 🐞 Bug ≡ Correctness

AgentFinding 新增了 verdict 字段且保存时会写入该列,但 Alembic 迁移里 agent_findings 表定义不包含
verdict,运行时插入会失败并触发回滚,导致任务可能“完成”但 findings 实际未入库。
Agent Prompt
### Issue description
`AgentFinding.verdict` 已写入 ORM 并在保存 findings 时赋值,但数据库迁移未添加该列,导致写库失败并回滚。

### Issue Context
- 现有 `agent_findings` 表由 Alembic revision `006_add_agent_tables` 创建,未包含 `verdict`。
- `_save_findings` 构建 `AgentFinding(..., verdict=verdict)` 会在 DB schema 未升级时触发插入失败。

### Fix Focus Areas
- backend/alembic/versions/006_add_agent_tables.py[146-232]
- backend/app/models/agent_task.py[355-363]
- backend/app/api/v1/endpoints/agent_tasks.py[1345-1396]

### Suggested fix
1. 新增一个 Alembic revision:
   - `op.add_column('agent_findings', sa.Column('verdict', sa.String(length=30), nullable=True))`
   - `op.create_index('ix_agent_findings_verdict', 'agent_findings', ['verdict'])`(如需要)
2. downgrade 中对称 `drop_index/drop_column`。
3.(可选)若写库失败不应“静默成功”,考虑在 `_save_findings` commit 失败时向上抛错或将 `saved_count` 置 0,避免任务统计误导。

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

status=FindingStatus.VERIFIED if is_verified else FindingStatus.NEW,
# 🔥 Additional fields
Expand Down
1 change: 1 addition & 0 deletions backend/app/models/agent_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ class AgentFinding(Base):

# 验证信息
status = Column(String(30), default=FindingStatus.NEW, index=True)
verdict = Column(String(30), nullable=True, index=True) # confirmed / likely / uncertain / false_positive
is_verified = Column(Boolean, default=False)
verification_method = Column(Text, nullable=True)
verification_result = Column(JSON, nullable=True)
Expand Down
31 changes: 30 additions & 1 deletion backend/app/services/agent/agents/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,18 +759,41 @@ async def run(self, input_data: Dict[str, Any]) -> AgentResult:
# 标准化发现
logger.info(f"[{self.name}] Standardizing {len(all_findings)} findings")
standardized_findings = []
skipped_no_filepath = 0
for finding in all_findings:
# 确保 finding 是字典
if not isinstance(finding, dict):
logger.warning(f"Skipping invalid finding (not a dict): {finding}")
continue

# 🔥 v2.2: file_path 必填校验 - 没有 file_path 的 finding 直接拒绝
# 优先从 file_path 获取,fallback 到 file / location
file_path = finding.get("file_path") or finding.get("file") or ""
if not file_path.strip() and finding.get("location"):
loc = finding.get("location", "")
if isinstance(loc, str):
file_path = loc.split(":")[0] if ":" in loc else loc
else:
# location 是非字符串类型(dict/list 等),跳过
logger.warning(
f"[Analysis] 🚫 跳过 location 非字符串的 finding: "
f"location type={type(loc).__name__}, title={finding.get('title', '?')[:50]}"
)
continue
if not file_path.strip():
skipped_no_filepath += 1
logger.warning(
f"[Analysis] 🚫 跳过无 file_path 的 finding: "
f"title={finding.get('title', '?')[:50]}, type={finding.get('vulnerability_type', '?')}"
)
continue
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

standardized = {
"vulnerability_type": finding.get("vulnerability_type", "other"),
"severity": finding.get("severity", "medium"),
"title": finding.get("title", "Unknown Finding"),
"description": finding.get("description", ""),
"file_path": finding.get("file_path", ""),
"file_path": file_path,
"line_start": finding.get("line_start") or finding.get("line", 0),
"code_snippet": finding.get("code_snippet", ""),
"source": finding.get("source", ""),
Expand All @@ -781,6 +804,12 @@ async def run(self, input_data: Dict[str, Any]) -> AgentResult:
}
standardized_findings.append(standardized)

if skipped_no_filepath > 0:
logger.warning(
f"[Analysis] ⚠️ 跳过了 {skipped_no_filepath} 个无 file_path 的 findings,"
f"保留 {len(standardized_findings)} 个有效 findings"
)

await self.emit_event(
"info",
f"Analysis Agent 完成: {len(standardized_findings)} 个发现, {self._iteration} 轮迭代, {self._tool_calls} 次工具调用"
Expand Down
26 changes: 25 additions & 1 deletion backend/app/services/agent/agents/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -878,10 +878,11 @@ async def run_with_cancel_check():
potential_file = parts[0].strip()
# 只有当 parts[0] 看起来像文件路径时才提取
# 文件路径通常包含 . 且没有空格(或只在结尾有扩展名)
# 🔥 FIX: 放宽文件路径校验 - 不再限制扩展名,只要像文件路径就提取
if ("." in potential_file and
" " not in potential_file and
len(potential_file) < 100 and
any(potential_file.endswith(ext) for ext in ['.py', '.js', '.ts', '.java', '.go', '.php', '.rb', '.c', '.cpp', '.h'])):
not potential_file.endswith("/")):
file_path = potential_file
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
# 尝试提取行号
if len(parts) > 1:
Expand Down Expand Up @@ -932,6 +933,8 @@ async def run_with_cancel_check():
for new_f in valid_findings:
# Normalize the finding first
normalized_new = self._normalize_finding(new_f)
if not normalized_new:
continue

# Create fingerprint for deduplication (file + description similarity)
new_file = normalized_new.get("file_path", "").lower().strip()
Expand Down Expand Up @@ -962,7 +965,28 @@ async def run_with_cancel_check():
(new_type in existing_type) or (existing_type in new_type)
)

# 🔥 Match criteria for same-file findings
if same_file and (same_line or similar_desc or same_type):
match_found = True
elif same_type and same_line and not same_file:
# 🔥 FIX: Only allow cross-file matching when:
# 1. new_file is garbage ("?"/empty) and descriptions still match
# 2. One path is a prefix of the other ("src/foo.py" vs "foo.py")
# Do NOT merge when existing_file is garbage - that would lose the real path.
new_is_garbage = not new_file or new_file == "?"
prefix_match = (
new_file.endswith("/" + existing_file) or
existing_file.endswith("/" + new_file)
)
if (new_is_garbage and similar_desc) or prefix_match:
match_found = True
logger.info(f"[Orchestrator] Matched by type+line despite file mismatch: {new_file} vs {existing_file}")
else:
match_found = False
else:
match_found = False

if match_found:
# Update existing with new info (e.g. verification results)
# 🔥 FIX: Smart merge - don't overwrite good data with empty values
merged = dict(existing_f) # Start with existing data
Expand Down