Merge pull request #348 from Sycun/feat/vulnerability-retest

feat: add vulnerability retest branches with an isolated evidence log
This commit is contained in:
公明 authored and GitHub committed 2026-10-10 09:41:35 +08:00
commit e77606b58d
28 files changed
+1207 -38

No files matched your search

+2
View File
@@ -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)
+34 -2
View File
@@ -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:
+19
View File
@@ -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)
}
}
+107
View File
@@ -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")
}
}
+56 -1
View File
@@ -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
})
}
+10 -9
View File
@@ -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 消息
+15 -2
View File
@@ -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 {
+5 -4
View File
@@ -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 {
+156
View File
@@ -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
}
@@ -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")
}
}
+5
View File
@@ -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)
}
+26
View File
@@ -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
+1
View File
@@ -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{}{
+9 -3
View File
@@ -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
}
+86
View File
@@ -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})
}
@@ -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")
}
}
+6 -3
View File
@@ -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,
+2 -2
View File
@@ -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")
+17 -1
View File
@@ -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."
}
}
+17 -1
View File
@@ -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": "Ветка проверки создана. Продолжите проверку в этой ветке."
}
}
+17 -1
View File
@@ -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": "复测分支已创建,请在该分支继续验证。"
}
}
+2 -2
View File
@@ -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 并继续追加未来增量', () => {
+13 -1
View File
@@ -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);
+1 -1
View File
@@ -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._-]+/);
});
+2 -2
View File
@@ -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;
+154
View File
@@ -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');
});
+79
View File
@@ -1410,6 +1410,7 @@ function renderVulnerabilities(vulnerabilities, renderOptions) {
</div>
</div>
<div class="vulnerability-actions" onclick="event.stopPropagation();">
<button type="button" class="btn-ghost" data-require-permission="vulnerability:write" onclick="openVulnerabilityRetest(${vulnIdJs})" ${vuln.conversation_id ? '' : 'disabled'}>${escapeHtml(vulnT('vulnerabilityRetest.button'))}</button>
<button class="btn-ghost" onclick="downloadVulnerabilityAsMarkdown(${vulnIdJs}, event)" title="${dlTitle}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -1450,6 +1451,7 @@ function renderVulnerabilities(vulnerabilities, renderOptions) {
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailImpact'), vuln.impact)}
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRecommendation'), vuln.recommendation)}
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRetestNotes'), vuln.retest_notes)}
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRetestLog'), vuln.retest_log, { code: true })}
</div>
<div class="vulnerability-related-facts" id="vuln-related-facts-${escapeAttr(vuln.id)}" data-project-id="${escapeAttr(vuln.project_id || '')}" data-vuln-id="${escapeAttr(vuln.id)}" hidden></div>
</div>
@@ -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;
}
}
+26 -3
View File
@@ -46,7 +46,7 @@
<link rel="stylesheet" href="/static/vendor/xterm.css">
<!-- 移动端样式层:全部规则限定在 767.98px 以下,桌面端零影响 -->
<link rel="stylesheet" href="/static/css/mobile.css?v=20261007-glass-1">
<script src="/static/js/router.js?v=20260907-1"></script>
<script src="/static/js/router.js?v=20261009-retest-1"></script>
</head>
<body>
<div id="login-overlay" class="login-overlay" style="display: none;">
@@ -6386,6 +6386,29 @@
</div>
</div>
<div id="vulnerability-retest-modal" class="modal">
<div class="modal-content" style="max-width: 720px;">
<div class="modal-header">
<h2 data-i18n="vulnerabilityRetest.title">复测漏洞</h2>
<button type="button" class="modal-close" onclick="closeAppModal('vulnerability-retest-modal')" aria-label="关闭">&times;</button>
</div>
<div class="modal-body">
<p id="vulnerability-retest-title"></p>
<p class="form-hint" data-i18n="vulnerabilityRetest.hint">每次确认都会从原始对话创建独立分支,继承原项目和历史,可继续多轮验证。</p>
<div class="form-group">
<label for="vulnerability-retest-prompt" data-i18n="vulnerabilityRetest.promptLabel">复测提示词</label>
<textarea id="vulnerability-retest-prompt" rows="8" class="form-input"></textarea>
<p class="form-hint" data-i18n="vulnerabilityRetest.placeholderHint">占位符发送时自动替换,也支持在后续对话中手动输入。</p>
</div>
<p id="vulnerability-retest-error" role="alert"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeAppModal('vulnerability-retest-modal')" data-i18n="common.cancel">取消</button>
<button type="button" id="vulnerability-retest-confirm" class="btn-primary" onclick="confirmVulnerabilityRetest()" data-i18n="vulnerabilityRetest.confirm">确认并开始复测</button>
</div>
</div>
</div>
<!-- 漏洞编辑模态框 -->
<div id="vulnerability-modal" class="modal">
<div class="modal-content" style="max-width: 900px;">
@@ -6976,7 +6999,7 @@
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
<script src="/static/js/monitor.js?v=20260907-blocked-1"></script>
<script src="/static/js/chat.js?v=20260907-blocked-1"></script>
<script src="/static/js/chat.js?v=20261009-retest-1"></script>
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
<script src="/static/js/hitl.js?v=20260819-1"></script>
<script src="/static/js/tool-guard.js?v=20260907-7"></script>
@@ -6992,7 +7015,7 @@
<script src="/static/js/skills.js"></script>
<script src="/static/js/fact-graph.js"></script>
<script src="/static/js/projects.js?v=20260819-1"></script>
<script src="/static/js/vulnerability.js?v=14"></script>
<script src="/static/js/vulnerability.js?v=20261010-retest-2"></script>
<script src="/static/js/webshell.js?v=20260907-blocked-1"></script>
<script src="/static/js/chat-files.js"></script>
<script src="/static/js/tasks.js"></script>