mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-18 13:41:42 +02:00
Merge pull request #348 from Sycun/feat/vulnerability-retest
feat: add vulnerability retest branches with an isolated evidence log
This commit is contained in:
28 files changed
+1207
-38
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 消息
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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": "Ветка проверки создана. Продолжите проверку в этой ветке."
|
||||
}
|
||||
}
|
||||
@@ -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": "复测分支已创建,请在该分支继续验证。"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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._-]+/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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="关闭">×</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>
|
||||
|
||||
Reference in new issue
Block a user