mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 14:07:36 +02:00
Retesting a finding creates an independent conversation branch that
snapshots the source history and model trace, so verification can run
multi-turn without writing to the original session.
- POST /api/vulnerabilities/:id/retest creates the branch and persists the
prompt, so a lost response, navigation or refresh can resume it
- new update_vulnerability_status tool closes the loop; a retest branch may
only read and update its own finding
- {{vulnerability_id}}, {{conversation_id}} and {{source_conversation_id}}
resolve on send, in the first turn and in follow-ups
- evidence appends to a read-only vulnerabilities.retest_log column instead
of retest_notes: that field is operator-authored and the edit form
rewrites it wholesale, so a routine edit used to erase the retest trail
- the retest prompt is prefilled with the operator's retest_notes, which
already states how to verify the fix
- copied messages are re-bound as time.Time. Scanning a DATETIME column into
a string yields RFC3339Nano while AddMessage writes the driver's
space-separated format, and messages sort on the raw created_at text, so a
branch rendered its new turn above the inherited history
221 lines
8.1 KiB
Go
221 lines
8.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"cyberstrike-ai/internal/agent"
|
|
"cyberstrike-ai/internal/audit"
|
|
"cyberstrike-ai/internal/config"
|
|
"cyberstrike-ai/internal/database"
|
|
"cyberstrike-ai/internal/mcp/builtin"
|
|
"cyberstrike-ai/internal/security"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// multiAgentPrepared 多代理请求在调用 Eino 前的会话与消息准备结果。
|
|
type multiAgentPrepared struct {
|
|
ConversationID string
|
|
CreatedNew bool
|
|
History []agent.ChatMessage
|
|
FinalMessage string
|
|
RoleTools []string
|
|
AssistantMessageID string
|
|
UserMessageID string
|
|
}
|
|
|
|
func chatRequestAgentMode(req *ChatRequest, source string) string {
|
|
if strings.HasPrefix(strings.TrimSpace(source), "multi_agent") {
|
|
return config.NormalizeMultiAgentOrchestration(req.Orchestration)
|
|
}
|
|
return "eino_single"
|
|
}
|
|
|
|
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
|
|
if len(req.Attachments) > maxAttachments {
|
|
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
|
}
|
|
|
|
conversationID := strings.TrimSpace(req.ConversationID)
|
|
projectID := strings.TrimSpace(effectiveProjectID(h.config, req.ProjectID))
|
|
webshellID := strings.TrimSpace(req.WebShellConnectionID)
|
|
session, hasSession := security.CurrentSession(c)
|
|
if !hasSession || !session.Permissions["chat:write"] {
|
|
return nil, fmt.Errorf("无权写入对话")
|
|
}
|
|
canAccess := func(resourceType, resourceID string) bool {
|
|
if !hasSession || h.db == nil || strings.TrimSpace(resourceID) == "" {
|
|
return false
|
|
}
|
|
return h.db.UserCanAccessResource(session.UserID, session.Scope, resourceType, resourceID)
|
|
}
|
|
if projectID != "" && (!session.Permissions["project:read"] || !canAccess("project", projectID)) {
|
|
return nil, fmt.Errorf("无权访问目标项目")
|
|
}
|
|
if webshellID != "" && (!session.Permissions["webshell:write"] || !canAccess("webshell", webshellID)) {
|
|
return nil, fmt.Errorf("无权访问该 WebShell 连接")
|
|
}
|
|
createdNew := false
|
|
if conversationID == "" {
|
|
title := safeTruncateString(req.Message, 50)
|
|
var conv *database.Conversation
|
|
var err error
|
|
meta := audit.ConversationCreateMetaFromGin(c, source)
|
|
meta.ProjectID = projectID
|
|
meta.RoleName = req.Role
|
|
meta.AgentMode = chatRequestAgentMode(req, source)
|
|
if webshellID != "" {
|
|
meta.Source = source + "_webshell"
|
|
meta.WebShellConnectionID = webshellID
|
|
conv, err = h.db.CreateConversationWithWebshell(meta.WebShellConnectionID, title, meta)
|
|
} else {
|
|
conv, err = h.db.CreateConversation(title, meta)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("创建对话失败: %w", err)
|
|
}
|
|
conversationID = conv.ID
|
|
createdNew = true
|
|
if hasSession {
|
|
_ = h.db.SetResourceOwner("conversation", conversationID, session.UserID)
|
|
_ = h.db.AssignResourceToUser(session.UserID, "conversation", conversationID)
|
|
}
|
|
} else {
|
|
if _, err := h.db.GetConversation(conversationID); err != nil {
|
|
return nil, fmt.Errorf("对话不存在")
|
|
}
|
|
if !canAccess("conversation", conversationID) {
|
|
return nil, fmt.Errorf("无权访问该对话")
|
|
}
|
|
}
|
|
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
|
|
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
|
|
}
|
|
if err := h.db.SetConversationAgentMode(conversationID, chatRequestAgentMode(req, source)); err != nil {
|
|
h.logger.Warn("更新对话模式失败", zap.String("conversationId", conversationID), zap.String("source", source), zap.String("orchestration", req.Orchestration), zap.Error(err))
|
|
}
|
|
|
|
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
|
|
if err != nil {
|
|
historyMessages, getErr := h.db.GetMessages(conversationID)
|
|
if getErr != nil {
|
|
agentHistoryMessages = []agent.ChatMessage{}
|
|
} else {
|
|
agentHistoryMessages = dbMessagesToAgentChatMessages(historyMessages)
|
|
}
|
|
}
|
|
|
|
resolvedMessage, resolveErr := h.db.ResolveConversationPlaceholders(conversationID, req.Message)
|
|
if resolveErr != nil {
|
|
return nil, fmt.Errorf("解析对话占位符失败: %w", resolveErr)
|
|
}
|
|
req.Message = resolvedMessage
|
|
finalMessage := req.Message
|
|
var roleTools []string
|
|
if webshellID != "" {
|
|
conn, errConn := h.db.GetWebshellConnection(webshellID)
|
|
if errConn != nil || conn == nil {
|
|
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(errConn))
|
|
return nil, fmt.Errorf("未找到该 WebShell 连接")
|
|
}
|
|
webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintMultiAgent, req.Message)
|
|
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
|
|
if req.Role != "" && req.Role != "默认" && h.config != nil && h.config.Roles != nil {
|
|
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
|
|
finalMessage = role.UserPrompt + "\n\n" + webshellContext
|
|
h.logger.Info("WebShell + 角色: 应用角色提示词(多代理)", zap.String("role", req.Role))
|
|
} else {
|
|
finalMessage = webshellContext
|
|
}
|
|
} else {
|
|
finalMessage = webshellContext
|
|
}
|
|
roleTools = []string{
|
|
builtin.ToolWebshellExec,
|
|
builtin.ToolWebshellFileList,
|
|
builtin.ToolWebshellFileRead,
|
|
builtin.ToolWebshellFileWrite,
|
|
builtin.ToolRecordVulnerability,
|
|
builtin.ToolListVulnerabilities,
|
|
builtin.ToolGetVulnerability,
|
|
builtin.ToolUpdateVulnerabilityStatus,
|
|
builtin.ToolUpsertProjectFact,
|
|
builtin.ToolGetProjectFact,
|
|
builtin.ToolListProjectFacts,
|
|
builtin.ToolSearchProjectFacts,
|
|
builtin.ToolDeprecateProjectFact,
|
|
builtin.ToolRestoreProjectFact,
|
|
builtin.ToolListKnowledgeRiskTypes,
|
|
builtin.ToolSearchKnowledgeBase,
|
|
}
|
|
} else if req.Role != "" && req.Role != "默认" && h.config != nil && h.config.Roles != nil {
|
|
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
|
|
if role.UserPrompt != "" {
|
|
finalMessage = role.UserPrompt + "\n\n" + req.Message
|
|
}
|
|
roleTools = role.Tools
|
|
}
|
|
}
|
|
|
|
retest, retestErr := h.db.GetVulnerabilityRetest(conversationID)
|
|
if retestErr != nil {
|
|
return nil, fmt.Errorf("读取复测上下文失败: %w", retestErr)
|
|
}
|
|
if retest != nil && len(roleTools) > 0 {
|
|
// Retesting requires these tools even when the inherited role has an
|
|
// older tool allowlist. MCP still enforces user and finding permissions.
|
|
roleTools = append(append([]string(nil), roleTools...), builtin.ToolGetVulnerability, builtin.ToolUpdateVulnerabilityStatus)
|
|
}
|
|
|
|
finalMessage, resolveErr = h.db.ResolveConversationPlaceholders(conversationID, finalMessage)
|
|
if resolveErr != nil {
|
|
return nil, fmt.Errorf("解析提示词占位符失败: %w", resolveErr)
|
|
}
|
|
|
|
var savedPaths []string
|
|
if len(req.Attachments) > 0 {
|
|
var aerr error
|
|
savedPaths, aerr = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
|
|
if aerr != nil {
|
|
return nil, fmt.Errorf("保存上传文件失败: %w", aerr)
|
|
}
|
|
}
|
|
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths)
|
|
|
|
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
|
|
userMsgRow, uerr := h.db.AddMessage(conversationID, "user", userContent, nil)
|
|
if uerr != nil {
|
|
h.logger.Error("保存用户消息失败", zap.Error(uerr))
|
|
return nil, fmt.Errorf("保存用户消息失败: %w", uerr)
|
|
}
|
|
if retest != nil {
|
|
if err := h.db.SetVulnerabilityRetestPrompt(conversationID, ""); err != nil {
|
|
h.logger.Warn("清除已发送复测提示词失败", zap.Error(err))
|
|
}
|
|
}
|
|
userMessageID := ""
|
|
if userMsgRow != nil {
|
|
userMessageID = userMsgRow.ID
|
|
}
|
|
|
|
assistantMsg, aerr := h.db.AddMessage(conversationID, "assistant", "处理中...", nil)
|
|
var assistantMessageID string
|
|
if aerr != nil {
|
|
h.logger.Warn("创建助手消息占位失败", zap.Error(aerr))
|
|
} else if assistantMsg != nil {
|
|
assistantMessageID = assistantMsg.ID
|
|
}
|
|
|
|
return &multiAgentPrepared{
|
|
ConversationID: conversationID,
|
|
CreatedNew: createdNew,
|
|
History: agentHistoryMessages,
|
|
FinalMessage: finalMessage,
|
|
RoleTools: roleTools,
|
|
AssistantMessageID: assistantMessageID,
|
|
UserMessageID: userMessageID,
|
|
}, nil
|
|
}
|