mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 14:07:36 +02:00
Retesting a finding creates an independent conversation branch that
snapshots the source history and model trace, so verification can run
multi-turn without writing to the original session.
- POST /api/vulnerabilities/:id/retest creates the branch and persists the
prompt, so a lost response, navigation or refresh can resume it
- new update_vulnerability_status tool closes the loop; a retest branch may
only read and update its own finding
- {{vulnerability_id}}, {{conversation_id}} and {{source_conversation_id}}
resolve on send, in the first turn and in follow-ups
- evidence appends to a read-only vulnerabilities.retest_log column instead
of retest_notes: that field is operator-authored and the edit form
rewrites it wholesale, so a routine edit used to erase the retest trail
- the retest prompt is prefilled with the operator's retest_notes, which
already states how to verify the fix
- copied messages are re-bound as time.Time. Scanning a DATETIME column into
a string yields RFC3339Nano while AddMessage writes the driver's
space-separated format, and messages sort on the raw created_at text, so a
branch rendered its new turn above the inherited history
137 lines
6.1 KiB
Go
137 lines
6.1 KiB
Go
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")
|
|
}
|
|
}
|