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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
36 changes: 34 additions & 2 deletions internal/app/mcp_authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions internal/app/template_render_test.go
Original file line number Diff line number Diff line change
@@ -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 changes: 107 additions & 0 deletions internal/app/vulnerability_retest_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
57 changes: 56 additions & 1 deletion internal/app/vulnerability_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
}
}
Expand Down Expand Up @@ -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 changes: 10 additions & 9 deletions internal/database/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 消息
Expand Down
17 changes: 15 additions & 2 deletions internal/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 5 additions & 4 deletions internal/database/vulnerability.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
Loading
Loading