From 6a67364947333db024c4a3d9afc9076b56a98848 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:21:20 +0800 Subject: [PATCH] feat: add vulnerability retest branches with an isolated evidence log 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 --- internal/app/app.go | 2 + internal/app/mcp_authorization.go | 36 +++- internal/app/template_render_test.go | 19 ++ internal/app/vulnerability_retest_test.go | 107 +++++++++ internal/app/vulnerability_tools.go | 57 ++++- internal/database/conversation.go | 19 +- internal/database/database.go | 17 +- internal/database/vulnerability.go | 9 +- internal/database/vulnerability_retest.go | 156 ++++++++++++++ .../database/vulnerability_retest_test.go | 204 ++++++++++++++++++ internal/handler/conversation.go | 5 + internal/handler/multi_agent_prepare.go | 26 +++ internal/handler/openapi.go | 1 + internal/handler/vulnerability.go | 12 +- internal/handler/vulnerability_retest.go | 86 ++++++++ internal/handler/vulnerability_retest_test.go | 136 ++++++++++++ internal/mcp/builtin/constants.go | 9 +- internal/projectprompt/blackboard.go | 4 +- web/static/i18n/en-US.json | 18 +- web/static/i18n/ru-RU.json | 18 +- web/static/i18n/zh-CN.json | 18 +- web/static/js/chat-scroll-refresh.test.cjs | 4 +- web/static/js/chat.js | 14 +- web/static/js/hitl-approval-ui.test.cjs | 2 +- web/static/js/router.js | 4 +- web/static/js/vulnerability-retest.test.cjs | 154 +++++++++++++ web/static/js/vulnerability.js | 79 +++++++ web/templates/index.html | 29 ++- 28 files changed, 1207 insertions(+), 38 deletions(-) create mode 100644 internal/app/template_render_test.go create mode 100644 internal/app/vulnerability_retest_test.go create mode 100644 internal/database/vulnerability_retest.go create mode 100644 internal/database/vulnerability_retest_test.go create mode 100644 internal/handler/vulnerability_retest.go create mode 100644 internal/handler/vulnerability_retest_test.go create mode 100644 web/static/js/vulnerability-retest.test.cjs diff --git a/internal/app/app.go b/internal/app/app.go index b8acaf40..c7ce5896 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -446,6 +446,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error authHandler.SetAudit(auditSvc) attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger) vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger) + vulnerabilityHandler.SetTaskStateProvider(agentHandler) assetHandler := handler.NewAssetHandler(db, log.Logger) projectHandler := handler.NewProjectHandler(db, log.Logger) rbacHandler := handler.NewRBACHandler(db, log.Logger) @@ -1329,6 +1330,7 @@ func setupRoutes( protected.PUT("/vulnerability-alerts/subscription", vulnerabilityHandler.UpdateMyAlertSubscription) protected.GET("/vulnerabilities/:id", vulnerabilityHandler.GetVulnerability) protected.POST("/vulnerabilities", vulnerabilityHandler.CreateVulnerability) + protected.POST("/vulnerabilities/:id/retest", vulnerabilityHandler.CreateRetest) protected.PUT("/vulnerabilities/:id", vulnerabilityHandler.UpdateVulnerability) protected.DELETE("/vulnerabilities/:id", vulnerabilityHandler.DeleteVulnerability) diff --git a/internal/app/mcp_authorization.go b/internal/app/mcp_authorization.go index 6526de7d..7862b655 100644 --- a/internal/app/mcp_authorization.go +++ b/internal/app/mcp_authorization.go @@ -82,8 +82,40 @@ func mcpToolAuthorizer(db *database.DB) func(context.Context, string, map[string return fmt.Errorf("no access to conversation %s", conversationID) } return nil - case builtin.ToolGetVulnerability: - return resource("vulnerability:read", "vulnerability", "id") + case builtin.ToolGetVulnerability, builtin.ToolUpdateVulnerabilityStatus: + permission := "vulnerability:read" + if toolName == builtin.ToolUpdateVulnerabilityStatus { + permission = "vulnerability:write" + } + if err := require(permission); err != nil { + return err + } + id := mcpAuthorizationString(args, "id") + if db == nil || id == "" || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "vulnerability", id) { + return fmt.Errorf("no access to vulnerability %s", id) + } + convID := mcpAuthorizationConversationID(ctx) + if r, err := db.GetVulnerabilityRetest(convID); err != nil { + return err + } else if r != nil { + if r.VulnerabilityID != id || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "conversation", convID) { + return fmt.Errorf("no access to retest vulnerability %s", id) + } + filter := mcpEffectiveProjectFilter(ctx, db) + if (filter == database.ProjectFilterUnbound && r.SourceProjectID != "") || (filter != "" && filter != database.ProjectFilterUnbound && filter != r.SourceProjectID) { + return fmt.Errorf("retest conversation conflicts with current project scope") + } + v, err := db.GetVulnerability(id) + if err != nil { + return err + } + projectID, err := db.GetConversationProjectID(convID) + if err != nil || !canAccessVulnerabilityForRetest(db, v, convID, projectID) { + return fmt.Errorf("retest conversation project no longer matches vulnerability %s", id) + } + return nil + } + return authorizeMCPProjectResourceBoundary(ctx, db, "vulnerability", id) case builtin.ToolQueryAssets: return require("asset:read") case builtin.ToolGetAsset: diff --git a/internal/app/template_render_test.go b/internal/app/template_render_test.go new file mode 100644 index 00000000..c09373e7 --- /dev/null +++ b/internal/app/template_render_test.go @@ -0,0 +1,19 @@ +package app + +import ( + "bytes" + "html/template" + "path/filepath" + "testing" +) + +func TestFrontendTemplatesRender(t *testing.T) { + templates, err := template.ParseGlob(filepath.Join("..", "..", "web", "templates", "*")) + if err != nil { + t.Fatalf("server template initialization fails: %v", err) + } + var output bytes.Buffer + if err = templates.ExecuteTemplate(&output, "index.html", map[string]interface{}{"Version": "test"}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/vulnerability_retest_test.go b/internal/app/vulnerability_retest_test.go new file mode 100644 index 00000000..472edd0c --- /dev/null +++ b/internal/app/vulnerability_retest_test.go @@ -0,0 +1,107 @@ +package app + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "cyberstrike-ai/internal/authctx" + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/mcp" + "cyberstrike-ai/internal/mcp/builtin" + "go.uber.org/zap" +) + +func TestVulnerabilityRetestMCPUpdatesOnlyAssignedFinding(t *testing.T) { + db, err := database.NewDB(filepath.Join(t.TempDir(), "retest.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + user, err := db.CreateRBACUser("retester", "Retester", "hash", true, nil) + if err != nil { + t.Fatal(err) + } + source, err := db.CreateConversation("source", database.ConversationCreateMeta{}) + if err != nil { + t.Fatal(err) + } + other, err := db.CreateConversation("other", database.ConversationCreateMeta{}) + if err != nil { + t.Fatal(err) + } + var findings []*database.Vulnerability + for _, cid := range []string{source.ID, source.ID, other.ID} { + v, err := db.CreateVulnerability(&database.Vulnerability{ConversationID: cid, Title: "finding", Severity: "high", Status: "open", RetestNotes: "original procedure"}) + if err != nil { + t.Fatal(err) + } + findings = append(findings, v) + if err = db.AssignResourceToUser(user.ID, "vulnerability", v.ID); err != nil { + t.Fatal(err) + } + } + branch, err := db.CreateVulnerabilityRetest(findings[0], database.ConversationCreateMeta{}) + if err != nil { + t.Fatal(err) + } + if err = db.AssignResourceToUser(user.ID, "conversation", branch.ID); err != nil { + t.Fatal(err) + } + server := mcp.NewServer(zap.NewNop()) + server.SetToolAuthorizer(mcpToolAuthorizer(db)) + registerVulnerabilityTools(server, db, zap.NewNop()) + principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{"vulnerability:read": true, "vulnerability:write": true}) + ctx := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), branch.ID), principal) + call := func(id, status, notes string) bool { + t.Helper() + result, _, err := server.CallTool(ctx, builtin.ToolUpdateVulnerabilityStatus, map[string]interface{}{"id": id, "status": status, "retest_notes": notes}) + return err == nil && result != nil && !result.IsError + } + if call(findings[0].ID, "fixed", "") || call(findings[0].ID, "invalid", "evidence") || call(findings[1].ID, "fixed", "evidence") || call(findings[2].ID, "fixed", "evidence") { + t.Fatal("invalid or unrelated update succeeded") + } + if !call(findings[0].ID, "confirmed", "still reproducible") || !call(findings[0].ID, "fixed", "now access denied with control request succeeding") { + t.Fatal("assigned finding update denied") + } + got, err := db.GetVulnerability(findings[0].ID) + if err != nil { + t.Fatal(err) + } + if got.Status != "fixed" || got.ConversationID != source.ID || got.RetestNotes != "original procedure" { + t.Fatalf("bad result: %+v", got) + } + if !strings.Contains(got.RetestLog, "still reproducible") || !strings.Contains(got.RetestLog, "now access denied") || !strings.Contains(got.RetestLog, branch.ID) { + t.Fatalf("retest evidence missing from retest_log: %q", got.RetestLog) + } + for _, v := range findings[1:] { + got, err := db.GetVulnerability(v.ID) + if err != nil || got.Status != "open" { + t.Fatalf("unrelated finding changed: %+v %v", got, err) + } + } + result, _, err := server.CallTool(ctx, builtin.ToolGetVulnerability, map[string]interface{}{"id": findings[0].ID}) + if err != nil || result.IsError { + t.Fatalf("branch cannot read source finding: %v %+v", err, result) + } + movedProject, err := db.CreateProject(&database.Project{Name: "different project"}) + if err != nil { + t.Fatal(err) + } + overrideCtx := mcp.WithMCPProjectID(ctx, movedProject.ID) + if err = mcpToolAuthorizer(db)(overrideCtx, builtin.ToolUpdateVulnerabilityStatus, map[string]interface{}{"id": findings[0].ID}); err == nil { + t.Fatal("explicit foreign project scope bypassed") + } + if err = db.SetConversationProjectID(branch.ID, movedProject.ID); err != nil { + t.Fatal(err) + } + if call(findings[0].ID, "confirmed", "after move") { + t.Fatal("moving branch bypassed project isolation") + } + readonly := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{"vulnerability:read": true}) + denied := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), branch.ID), readonly) + if err = mcpToolAuthorizer(db)(denied, builtin.ToolUpdateVulnerabilityStatus, map[string]interface{}{"id": findings[0].ID}); err == nil { + t.Fatal("read-only principal allowed mutation") + } +} diff --git a/internal/app/vulnerability_tools.go b/internal/app/vulnerability_tools.go index 54d37819..d6ca1b89 100644 --- a/internal/app/vulnerability_tools.go +++ b/internal/app/vulnerability_tools.go @@ -189,11 +189,13 @@ func registerVulnerabilityTools(mcpServer *mcp.Server, db *database.DB, logger * registerRecordVulnerabilityTool(mcpServer, db, logger) registerListVulnerabilitiesTool(mcpServer, db, logger) registerGetVulnerabilityTool(mcpServer, db, logger) + registerUpdateVulnerabilityStatusTool(mcpServer, db, logger) if logger != nil { logger.Debug("漏洞 MCP 工具注册成功", zap.Strings("tools", []string{ builtin.ToolRecordVulnerability, builtin.ToolListVulnerabilities, builtin.ToolGetVulnerability, + builtin.ToolUpdateVulnerabilityStatus, })) } } @@ -457,10 +459,63 @@ func registerGetVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, logger projectID = strings.TrimSpace(pid) } - if !canAccessVulnerability(vuln, convID, projectID) { + if !canAccessVulnerabilityForRetest(db, vuln, convID, projectID) { return textResult("错误: 无权访问该漏洞(仅可查看当前项目或当前会话下的记录)", true), nil } return textResult(formatVulnerabilityDetail(vuln), false), nil }) } + +// A retest may access its exact source record, including legacy unbound records. +func canAccessVulnerabilityForRetest(db *database.DB, v *database.Vulnerability, conversationID, projectID string) bool { + r, err := db.GetVulnerabilityRetest(conversationID) + if err != nil { + return false + } + if r != nil { + return v != nil && r.VulnerabilityID == v.ID && r.SourceProjectID == projectID && (v.ProjectID == "" || v.ProjectID == projectID) + } + return canAccessVulnerability(v, conversationID, projectID) +} + +func registerUpdateVulnerabilityStatusTool(server *mcp.Server, db *database.DB, logger *zap.Logger) { + server.RegisterTool(mcp.Tool{ + Name: builtin.ToolUpdateVulnerabilityStatus, + Description: "复测后更新已有漏洞状态并追加复测证据,不创建新漏洞。fixed 表示有证据确认修复,confirmed 表示仍可复现;无法验证时保留原状态并写明原因。仅可更新有权限的漏洞。", + ShortDescription: "更新漏洞状态与复测结果", + InputSchema: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "已有漏洞 ID"}, + "status": map[string]interface{}{"type": "string", "enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"}}, + "retest_notes": map[string]interface{}{"type": "string", "description": "本次复测方法、请求/响应或工具输出证据及结论,必填"}, + }, "required": []string{"id", "status", "retest_notes"}, + }, + }, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) { + convID := conversationIDFromToolCtx(ctx) + id, status, notes := strings.TrimSpace(strArg(args, "id")), strings.TrimSpace(strArg(args, "status")), strings.TrimSpace(strArg(args, "retest_notes")) + if convID == "" || id == "" || notes == "" { + return textResult("错误: 当前对话、id 和复测证据必需", true), nil + } + switch status { + case "open", "confirmed", "fixed", "false_positive", "ignored": + default: + return textResult("错误: 无效漏洞状态", true), nil + } + v, err := db.GetVulnerability(id) + if err != nil { + return textResult("错误: 漏洞不存在", true), nil + } + pid, err := db.GetConversationProjectID(convID) + if err != nil || !canAccessVulnerabilityForRetest(db, v, convID, strings.TrimSpace(pid)) { + return textResult("错误: 无权更新该漏洞", true), nil + } + if err = db.UpdateVulnerabilityRetestResult(id, status, notes, convID); err != nil { + return textResult("错误: 更新漏洞失败", true), nil + } + if logger != nil { + logger.Info("更新漏洞复测状态", zap.String("id", id), zap.String("status", status), zap.String("conversation_id", convID)) + } + return textResult(fmt.Sprintf("漏洞 %s 状态已更新为 %s,复测证据已保存。", id, status), false), nil + }) +} diff --git a/internal/database/conversation.go b/internal/database/conversation.go index 0770c652..5083a149 100644 --- a/internal/database/conversation.go +++ b/internal/database/conversation.go @@ -19,15 +19,16 @@ const ProjectFilterUnbound = "__none__" // Conversation 对话 type Conversation struct { - ID string `json:"id"` - Title string `json:"title"` - ProjectID string `json:"projectId,omitempty"` - RoleName string `json:"roleName,omitempty"` - AgentMode string `json:"agentMode,omitempty"` - Pinned bool `json:"pinned"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - Messages []Message `json:"messages,omitempty"` + ID string `json:"id"` + Title string `json:"title"` + ProjectID string `json:"projectId,omitempty"` + RoleName string `json:"roleName,omitempty"` + AgentMode string `json:"agentMode,omitempty"` + Pinned bool `json:"pinned"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Messages []Message `json:"messages,omitempty"` + Retest *VulnerabilityRetest `json:"retest,omitempty"` } // Message 消息 diff --git a/internal/database/database.go b/internal/database/database.go index aab1fd73..8cc2f13e 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -423,6 +423,7 @@ func (db *DB) initTables() error { impact TEXT, recommendation TEXT, retest_notes TEXT, + retest_log TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, project_id TEXT, @@ -819,6 +820,16 @@ func (db *DB) initTables() error { return fmt.Errorf("创建conversations表失败: %w", err) } + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS vulnerability_retests ( + conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE, + source_conversation_id TEXT NOT NULL, + vulnerability_id TEXT NOT NULL, + source_project_id TEXT NOT NULL DEFAULT '', + pending_prompt TEXT NOT NULL DEFAULT '' + )`); err != nil { + return fmt.Errorf("创建复测分支表失败: %w", err) + } + if _, err := db.Exec(createMessagesTable); err != nil { return fmt.Errorf("创建messages表失败: %w", err) } @@ -1495,6 +1506,7 @@ func (db *DB) migrateVulnerabilitiesConversationFK() error { impact TEXT, recommendation TEXT, retest_notes TEXT, + retest_log TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, project_id TEXT, @@ -1508,14 +1520,14 @@ func (db *DB) migrateVulnerabilitiesConversationFK() error { INSERT INTO vulnerabilities_new ( id, conversation_id, conversation_tag, task_tag, title, description, severity, status, vulnerability_type, target, preconditions, reproduction_steps, - evidence, impact, recommendation, retest_notes, + evidence, impact, recommendation, retest_notes, retest_log, created_at, updated_at, project_id ) SELECT id, conversation_id, conversation_tag, task_tag, title, description, severity, status, vulnerability_type, target, COALESCE(preconditions, ''), COALESCE(reproduction_steps, ''), - COALESCE(evidence, ''), impact, recommendation, COALESCE(retest_notes, ''), + COALESCE(evidence, ''), impact, recommendation, COALESCE(retest_notes, ''), COALESCE(retest_log, ''), created_at, updated_at, project_id FROM vulnerabilities;` if _, err := tx.Exec(copyRows); err != nil { @@ -1590,6 +1602,7 @@ func (db *DB) migrateVulnerabilitiesTable() error { {name: "reproduction_steps", stmt: "ALTER TABLE vulnerabilities ADD COLUMN reproduction_steps TEXT"}, {name: "evidence", stmt: "ALTER TABLE vulnerabilities ADD COLUMN evidence TEXT"}, {name: "retest_notes", stmt: "ALTER TABLE vulnerabilities ADD COLUMN retest_notes TEXT"}, + {name: "retest_log", stmt: "ALTER TABLE vulnerabilities ADD COLUMN retest_log TEXT NOT NULL DEFAULT ''"}, } for _, col := range columns { diff --git a/internal/database/vulnerability.go b/internal/database/vulnerability.go index 2eede0fa..b0a33da2 100644 --- a/internal/database/vulnerability.go +++ b/internal/database/vulnerability.go @@ -149,6 +149,7 @@ type Vulnerability struct { Impact string `json:"impact"` Recommendation string `json:"recommendation"` RetestNotes string `json:"retest_notes"` + RetestLog string `json:"retest_log"` // append-only retest evidence; kept out of retest_notes because the edit form rewrites that field wholesale CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -202,7 +203,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) { SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag, vulnerability_type, target, COALESCE(preconditions,''), COALESCE(reproduction_steps,''), COALESCE(evidence,''), - impact, recommendation, COALESCE(retest_notes,''), + impact, recommendation, COALESCE(retest_notes,''), COALESCE(retest_log,''), COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id, COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id, created_at, updated_at @@ -214,7 +215,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) { &vuln.ID, &vuln.ConversationID, &vuln.ProjectID, &vuln.Title, &vuln.Description, &vuln.Severity, &vuln.Status, &vuln.ConversationTag, &vuln.TaskTag, &vuln.Type, &vuln.Target, &vuln.Preconditions, &vuln.ReproSteps, &vuln.Evidence, &vuln.Impact, &vuln.Recommendation, &vuln.RetestNotes, - &vuln.TaskID, &vuln.TaskQueueID, + &vuln.RetestLog, &vuln.TaskID, &vuln.TaskQueueID, &vuln.CreatedAt, &vuln.UpdatedAt, ) if err != nil { @@ -237,7 +238,7 @@ func (db *DB) ListVulnerabilitiesForAccess(limit, offset int, filter Vulnerabili SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag, vulnerability_type, target, COALESCE(preconditions,''), COALESCE(reproduction_steps,''), COALESCE(evidence,''), - impact, recommendation, COALESCE(retest_notes,''), + impact, recommendation, COALESCE(retest_notes,''), COALESCE(retest_log,''), COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id, COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id, created_at, updated_at @@ -264,7 +265,7 @@ func (db *DB) ListVulnerabilitiesForAccess(limit, offset int, filter Vulnerabili &vuln.ID, &vuln.ConversationID, &vuln.ProjectID, &vuln.Title, &vuln.Description, &vuln.Severity, &vuln.Status, &vuln.ConversationTag, &vuln.TaskTag, &vuln.Type, &vuln.Target, &vuln.Preconditions, &vuln.ReproSteps, &vuln.Evidence, &vuln.Impact, &vuln.Recommendation, &vuln.RetestNotes, - &vuln.TaskID, &vuln.TaskQueueID, + &vuln.RetestLog, &vuln.TaskID, &vuln.TaskQueueID, &vuln.CreatedAt, &vuln.UpdatedAt, ) if err != nil { diff --git a/internal/database/vulnerability_retest.go b/internal/database/vulnerability_retest.go new file mode 100644 index 00000000..a219d3ad --- /dev/null +++ b/internal/database/vulnerability_retest.go @@ -0,0 +1,156 @@ +package database + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// VulnerabilityRetest identifies the independent snapshot used for a retest. +type VulnerabilityRetest struct { + ConversationID string `json:"conversation_id"` + SourceConversationID string `json:"source_conversation_id"` + VulnerabilityID string `json:"vulnerability_id"` + SourceProjectID string `json:"source_project_id"` + PendingPrompt string `json:"pending_prompt,omitempty"` +} + +func (db *DB) GetVulnerabilityRetest(conversationID string) (*VulnerabilityRetest, error) { + r := &VulnerabilityRetest{ConversationID: conversationID} + err := db.QueryRow(`SELECT source_conversation_id, vulnerability_id, source_project_id, pending_prompt FROM vulnerability_retests WHERE conversation_id = ?`, conversationID).Scan(&r.SourceConversationID, &r.VulnerabilityID, &r.SourceProjectID, &r.PendingPrompt) + if err == sql.ErrNoRows { + return nil, nil + } + return r, err +} + +// ResolveConversationPlaceholders replaces identifiers throughout the input, +// including follow-up turns. Unknown placeholders remain untouched. +func (db *DB) ResolveConversationPlaceholders(conversationID, input string) (string, error) { + r, err := db.GetVulnerabilityRetest(conversationID) + if err != nil { + return "", err + } + replacements := []string{"{{conversation_id}}", conversationID} + if r != nil { + replacements = append(replacements, "{{source_conversation_id}}", r.SourceConversationID, "{{vulnerability_id}}", r.VulnerabilityID) + } + return strings.NewReplacer(replacements...).Replace(input), nil +} + +// copiedTime re-binds a scanned timestamp as time.Time so copied rows use the same created_at format AddMessage writes, which the raw string ORDER BY depends on. +func copiedTime(value sql.NullString) interface{} { + if !value.Valid || strings.TrimSpace(value.String) == "" { + return nil + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05"} { + if parsed, err := time.Parse(layout, value.String); err == nil { + return parsed + } + } + return value.String +} + +// CreateVulnerabilityRetest snapshots history and model trace in one transaction. +// Each call creates a sibling; nothing is written to the source conversation. +func (db *DB) CreateVulnerabilityRetest(v *Vulnerability, meta ConversationCreateMeta) (*Conversation, error) { + tx, err := db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + id, now := uuid.NewString(), time.Now() + title := "复测:" + v.Title + result, err := tx.Exec(`INSERT INTO conversations (id, title, project_id, role_name, agent_mode, last_react_input, last_react_output, created_at, updated_at) + SELECT ?, ?, project_id, role_name, agent_mode, last_react_input, last_react_output, ?, ? FROM conversations WHERE id = ?`, id, title, now, now, v.ConversationID) + if err != nil { + return nil, err + } + n, err := result.RowsAffected() + if err != nil || n != 1 { + return nil, fmt.Errorf("原始对话不存在") + } + conv := &Conversation{ID: id, Title: title, CreatedAt: now, UpdatedAt: now} + if err = tx.QueryRow(`SELECT COALESCE(project_id,''), role_name, agent_mode FROM conversations WHERE id=?`, id).Scan(&conv.ProjectID, &conv.RoleName, &conv.AgentMode); err != nil { + return nil, err + } + // Copy visible messages using new IDs, preserving chronological order. Execution + // records belong to the source and must not be shared with a mutable branch. + rows, err := tx.Query(`SELECT role, content, reasoning_content, created_at, updated_at FROM messages WHERE conversation_id=? ORDER BY created_at, rowid`, v.ConversationID) + if err != nil { + return nil, err + } + type snapshotMessage struct { + role, content string + reasoning, created, updated sql.NullString + } + var messages []snapshotMessage + for rows.Next() { + var m snapshotMessage + if err = rows.Scan(&m.role, &m.content, &m.reasoning, &m.created, &m.updated); err != nil { + rows.Close() + return nil, err + } + messages = append(messages, m) + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + for _, m := range messages { + if _, err = tx.Exec(`INSERT INTO messages (id, conversation_id, role, content, reasoning_content, created_at, updated_at) VALUES (?,?,?,?,?,?,?)`, uuid.NewString(), id, m.role, m.content, m.reasoning, copiedTime(m.created), copiedTime(m.updated)); err != nil { + return nil, err + } + } + if _, err = tx.Exec(`INSERT INTO vulnerability_retests (conversation_id, source_conversation_id, vulnerability_id, source_project_id) VALUES (?,?,?,?)`, id, v.ConversationID, v.ID, conv.ProjectID); err != nil { + return nil, err + } + // Preserve the source's approval policy without copying live interrupts. + // HITL schema is installed by the agent handler rather than by NewDB. + var hasHITL int + if err = tx.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='hitl_conversation_configs'`).Scan(&hasHITL); err != nil { + return nil, err + } + if hasHITL != 0 { + if _, err = tx.Exec(`INSERT INTO hitl_conversation_configs (conversation_id, enabled, mode, reviewer, sensitive_tools, timeout_seconds, updated_at) + SELECT ?, enabled, mode, reviewer, sensitive_tools, timeout_seconds, ? FROM hitl_conversation_configs WHERE conversation_id=?`, id, now, v.ConversationID); err != nil { + return nil, err + } + } + if err = tx.Commit(); err != nil { + return nil, err + } + meta.ProjectID, meta.RoleName, meta.AgentMode = conv.ProjectID, conv.RoleName, conv.AgentMode + notifyConversationCreated(conv, meta) + return conv, nil +} + +// UpdateVulnerabilityRetestResult appends evidence to retest_log; retest_notes stays operator-owned. +func (db *DB) UpdateVulnerabilityRetestResult(id, status, notes, conversationID string) error { + entry := fmt.Sprintf("\n\n[复测 %s | 对话 %s | 状态 %s]\n%s", time.Now().Format(time.RFC3339), conversationID, status, notes) + result, err := db.Exec(`UPDATE vulnerabilities SET status=?, retest_log=COALESCE(retest_log,'') || ?, updated_at=? WHERE id=?`, status, entry, time.Now(), id) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return fmt.Errorf("漏洞不存在") + } + if v, err := db.GetVulnerability(id); err == nil { + db.refreshAssetRiskCacheForConversationsBestEffort(v.ConversationID) + } + return nil +} + +// A pending prompt survives a lost response, navigation or browser refresh. +func (db *DB) SetVulnerabilityRetestPrompt(conversationID, prompt string) error { + _, err := db.Exec(`UPDATE vulnerability_retests SET pending_prompt=? WHERE conversation_id=?`, prompt, conversationID) + return err +} diff --git a/internal/database/vulnerability_retest_test.go b/internal/database/vulnerability_retest_test.go new file mode 100644 index 00000000..f597e37e --- /dev/null +++ b/internal/database/vulnerability_retest_test.go @@ -0,0 +1,204 @@ +package database + +import ( + "path/filepath" + "reflect" + "strings" + "testing" + + "go.uber.org/zap" +) + +func TestVulnerabilityRetestSiblingSnapshotsAndPlaceholders(t *testing.T) { + db, err := NewDB(filepath.Join(t.TempDir(), "retest.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + project, err := db.CreateProject(&Project{Name: "retest project"}) + if err != nil { + t.Fatal(err) + } + source, err := db.CreateConversation("source", ConversationCreateMeta{ProjectID: project.ID, RoleName: "tester", AgentMode: "eino_single"}) + if err != nil { + t.Fatal(err) + } + if _, err = db.AddMessage(source.ID, "user", "original request", nil); err != nil { + t.Fatal(err) + } + if _, err = db.AddMessage(source.ID, "assistant", "three vulnerabilities", nil); err != nil { + t.Fatal(err) + } + if err = db.SaveAgentTrace(source.ID, `[{"role":"user","content":"original request"}]`, "trace output"); err != nil { + t.Fatal(err) + } + before, err := db.GetConversation(source.ID) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for i := 0; i < 3; i++ { + v, err := db.CreateVulnerability(&Vulnerability{ConversationID: source.ID, ProjectID: project.ID, Title: "finding", Severity: "high", Status: "open", RetestNotes: "operator method"}) + if err != nil { + t.Fatal(err) + } + branch, err := db.CreateVulnerabilityRetest(v, ConversationCreateMeta{Source: "test"}) + if err != nil { + t.Fatal(err) + } + if branch.ID == source.ID || seen[branch.ID] || branch.ProjectID != source.ProjectID || branch.RoleName != source.RoleName || branch.AgentMode != source.AgentMode { + t.Fatalf("invalid sibling: %+v", branch) + } + seen[branch.ID] = true + history, err := db.GetMessages(branch.ID) + if err != nil { + t.Fatal(err) + } + if len(history) != 2 || history[0].Content != "original request" || history[0].ID == before.Messages[0].ID { + t.Fatalf("invalid history: %+v", history) + } + trace, out, err := db.GetAgentTrace(branch.ID) + if err != nil || trace == "" || out != "trace output" { + t.Fatalf("trace: %s %s %v", trace, out, err) + } + resolved, err := db.ResolveConversationPlaceholders(branch.ID, "prefix {{vulnerability_id}} middle {{conversation_id}} {{source_conversation_id}} repeat {{vulnerability_id}} {{unknown}}") + if err != nil { + t.Fatal(err) + } + want := "prefix " + v.ID + " middle " + branch.ID + " " + source.ID + " repeat " + v.ID + " {{unknown}}" + if resolved != want { + t.Fatalf("resolved=%q want=%q", resolved, want) + } + if _, err = db.AddMessage(branch.ID, "user", "continue verification", nil); err != nil { + t.Fatal(err) + } + if err = db.UpdateVulnerabilityRetestResult(v.ID, "fixed", "response proves denied", branch.ID); err != nil { + t.Fatal(err) + } + got, err := db.GetVulnerability(v.ID) + if err != nil || got.Status != "fixed" || got.ConversationID != source.ID { + t.Fatalf("result: %+v %v", got, err) + } + if !strings.Contains(got.RetestLog, branch.ID) || !strings.Contains(got.RetestLog, "response proves denied") { + t.Fatalf("retest evidence missing from retest_log: %q", got.RetestLog) + } + if got.RetestNotes != "operator method" { + t.Fatalf("retest wrote into the operator-owned retest_notes: %q", got.RetestNotes) + } + } + after, err := db.GetConversation(source.ID) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("source conversation was changed by retests") + } +} + +// The edit form rewrites every column it owns in one UPDATE, which used to erase evidence kept in retest_notes. +func TestVulnerabilityRetestLogSurvivesOperatorEdit(t *testing.T) { + db, err := NewDB(filepath.Join(t.TempDir(), "edit.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + v, err := db.CreateVulnerability(&Vulnerability{Title: "finding", Severity: "high", Status: "open", RetestNotes: "operator method"}) + if err != nil { + t.Fatal(err) + } + if err = db.UpdateVulnerabilityRetestResult(v.ID, "fixed", "403 as expected", "branch-1"); err != nil { + t.Fatal(err) + } + edited, err := db.GetVulnerability(v.ID) + if err != nil { + t.Fatal(err) + } + edited.Title = "finding renamed" + edited.RetestNotes = "operator method v2" + if err = db.UpdateVulnerability(v.ID, edited); err != nil { + t.Fatal(err) + } + after, err := db.GetVulnerability(v.ID) + if err != nil { + t.Fatal(err) + } + if after.Title != "finding renamed" || after.RetestNotes != "operator method v2" { + t.Fatalf("operator edit lost: %+v", after) + } + if !strings.Contains(after.RetestLog, "403 as expected") || !strings.Contains(after.RetestLog, "branch-1") { + t.Fatalf("operator edit clobbered the retest log: %q", after.RetestLog) + } + // The detail card renders from list data, so the log has to ride along there too. + list, err := db.ListVulnerabilities(10, 0, VulnerabilityListFilter{}) + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || !strings.Contains(list[0].RetestLog, "403 as expected") { + t.Fatalf("list view is missing the retest log: %+v", list) + } +} + +func TestVulnerabilityRetestMissingSourceRollsBack(t *testing.T) { + db, err := NewDB(filepath.Join(t.TempDir(), "rollback.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err = db.CreateVulnerabilityRetest(&Vulnerability{ConversationID: "missing", ID: "finding", Title: "missing"}, ConversationCreateMeta{}); err == nil { + t.Fatal("missing source accepted") + } + var count int + if err = db.QueryRow(`SELECT COUNT(*) FROM conversations`).Scan(&count); err != nil || count != 0 { + t.Fatalf("left partial branch: %d %v", count, err) + } +} + +// Copied rows must land in the same created_at format AddMessage writes: GetMessages +// orders by a raw string sort, so a mixed format puts new turns above inherited history. +func TestVulnerabilityRetestBranchKeepsChronologicalOrder(t *testing.T) { + db, err := NewDB(filepath.Join(t.TempDir(), "order.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + source, err := db.CreateConversation("source", ConversationCreateMeta{AgentMode: "eino_single"}) + if err != nil { + t.Fatal(err) + } + if _, err = db.AddMessage(source.ID, "user", "original request", nil); err != nil { + t.Fatal(err) + } + if _, err = db.AddMessage(source.ID, "assistant", "found it", nil); err != nil { + t.Fatal(err) + } + v, err := db.CreateVulnerability(&Vulnerability{ConversationID: source.ID, Title: "finding", Severity: "high", Status: "open"}) + if err != nil { + t.Fatal(err) + } + branch, err := db.CreateVulnerabilityRetest(v, ConversationCreateMeta{Source: "test"}) + if err != nil { + t.Fatal(err) + } + if _, err = db.AddMessage(branch.ID, "user", "retest turn", nil); err != nil { + t.Fatal(err) + } + timeline, err := db.GetMessages(branch.ID) + if err != nil { + t.Fatal(err) + } + if len(timeline) != 3 { + t.Fatalf("want 3 messages, got %d", len(timeline)) + } + for i, want := range []string{"original request", "found it", "retest turn"} { + if timeline[i].Content != want { + t.Fatalf("position %d = %q, want %q", i, timeline[i].Content, want) + } + } + var formats int + if err = db.QueryRow(`SELECT COUNT(DISTINCT CASE WHEN created_at LIKE '%T%' THEN 1 ELSE 0 END) FROM messages WHERE conversation_id=?`, branch.ID).Scan(&formats); err != nil { + t.Fatal(err) + } + if formats != 1 { + t.Fatal("branch mixes created_at formats, which inverts the string ORDER BY") + } +} diff --git a/internal/handler/conversation.go b/internal/handler/conversation.go index 7db31fcf..96c6e28f 100644 --- a/internal/handler/conversation.go +++ b/internal/handler/conversation.go @@ -239,6 +239,11 @@ func (h *ConversationHandler) GetConversation(c *gin.Context) { return } + conv.Retest, err = h.db.GetVulnerabilityRetest(id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取复测分支失败"}) + return + } c.JSON(http.StatusOK, conv) } diff --git a/internal/handler/multi_agent_prepare.go b/internal/handler/multi_agent_prepare.go index 08564beb..9da5e142 100644 --- a/internal/handler/multi_agent_prepare.go +++ b/internal/handler/multi_agent_prepare.go @@ -107,6 +107,11 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context } } + 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 != "" { @@ -135,6 +140,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context builtin.ToolRecordVulnerability, builtin.ToolListVulnerabilities, builtin.ToolGetVulnerability, + builtin.ToolUpdateVulnerabilityStatus, builtin.ToolUpsertProjectFact, builtin.ToolGetProjectFact, builtin.ToolListProjectFacts, @@ -153,6 +159,21 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context } } + 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 @@ -169,6 +190,11 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context 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 diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index 9034285c..2deea5e7 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -608,6 +608,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "description": "修复建议", }, "retest_notes": map[string]interface{}{"type": "string", "description": "复测方式"}, + "retest_log": map[string]interface{}{"type": "string", "description": "复测记录(只读,由复测流程追加)"}, }, }, "UpdateVulnerabilityRequest": map[string]interface{}{ diff --git a/internal/handler/vulnerability.go b/internal/handler/vulnerability.go index ab1eef22..1436820d 100644 --- a/internal/handler/vulnerability.go +++ b/internal/handler/vulnerability.go @@ -16,9 +16,10 @@ import ( // VulnerabilityHandler 漏洞处理器 type VulnerabilityHandler struct { - db *database.DB - logger *zap.Logger - audit *audit.Service + db *database.DB + logger *zap.Logger + audit *audit.Service + taskState ConversationTaskStateProvider } // SetAudit wires platform audit logging. @@ -600,3 +601,8 @@ func sanitizeExportName(raw string) string { replacer := strings.NewReplacer("/", "-", "\\", "-", ":", "-", "*", "-", "?", "-", "\"", "-", "<", "-", ">", "-", "|", "-") return replacer.Replace(name) } + +// SetTaskStateProvider prevents snapshots of an in-flight source turn. +func (h *VulnerabilityHandler) SetTaskStateProvider(provider ConversationTaskStateProvider) { + h.taskState = provider +} diff --git a/internal/handler/vulnerability_retest.go b/internal/handler/vulnerability_retest.go new file mode 100644 index 00000000..fddd0582 --- /dev/null +++ b/internal/handler/vulnerability_retest.go @@ -0,0 +1,86 @@ +package handler + +import ( + "net/http" + "strings" + + "cyberstrike-ai/internal/audit" + "cyberstrike-ai/internal/security" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// CreateRetest creates a sibling snapshot; the chat endpoint runs the new turn. +func (h *VulnerabilityHandler) CreateRetest(c *gin.Context) { + var req struct { + Prompt string `json:"prompt" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Prompt) == "" || len(req.Prompt) > 20000 { + c.JSON(http.StatusBadRequest, gin.H{"error": "请输入复测提示词(最多 20000 字节)"}) + return + } + if _, ok := security.CurrentSession(c); !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + v, err := h.db.GetVulnerability(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "漏洞不存在"}) + return + } + source, err := h.db.GetConversation(v.ConversationID) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "原始对话已不存在,无法创建复测分支"}) + return + } + if session, ok := security.CurrentSession(c); ok { + if !security.SessionHasPermission(c, "chat:read") || !security.SessionHasPermission(c, "chat:write") || !security.SessionHasPermission(c, "agent:execute") || !security.SessionHasPermission(c, "vulnerability:write") || !h.db.UserCanAccessResource(session.UserID, session.ScopeFor("vulnerability:write"), "vulnerability", v.ID) || !h.db.UserCanAccessResource(session.UserID, session.ScopeFor("chat:read"), "conversation", source.ID) || (source.ProjectID != "" && (!security.SessionHasPermission(c, "project:read") || !h.db.UserCanAccessResource(session.UserID, session.ScopeFor("project:read"), "project", source.ProjectID))) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权创建或执行该漏洞的复测分支"}) + return + } + } + if v.ProjectID != "" && v.ProjectID != source.ProjectID { + c.JSON(http.StatusConflict, gin.H{"error": "漏洞与原始对话所属项目不一致,请先检查项目归属"}) + return + } + if h.taskState != nil { + if running, _ := h.taskState.ConversationTaskRuntimeState(source.ID); running { + c.JSON(http.StatusConflict, gin.H{"error": "原始对话仍在执行,请等待完成或停止后再复测"}) + return + } + } + meta := audit.ConversationCreateMetaFromGin(c, "vulnerability_retest") + conv, err := h.db.CreateVulnerabilityRetest(v, meta) + if err != nil { + h.logger.Error("创建复测分支失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建复测分支失败"}) + return + } + if session, ok := security.CurrentSession(c); ok { + if err = h.db.SetResourceOwner("conversation", conv.ID, session.UserID); err == nil { + err = h.db.AssignResourceToUser(session.UserID, "conversation", conv.ID) + } + if err != nil { + _ = h.db.DeleteConversation(conv.ID) + c.JSON(http.StatusInternalServerError, gin.H{"error": "设置分支权限失败"}) + return + } + } + // Continue with the user's message unchanged apart from placeholder resolution. + // Branch linkage and tool authorization are maintained by the backend. + prompt, err := h.db.ResolveConversationPlaceholders(conv.ID, strings.TrimSpace(req.Prompt)) + if err != nil { + _ = h.db.DeleteConversation(conv.ID) + c.JSON(http.StatusInternalServerError, gin.H{"error": "解析复测提示词失败"}) + return + } + if err = h.db.SetVulnerabilityRetestPrompt(conv.ID, prompt); err != nil { + _ = h.db.DeleteConversation(conv.ID) + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存复测提示词失败"}) + return + } + if h.audit != nil { + h.audit.RecordOK(c, "vulnerability", "retest", "创建漏洞复测分支", "vulnerability", v.ID, map[string]interface{}{"conversation_id": conv.ID, "source_conversation_id": source.ID}) + } + c.JSON(http.StatusOK, gin.H{"conversation": conv, "prompt": prompt, "vulnerability_id": v.ID, "source_conversation_id": source.ID}) +} diff --git a/internal/handler/vulnerability_retest_test.go b/internal/handler/vulnerability_retest_test.go new file mode 100644 index 00000000..dc873ca1 --- /dev/null +++ b/internal/handler/vulnerability_retest_test.go @@ -0,0 +1,136 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/security" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func TestCreateVulnerabilityRetestPromptAndPermissions(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := database.NewDB(filepath.Join(t.TempDir(), "retest.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + user, err := db.CreateRBACUser("retester", "Retester", "hash", true, nil) + if err != nil { + t.Fatal(err) + } + source, err := db.CreateConversation("source", database.ConversationCreateMeta{}) + if err != nil { + t.Fatal(err) + } + hitl := NewHITLManager(db, zap.NewNop()) + if err = hitl.EnsureSchema(); err != nil { + t.Fatal(err) + } + if err = hitl.SaveConversationConfig(source.ID, &HITLRequest{Enabled: true, Mode: "approval", Reviewer: "audit_agent", SensitiveTools: []string{"execute"}, TimeoutSeconds: 90}); err != nil { + t.Fatal(err) + } + v, err := db.CreateVulnerability(&database.Vulnerability{ConversationID: source.ID, Title: "finding", Severity: "high", Status: "open"}) + if err != nil { + t.Fatal(err) + } + if err = db.AssignResourceToUser(user.ID, "vulnerability", v.ID); err != nil { + t.Fatal(err) + } + h := NewVulnerabilityHandler(db, zap.NewNop()) + permissions := map[string]bool{"vulnerability:write": true, "chat:read": true, "chat:write": true, "agent:execute": true} + request := func(prompt string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]string{"prompt": prompt}) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/vulnerabilities/"+v.ID+"/retest", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "id", Value: v.ID}} + c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned, Permissions: permissions}) + h.CreateRetest(c) + return w + } + w := request("check {{vulnerability_id}} in {{conversation_id}} from {{source_conversation_id}}") + if w.Code != http.StatusForbidden { + t.Fatalf("missing source access allowed: %d %s", w.Code, w.Body.String()) + } + if err = db.AssignResourceToUser(user.ID, "conversation", source.ID); err != nil { + t.Fatal(err) + } + h.SetTaskStateProvider(staticConversationTaskState{running: true}) + if w = request("check"); w.Code != http.StatusConflict { + t.Fatalf("running source accepted: %d", w.Code) + } + h.SetTaskStateProvider(staticConversationTaskState{running: false}) + w = request("check {{vulnerability_id}} in {{conversation_id}} from {{source_conversation_id}}") + if w.Code != http.StatusOK { + t.Fatalf("retest failed: %d %s", w.Code, w.Body.String()) + } + var result struct { + Conversation database.Conversation `json:"conversation"` + Prompt string `json:"prompt"` + } + if err = json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + want := "check " + v.ID + " in " + result.Conversation.ID + " from " + source.ID + if result.Prompt != want || result.Conversation.ID == source.ID || !db.UserCanAccessResource(user.ID, database.RBACScopeAssigned, "conversation", result.Conversation.ID) { + t.Fatalf("bad response: %+v", result) + } + retest, err := db.GetVulnerabilityRetest(result.Conversation.ID) + if err != nil || retest.PendingPrompt != result.Prompt { + t.Fatalf("prompt not persisted: %+v %v", retest, err) + } + read := httptest.NewRecorder() + readCtx, _ := gin.CreateTestContext(read) + readCtx.Request = httptest.NewRequest(http.MethodGet, "/api/conversations/"+result.Conversation.ID, nil) + readCtx.Params = gin.Params{{Key: "id", Value: result.Conversation.ID}} + NewConversationHandler(db, zap.NewNop()).GetConversation(readCtx) + var recovered database.Conversation + if err = json.Unmarshal(read.Body.Bytes(), &recovered); err != nil || recovered.Retest == nil || recovered.Retest.PendingPrompt != result.Prompt { + t.Fatalf("cannot recover pending prompt: %s %v", read.Body.String(), err) + } + inherited, err := hitl.LoadConversationConfig(result.Conversation.ID) + if err != nil || !inherited.Enabled || inherited.Reviewer != "audit_agent" || inherited.Mode != "approval" || inherited.TimeoutSeconds != 90 || len(inherited.SensitiveTools) != 1 || inherited.SensitiveTools[0] != "execute" { + t.Fatalf("approval policy not inherited: %+v %v", inherited, err) + } + chatRecorder := httptest.NewRecorder() + chatCtx, _ := gin.CreateTestContext(chatRecorder) + chatCtx.Request = httptest.NewRequest(http.MethodPost, "/api/eino-agent", nil) + chatCtx.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned, Permissions: permissions}) + agentHandler := &AgentHandler{db: db, logger: zap.NewNop()} + req := &ChatRequest{ConversationID: result.Conversation.ID, Message: "continue {{vulnerability_id}} in {{conversation_id}} from {{source_conversation_id}}"} + prep, err := agentHandler.prepareMultiAgentSession(req, chatCtx, "test") + if err != nil || !strings.Contains(prep.FinalMessage, "continue "+v.ID+" in "+result.Conversation.ID+" from "+source.ID) { + t.Fatalf("follow-up placeholders failed: %+v %v", prep, err) + } + retest, err = db.GetVulnerabilityRetest(result.Conversation.ID) + if err != nil || retest.PendingPrompt != "" { + t.Fatalf("sent prompt still pending: %+v %v", retest, err) + } + defaultPrompt := "请帮我对漏洞 {{vulnerability_id}} 进行验证,确认是否已修复。完成后更新该漏洞状态。" + w = request(defaultPrompt) + if w.Code != http.StatusOK { + t.Fatalf("default retest failed: %d %s", w.Code, w.Body.String()) + } + if err = json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.Prompt != strings.ReplaceAll(defaultPrompt, "{{vulnerability_id}}", v.ID) || strings.Contains(result.Prompt, source.ID) || strings.Contains(result.Prompt, result.Conversation.ID) { + t.Fatalf("branch context injected into user prompt: %q", result.Prompt) + } + delete(permissions, "agent:execute") + if w = request("check"); w.Code != http.StatusForbidden { + t.Fatal("execution permission not enforced") + } + if w = request(" "); w.Code != http.StatusBadRequest { + t.Fatal("empty prompt allowed") + } +} diff --git a/internal/mcp/builtin/constants.go b/internal/mcp/builtin/constants.go index d38bcaae..6afe54ca 100644 --- a/internal/mcp/builtin/constants.go +++ b/internal/mcp/builtin/constants.go @@ -4,9 +4,10 @@ package builtin // 所有代码中使用内置工具名称的地方都应该使用这些常量,而不是硬编码字符串 const ( // 漏洞管理工具 - ToolRecordVulnerability = "record_vulnerability" - ToolListVulnerabilities = "list_vulnerabilities" - ToolGetVulnerability = "get_vulnerability" + ToolRecordVulnerability = "record_vulnerability" + ToolListVulnerabilities = "list_vulnerabilities" + ToolGetVulnerability = "get_vulnerability" + ToolUpdateVulnerabilityStatus = "update_vulnerability_status" // 资产管理工具 ToolCreateAsset = "create_asset" @@ -81,6 +82,7 @@ func IsBuiltinTool(toolName string) bool { case ToolRecordVulnerability, ToolListVulnerabilities, ToolGetVulnerability, + ToolUpdateVulnerabilityStatus, ToolCreateAsset, ToolGetAsset, ToolQueryAssets, @@ -142,6 +144,7 @@ func GetAllBuiltinTools() []string { ToolRecordVulnerability, ToolListVulnerabilities, ToolGetVulnerability, + ToolUpdateVulnerabilityStatus, ToolCreateAsset, ToolGetAsset, ToolQueryAssets, diff --git a/internal/projectprompt/blackboard.go b/internal/projectprompt/blackboard.go index d3e3ae76..098efa0b 100644 --- a/internal/projectprompt/blackboard.go +++ b/internal/projectprompt/blackboard.go @@ -9,7 +9,7 @@ import ( ) const ( - factRhythmCore = "勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。" + factRhythmCore = "勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。" factRhythmCoordinatorSuffix = "委派/子任务返回新认知或漏洞时,由协调者及时写入,勿假定子代理已记。" factRhythmSubAgentSuffix = "若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。" ) @@ -85,7 +85,7 @@ func FactRecordingBlackboardSection(coordinatorDelegate bool) string { b.WriteString(builtin.ToolListVulnerabilities) b.WriteString(" 查重,详情用 ") b.WriteString(builtin.ToolGetVulnerability) - b.WriteString("(id)(默认仅当前项目/会话)。\n") + b.WriteString("(id)(默认仅当前项目/会话)。复测已有漏洞后用 update_vulnerability_status(id, status, retest_notes) 更新状态并追加证据,勿重复记录;无法验证时保留原状态。\n") b.WriteString("- 同一发现可能需**各记一次**(事实记**完整攻击链与 exploit 细节**供复现,漏洞记正式 findings)。误报用 ") b.WriteString(builtin.ToolDeprecateProjectFact) b.WriteString(" 或漏洞状态 false_positive。\n") diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 7ac4dcf2..9a7e7f86 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -2905,6 +2905,7 @@ "detailImpact": "Impact", "detailRecommendation": "Remediation", "detailRetestNotes": "Retest method", + "detailRetestLog": "Retest log", "downloadOkTitle": "Downloaded", "exportFailedMessage": "Export failed", "downloadFailed": "Download failed" @@ -3870,7 +3871,8 @@ "headingEvidence": "Evidence / POC", "headingImpact": "Impact", "headingRecommendation": "Remediation", - "headingRetestNotes": "Retest method" + "headingRetestNotes": "Retest method", + "headingRetestLog": "Retest log" }, "roleModal": { "addRole": "Add role", @@ -5065,5 +5067,19 @@ "delete": "Delete execution-monitor records" } } + }, + "vulnerabilityRetest": { + "startFailed": "The retest was not sent. Its prompt is saved in the branch; return there to continue.", + "title": "Retest vulnerability", + "button": "Retest", + "hint": "Each confirmation creates an independent branch from the original conversation, preserving its project and history for follow-up turns.", + "promptLabel": "Retest prompt", + "placeholderHint": "Placeholders are replaced when sending. You can also type them in follow-up messages.", + "confirm": "Confirm and start retest", + "defaultPrompt": "Please verify whether vulnerability {{vulnerability_id}} has been fixed. Update its status when verification is complete.", + "guidanceHeading": "Verification method (from the finding's retest notes):", + "promptRequired": "Enter a retest prompt.", + "navigationChanged": "You switched conversations. Return to the retest branch to send the prompt.", + "branchCreated": "The retest branch was created. Continue verification in that branch." } } diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index d4e554de..f7bd5272 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -2918,6 +2918,7 @@ "detailImpact": "Влияние", "detailRecommendation": "Устранение", "detailRetestNotes": "Метод повторной проверки", + "detailRetestLog": "Журнал повторных проверок", "downloadOkTitle": "Скачано", "exportFailedMessage": "Экспорт не удался", "downloadFailed": "Загрузка не удалась" @@ -3882,7 +3883,8 @@ "headingEvidence": "Доказательства / POC", "headingImpact": "Влияние", "headingRecommendation": "Устранение", - "headingRetestNotes": "Метод повторной проверки" + "headingRetestNotes": "Метод повторной проверки", + "headingRetestLog": "Журнал повторных проверок" }, "roleModal": { "addRole": "Добавить роль", @@ -5077,5 +5079,19 @@ "delete": "Удалять записи монитора выполнения" } } + }, + "vulnerabilityRetest": { + "startFailed": "Проверка не отправлена. Инструкция сохранена в ветке; вернитесь туда для продолжения.", + "title": "Повторная проверка уязвимости", + "button": "Перепроверить", + "hint": "Каждое подтверждение создаёт независимую ветку исходного диалога с сохранением проекта и истории для дальнейшей проверки.", + "promptLabel": "Инструкция для проверки", + "placeholderHint": "Заполнители заменяются при отправке. Их также можно вводить в последующих сообщениях.", + "confirm": "Подтвердить и начать проверку", + "defaultPrompt": "Проверьте, исправлена ли уязвимость {{vulnerability_id}}. После завершения проверки обновите её статус.", + "guidanceHeading": "Метод проверки (из поля «Метод повторной проверки»):", + "promptRequired": "Введите инструкцию для проверки.", + "navigationChanged": "Вы переключились на другой диалог. Вернитесь в ветку проверки для отправки инструкции.", + "branchCreated": "Ветка проверки создана. Продолжите проверку в этой ветке." } } diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 1cf04332..22acd141 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -2893,6 +2893,7 @@ "detailImpact": "影响", "detailRecommendation": "修复建议", "detailRetestNotes": "复测方式", + "detailRetestLog": "复测记录", "downloadOkTitle": "下载成功", "exportFailedMessage": "导出失败", "downloadFailed": "下载失败" @@ -3858,7 +3859,8 @@ "headingEvidence": "证据 / POC", "headingImpact": "影响", "headingRecommendation": "修复建议", - "headingRetestNotes": "复测方式" + "headingRetestNotes": "复测方式", + "headingRetestLog": "复测记录" }, "roleModal": { "addRole": "添加角色", @@ -5053,5 +5055,19 @@ "delete": "删除执行监控记录" } } + }, + "vulnerabilityRetest": { + "startFailed": "复测尚未成功发送,提示词已保存在分支中,请返回该分支继续。", + "title": "复测漏洞", + "button": "复测", + "hint": "每次确认都会从原始对话创建独立分支,继承原项目和历史,可继续多轮验证。", + "promptLabel": "复测提示词", + "placeholderHint": "占位符发送时自动替换,也支持在后续对话中手动输入。", + "confirm": "确认并开始复测", + "defaultPrompt": "请帮我对漏洞 {{vulnerability_id}} 进行验证,确认是否已修复。完成后更新该漏洞状态。", + "guidanceHeading": "验证方式(来自漏洞记录的复测方式):", + "promptRequired": "请输入复测提示词。", + "navigationChanged": "已切换到其他对话,请返回复测分支继续发送。", + "branchCreated": "复测分支已创建,请在该分支继续验证。" } } diff --git a/web/static/js/chat-scroll-refresh.test.cjs b/web/static/js/chat-scroll-refresh.test.cjs index 22282e99..53cc6a75 100644 --- a/web/static/js/chat-scroll-refresh.test.cjs +++ b/web/static/js/chat-scroll-refresh.test.cjs @@ -468,8 +468,8 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状 assert.match(loadSource, /finally \{[\s\S]*?finishChatConversationRestore\(conversationId\)/); assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/); assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/); - assert.match(html, /router\.js\?v=20260907-1/); - assert.match(html, /chat\.js\?v=20260907-blocked-1/); + assert.match(html, /router\.js\?v=[A-Za-z0-9._-]+/); + assert.match(html, /chat\.js\?v=[A-Za-z0-9._-]+/); }); test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => { diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 846ef0c4..70637194 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -2245,7 +2245,7 @@ function adjustTextareaHeight(textarea) { } // 发送消息 -async function sendMessage() { +async function sendMessage(options = {}) { const input = document.getElementById('chat-input'); let message = input.value.trim(); const hasAttachments = chatAttachments && chatAttachments.length > 0; @@ -2440,6 +2440,8 @@ async function sendMessage() { throw new Error('请求失败: ' + response.status); } + if (typeof options.onRequestAccepted === 'function') options.onRequestAccepted(); + liveStreamState.conversationId = streamConversationId || null; try { const reader = response.body.getReader(); @@ -6662,6 +6664,16 @@ async function loadConversation(conversationId) { } } + // A branch may have been created while the network or navigation + // interrupted its automatic send. Restore its own persisted prompt. + const pendingRetestPrompt = conversation.retest && conversation.retest.pending_prompt; + const retestInput = document.getElementById('chat-input'); + if (seq === loadConversationRequestSeq && currentConversationId === conversationId && pendingRetestPrompt && retestInput && + (previousConversationId !== conversationId || !retestInput.value.trim())) { + retestInput.value = pendingRetestPrompt; + adjustTextareaHeight(retestInput); + } + // 页面刷新后主流式连接会中断;若该会话仍在后端运行,自动挂载 task-events 补流继续更新前端迭代进度。 const skipReplay = typeof window.shouldSkipTaskEventReplayAttach === 'function' && window.shouldSkipTaskEventReplayAttach(conversationId); diff --git a/web/static/js/hitl-approval-ui.test.cjs b/web/static/js/hitl-approval-ui.test.cjs index e218da3f..598bdf12 100644 --- a/web/static/js/hitl-approval-ui.test.cjs +++ b/web/static/js/hitl-approval-ui.test.cjs @@ -295,7 +295,7 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状 assert.match(chat, /signal: conversationLoadController\.signal/); assert.match(template, /monitor\.js\?v=20260907-blocked-1/); assert.match(template, /chat-scroll\.js\?v=20260815-1/); - assert.match(template, /chat\.js\?v=20260907-blocked-1/); + assert.match(template, /chat\.js\?v=[A-Za-z0-9._-]+/); assert.match(template, /style\.css\?v=[A-Za-z0-9._-]+/); }); diff --git a/web/static/js/router.js b/web/static/js/router.js index 6ac61a77..ddb3955d 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -95,9 +95,9 @@ function navigateToConversation(conversationId) { } if (typeof loadConversation === 'function') { - void loadConversation(cid); + return loadConversation(cid); } else if (typeof window.loadConversation === 'function') { - void window.loadConversation(cid); + return window.loadConversation(cid); } } window.navigateToConversation = navigateToConversation; diff --git a/web/static/js/vulnerability-retest.test.cjs b/web/static/js/vulnerability-retest.test.cjs new file mode 100644 index 00000000..9c79133e --- /dev/null +++ b/web/static/js/vulnerability-retest.test.cjs @@ -0,0 +1,154 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const test = require('node:test'); + +function setup() { + const source = fs.readFileSync('web/static/js/vulnerability.js', 'utf8'); + const elements = Object.fromEntries(['vulnerability-retest-prompt', 'vulnerability-retest-error', 'vulnerability-retest-confirm', 'vulnerability-retest-title'].map(id => [id, { value: '', textContent: '', disabled: false }])); + elements['vulnerability-retest-prompt'].value = 'Check {{vulnerability_id}} here {{conversation_id}}'; + elements['chat-input'] = { value: '', dispatchEvent() {} }; + const sent = [], requests = [], alerts = []; + let navigateResolve; + const navigation = new Promise(resolve => { navigateResolve = resolve; }); + const ctx = vm.createContext({ + document: { getElementById: id => elements[id], dispatchEvent() {} }, + window: { currentConversationId: 'source' }, + Event: class {}, CustomEvent: class {}, + vulnT: key => key, closeAppModal() {}, openAppModal() {}, alert: message => alerts.push(message), + apiFetch: async (url, options) => { + requests.push({url, ...options}); + return { ok: true, json: async () => ({ conversation: {id: 'branch', projectId: 'project'}, prompt: 'resolved verification for finding in branch' }) }; + }, + navigateToConversation: () => navigation, + sendMessage: async options => { sent.push({ id: ctx.window.currentConversationId, prompt: elements['chat-input'].value }); options.onRequestAccepted(); }, + }); + vm.runInContext(source.slice(source.indexOf("let vulnerabilityRetestId = '';")), ctx); + vm.runInContext("vulnerabilityRetestId = 'finding';", ctx); + return {ctx, elements, sent, requests, alerts, navigateResolve}; +} + +test('confirmation creates one branch and waits for navigation before sending', async () => { + const s = setup(); + const first = s.ctx.confirmVulnerabilityRetest(); + await s.ctx.confirmVulnerabilityRetest(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(s.requests.length, 1); + assert.equal(s.requests[0].url, '/api/vulnerabilities/finding/retest'); + assert.equal(JSON.parse(s.requests[0].body).prompt, s.elements['vulnerability-retest-prompt'].value); + assert.equal(s.sent.length, 0); + s.ctx.window.currentConversationId = 'branch'; + s.navigateResolve(); + await first; + assert.deepEqual(s.sent, [{id: 'branch', prompt: 'resolved verification for finding in branch'}]); + assert.equal(s.elements['vulnerability-retest-confirm'].disabled, false); +}); + +test('switching to another conversation prevents retest prompt being sent there', async () => { + const s = setup(); + const work = s.ctx.confirmVulnerabilityRetest(); + await new Promise(resolve => setImmediate(resolve)); + s.ctx.window.currentConversationId = 'unrelated'; + s.navigateResolve(); + await work; + assert.equal(s.sent.length, 0); + assert.equal(s.alerts.length, 1); + assert.equal(s.elements['chat-input'].value, ''); +}); + +test('an empty prompt does not create a branch', async () => { + const s = setup(); + s.elements['vulnerability-retest-prompt'].value = ' '; + await s.ctx.confirmVulnerabilityRetest(); + assert.equal(s.requests.length, 0); + assert.equal(s.elements['vulnerability-retest-error'].textContent, 'vulnerabilityRetest.promptRequired'); +}); + +test('an active retest stream does not lock out the next retest confirmation', async () => { + const s = setup(); + let finish; + const running = new Promise(resolve => { finish = resolve; }); + s.ctx.sendMessage = options => { options.onRequestAccepted(); return running; }; + s.ctx.window.currentConversationId = 'branch'; + s.navigateResolve(); + await s.ctx.confirmVulnerabilityRetest(); + await s.ctx.confirmVulnerabilityRetest(); + assert.equal(s.requests.length, 2); + finish(); +}); + + +test('slow responses from an older finding cannot overwrite the selected finding', async () => { + const s = setup(); + const pending = {}; + s.ctx.apiFetch = url => new Promise(resolve => { pending[url] = resolve; }); + const first = s.ctx.openVulnerabilityRetest('first'); + const second = s.ctx.openVulnerabilityRetest('second'); + pending['/api/vulnerabilities/second']({ok: true, json: async () => ({title: 'Second finding'})}); + await second; + pending['/api/vulnerabilities/first']({ok: true, json: async () => ({title: 'First finding'})}); + await first; + assert.equal(s.elements['vulnerability-retest-title'].textContent, 'Second finding'); + assert.equal(vm.runInContext('vulnerabilityRetestId', s.ctx), 'second'); +}); + +test('the modal stays locked through send preflight, but unlocks on HTTP acceptance', async () => { + const s = setup(); + let accept, finish; + const stream = new Promise(resolve => { finish = resolve; }); + s.ctx.sendMessage = options => { accept = options.onRequestAccepted; return stream; }; + s.ctx.window.currentConversationId = 'branch'; + s.navigateResolve(); + const work = s.ctx.confirmVulnerabilityRetest(); + await new Promise(resolve => setImmediate(resolve)); + await s.ctx.confirmVulnerabilityRetest(); + assert.equal(s.requests.length, 1); + assert.equal(s.elements['vulnerability-retest-confirm'].disabled, true); + accept(); + await work; + assert.equal(s.elements['vulnerability-retest-confirm'].disabled, false); + finish(); +}); + +test('a send cancelled before acceptance is reported with its recoverable branch ID', async () => { + const s = setup(); + s.ctx.sendMessage = async () => {}; + s.ctx.window.currentConversationId = 'branch'; + s.navigateResolve(); + await s.ctx.confirmVulnerabilityRetest(); + assert.equal(s.alerts.length, 1); + assert.match(s.alerts[0], /branch/); + assert.match(s.alerts[0], /startFailed/); +}); + +test('navigation while creating a branch does not take over the new page', async () => { + const s = setup(); + let respond; + s.ctx.window.location = {hash: '#vulnerabilities'}; + s.ctx.apiFetch = () => new Promise(resolve => { respond = resolve; }); + let navigations = 0; + s.ctx.navigateToConversation = () => { navigations++; }; + const work = s.ctx.confirmVulnerabilityRetest(); + s.ctx.window.location.hash = '#chat?conversation=other'; + respond({ok: true, json: async () => ({conversation:{id:'branch'},prompt:'saved prompt'})}); + await work; + assert.equal(navigations, 0); + assert.equal(s.sent.length, 0); + assert.match(s.alerts[0], /branch/); +}); + +test('the retest prompt carries the operator-written verification method', async () => { + const s = setup(); + s.ctx.apiFetch = () => Promise.resolve({ok: true, json: async () => ({title: 'SQLi', retest_notes: '重放登录请求,应返回 403'})}); + await s.ctx.openVulnerabilityRetest('finding-1'); + assert.equal(s.elements['vulnerability-retest-title'].textContent, 'SQLi'); + assert.equal(s.elements['vulnerability-retest-prompt'].value, + 'vulnerabilityRetest.defaultPrompt\n\nvulnerabilityRetest.guidanceHeading\n重放登录请求,应返回 403'); +}); + +test('a finding without a verification method gets the plain default prompt', async () => { + const s = setup(); + s.ctx.apiFetch = () => Promise.resolve({ok: true, json: async () => ({title: 'SQLi', retest_notes: ' '})}); + await s.ctx.openVulnerabilityRetest('finding-2'); + assert.equal(s.elements['vulnerability-retest-prompt'].value, 'vulnerabilityRetest.defaultPrompt'); +}); diff --git a/web/static/js/vulnerability.js b/web/static/js/vulnerability.js index 329f3501..5ce07554 100644 --- a/web/static/js/vulnerability.js +++ b/web/static/js/vulnerability.js @@ -1410,6 +1410,7 @@ function renderVulnerabilities(vulnerabilities, renderOptions) {
+
@@ -2223,6 +2225,10 @@ function formatVulnerabilityAsMarkdown(vuln) { markdown += `## ${L('headingRetestNotes')}\n\n${vuln.retest_notes}\n\n`; } + if (vuln.retest_log) { + markdown += `## ${L('headingRetestLog')}\n\n\`\`\`\n${String(vuln.retest_log).trim()}\n\`\`\`\n\n`; + } + return markdown; } @@ -2503,3 +2509,76 @@ window.changeVulnerabilityStatus = changeVulnerabilityStatus; window.openVulnerabilityConversation = openVulnerabilityConversation; window.loadVulnerabilityAlertSubscription = loadVulnerabilityAlertSubscription; window.saveVulnerabilityAlertSubscription = saveVulnerabilityAlertSubscription; + + +let vulnerabilityRetestId = ''; +let vulnerabilityRetestSubmitting = false; +let vulnerabilityRetestOpenSeq = 0; + +// Fold the operator's retest_notes into the prompt: that field already says how to verify the fix. +function vulnerabilityRetestPrompt(vuln) { + const base = vulnT('vulnerabilityRetest.defaultPrompt'); + const guidance = String((vuln && vuln.retest_notes) || '').trim(); + if (!guidance) return base; + return base + '\n\n' + vulnT('vulnerabilityRetest.guidanceHeading') + '\n' + guidance; +} + +async function openVulnerabilityRetest(id) { + if (vulnerabilityRetestSubmitting) return; + const seq = ++vulnerabilityRetestOpenSeq; + try { + const response = await apiFetch(`/api/vulnerabilities/${encodeURIComponent(id)}`); + const vuln = await response.json(); + if (seq !== vulnerabilityRetestOpenSeq || vulnerabilityRetestSubmitting) return; + if (!response.ok) throw new Error(vuln.error || vulnT('vulnerabilityPage.fetchFailed')); + vulnerabilityRetestId = id; + document.getElementById('vulnerability-retest-title').textContent = vuln.title; + document.getElementById('vulnerability-retest-prompt').value = vulnerabilityRetestPrompt(vuln); + document.getElementById('vulnerability-retest-error').textContent = ''; + openAppModal('vulnerability-retest-modal'); + } catch (err) { if (seq === vulnerabilityRetestOpenSeq) alert(err.message); } +} + +async function confirmVulnerabilityRetest() { + if (vulnerabilityRetestSubmitting || !vulnerabilityRetestId) return; + const prompt = document.getElementById('vulnerability-retest-prompt').value.trim(); + const error = document.getElementById('vulnerability-retest-error'); + if (!prompt) { error.textContent = vulnT('vulnerabilityRetest.promptRequired'); return; } + vulnerabilityRetestSubmitting = true; + const button = document.getElementById('vulnerability-retest-confirm'); + button.disabled = true; + let branchId = ''; + const initialHash = window.location ? window.location.hash : ''; + try { + const response = await apiFetch(`/api/vulnerabilities/${encodeURIComponent(vulnerabilityRetestId)}/retest`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt }) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || vulnT('vulnerabilityPage.fetchFailed')); + branchId = result.conversation.id; + if (window.location && window.location.hash !== initialHash) throw new Error(vulnT('vulnerabilityRetest.navigationChanged')); + closeAppModal('vulnerability-retest-modal'); + await navigateToConversation(branchId); + if (String(window.currentConversationId || '') !== branchId) throw new Error(vulnT('vulnerabilityRetest.navigationChanged')); + const input = document.getElementById('chat-input'); + input.value = result.prompt; + input.dispatchEvent(new Event('input', { bubbles: true })); + // Wait through preflight and HTTP acceptance, then release the modal + // lock while the chat continues to own the long-running SSE stream. + let accepted = false; + let resolveAccepted; + const acceptance = new Promise(resolve => { resolveAccepted = resolve; }); + const sending = sendMessage({ onRequestAccepted() { accepted = true; resolveAccepted(); } }); + await Promise.race([acceptance, sending]); + if (!accepted) throw new Error(vulnT('vulnerabilityRetest.startFailed')); + void sending.catch(err => console.error('复测对话发送失败:', err)); + if (typeof refreshChatProjectFoldersAfterAction === 'function') void refreshChatProjectFoldersAfterAction(); + if (typeof loadConversations === 'function') void loadConversations(); + } catch (err) { + error.textContent = err.message; + if (branchId) alert(vulnT('vulnerabilityRetest.branchCreated') + '\n' + branchId + '\n' + err.message); + } finally { + vulnerabilityRetestSubmitting = false; + button.disabled = false; + } +} diff --git a/web/templates/index.html b/web/templates/index.html index 17bf764d..f2f5630f 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -46,7 +46,7 @@ - + + +