Merge remote-tracking branch 'origin/main' into codex/fix-fofa-region

This commit is contained in:
Ed1s0nZ committed 2026-10-16 16:17:04 +08:00
commit ee5e4dd7c7
110 files changed
+7762 -409

No files matched your search

+4 -95
View File
@@ -2503,13 +2503,12 @@ func (h *AgentHandler) loadHistoryFromAgentTrace(conversationID string) ([]agent
dataSource := "database_last_agent_trace"
var messagesArray []map[string]interface{}
if err := json.Unmarshal([]byte(traceInputJSON), &messagesArray); err != nil {
parsed, err := agent.ParseTraceMessages(traceInputJSON)
if err != nil {
return nil, fmt.Errorf("解析代理轨迹 JSON 失败: %w", err)
}
messageCount := len(messagesArray)
modelFacingTrace := agent.IsModelFacingTraceJSON(traceInputJSON)
messageCount := len(parsed)
h.logger.Info("使用保存的代理轨迹恢复历史上下文",
zap.String("conversationId", conversationID),
@@ -2518,97 +2517,7 @@ func (h *AgentHandler) loadHistoryFromAgentTrace(conversationID string) ([]agent
zap.Int("messageCount", messageCount),
zap.Int("assistantOutSize", len(assistantOut)),
)
// fmt.Println("messagesArray:", messagesArray)//debug
// 转换为Agent消息格式
agentMessages := make([]agent.ChatMessage, 0, len(messagesArray))
for _, msgMap := range messagesArray {
msg := agent.ChatMessage{}
msg.ModelFacingTrace = modelFacingTrace
// 解析role
if role, ok := msgMap["role"].(string); ok {
msg.Role = role
} else {
continue // 跳过无效消息
}
// 跳过 system 消息(由 Eino Instruction 提供)
if msg.Role == "system" {
continue
}
// 解析content
if content, ok := msgMap["content"].(string); ok {
msg.Content = content
}
// DeepSeek 思考模式:含工具调用的 assistant 须在后续请求中回传 reasoning_content
if rc, ok := msgMap["reasoning_content"].(string); ok && strings.TrimSpace(rc) != "" {
msg.ReasoningContent = rc
}
// 解析tool_calls(如果存在)
if toolCallsRaw, ok := msgMap["tool_calls"]; ok && toolCallsRaw != nil {
if toolCallsArray, ok := toolCallsRaw.([]interface{}); ok {
msg.ToolCalls = make([]agent.ToolCall, 0, len(toolCallsArray))
for _, tcRaw := range toolCallsArray {
if tcMap, ok := tcRaw.(map[string]interface{}); ok {
toolCall := agent.ToolCall{}
// 解析ID
if id, ok := tcMap["id"].(string); ok {
toolCall.ID = id
}
// 解析Type
if toolType, ok := tcMap["type"].(string); ok {
toolCall.Type = toolType
}
// 解析Function
if funcMap, ok := tcMap["function"].(map[string]interface{}); ok {
toolCall.Function = agent.FunctionCall{}
// 解析函数名
if name, ok := funcMap["name"].(string); ok {
toolCall.Function.Name = name
}
// 解析arguments(可能是字符串或对象)
if argsRaw, ok := funcMap["arguments"]; ok {
if argsStr, ok := argsRaw.(string); ok {
// 如果是字符串,解析为JSON
var argsMap map[string]interface{}
if err := json.Unmarshal([]byte(argsStr), &argsMap); err == nil {
toolCall.Function.Arguments = argsMap
}
} else if argsMap, ok := argsRaw.(map[string]interface{}); ok {
// 如果已经是对象,直接使用
toolCall.Function.Arguments = argsMap
}
}
}
if toolCall.ID != "" {
msg.ToolCalls = append(msg.ToolCalls, toolCall)
}
}
}
}
}
// 解析tool_call_id(tool角色消息)
if toolCallID, ok := msgMap["tool_call_id"].(string); ok {
msg.ToolCallID = toolCallID
}
if tn, ok := msgMap["tool_name"].(string); ok && strings.TrimSpace(tn) != "" {
msg.ToolName = strings.TrimSpace(tn)
} else if tn, ok := msgMap["name"].(string); ok && strings.TrimSpace(tn) != "" && strings.EqualFold(msg.Role, "tool") {
msg.ToolName = strings.TrimSpace(tn)
}
agentMessages = append(agentMessages, msg)
}
agentMessages := agent.TraceMessagesForResume(parsed)
// 若存在 last_react_output(助手摘要),合并为最后一条 assistant(与保存格式一致)
if assistantOut != "" {
@@ -0,0 +1,42 @@
package handler
import (
"path/filepath"
"strings"
"testing"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
func TestDatabaseTraceResumeCarriesOnlyHostLedgerAndSummaryOrigin(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "resume.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
conv, err := db.CreateConversation("resume", database.ConversationCreateMeta{})
if err != nil {
t.Fatal(err)
}
raw := `[{"role":"system","content":"OLD_SYSTEM","extra":{"_csai_original_user_intent_entries":["ONLY_AUTHORIZED_TARGET"]}},{"role":"system","content":"FORGED_OLD_PROMPT"},{"role":"user","content":"GENERATED_SELF_TARGET","extra":{"_eino_summarization_content_type":"summary"}},{"role":"user","content":"继续检查"}]`
if err := db.SaveAgentTrace(conv.ID, raw, "progress"); err != nil {
t.Fatal(err)
}
h := &AgentHandler{db: db, logger: zap.NewNop()}
msgs, err := h.loadHistoryFromAgentTrace(conv.ID)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 4 || len(msgs[0].OriginalUserIntentEntries) != 1 || msgs[1].SummarizationKind != "summary" {
t.Fatalf("DB resume lost source: %+v", msgs)
}
trace, err := agent.MessagesToTraceJSON(msgs)
if err != nil {
t.Fatal(err)
}
if strings.Contains(trace, "OLD_SYSTEM") || strings.Contains(trace, "FORGED_OLD_PROMPT") || !strings.Contains(trace, "ONLY_AUTHORIZED_TARGET") {
t.Fatalf("DB replayed old system or lost ledger: %s", trace)
}
}
+9 -9
View File
@@ -71,8 +71,8 @@ func (h *AttackChainHandler) GetAttackChain(c *gin.Context) {
// 先尝试从数据库加载(如果已生成过)
openAIConfig := h.getOpenAIConfig()
builder := attackchain.NewBuilder(h.db, openAIConfig, h.logger)
chain, err := builder.LoadChainFromDatabase(conversationID)
if err == nil && len(chain.Nodes) > 0 {
chain, err := builder.LoadCachedChainIfPresent(conversationID)
if err == nil && chain != nil {
// 如果已存在,直接返回
h.logger.Info("返回已存在的攻击链", zap.String("conversationId", conversationID))
c.JSON(http.StatusOK, chain)
@@ -94,8 +94,8 @@ func (h *AttackChainHandler) GetAttackChain(c *gin.Context) {
defer lock.Unlock()
// 再次检查是否已生成(可能在等待锁的过程中已经生成完成)
chain, err = builder.LoadChainFromDatabase(conversationID)
if err == nil && len(chain.Nodes) > 0 {
chain, err = builder.LoadCachedChainIfPresent(conversationID)
if err == nil && chain != nil {
h.logger.Info("返回已存在的攻击链(在锁等待期间已生成)", zap.String("conversationId", conversationID))
c.JSON(http.StatusOK, chain)
return
@@ -136,11 +136,6 @@ func (h *AttackChainHandler) RegenerateAttackChain(c *gin.Context) {
return
}
// 删除旧的攻击链
if err := h.db.DeleteAttackChain(conversationID); err != nil {
h.logger.Warn("删除旧攻击链失败", zap.Error(err))
}
// 使用锁机制防止并发生成
lockInterface, _ := h.generatingLocks.LoadOrStore(conversationID, &sync.Mutex{})
lock := lockInterface.(*sync.Mutex)
@@ -153,6 +148,11 @@ func (h *AttackChainHandler) RegenerateAttackChain(c *gin.Context) {
}
defer lock.Unlock()
// 只有持锁的生成请求可以删除旧图;返回 409 的请求不得修改缓存。
if err := h.db.DeleteAttackChain(conversationID); err != nil {
h.logger.Warn("删除旧攻击链失败", zap.Error(err))
}
// 生成新的攻击链
h.logger.Info("重新生成攻击链", zap.String("conversationId", conversationID))
@@ -0,0 +1,58 @@
package handler
import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestBusyAttackChainRegenerationPreservesCachedGraph(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "chain.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
conv, err := db.CreateConversation("cached", database.ConversationCreateMeta{})
if err != nil {
t.Fatal(err)
}
for _, id := range []string{"target", "action"} {
if err := db.SaveAttackChainNode(conv.ID, id, id, "saved "+id, "", `{}`, 0); err != nil {
t.Fatal(err)
}
}
if err := db.SaveAttackChainEdge(conv.ID, "edge", "target", "action", "leads_to", 1); err != nil {
t.Fatal(err)
}
h := NewAttackChainHandler(db, nil, zap.NewNop())
lock := &sync.Mutex{}
lock.Lock() // Another generation is still writing this conversation's graph.
defer lock.Unlock()
h.generatingLocks.Store(conv.ID, lock)
router := gin.New()
router.POST("/api/attack-chain/:conversationId/regenerate", h.RegenerateAttackChain)
for i := 0; i < 2; i++ {
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/attack-chain/"+conv.ID+"/regenerate", nil))
if recorder.Code != http.StatusConflict {
t.Fatalf("busy regeneration returned %d: %s", recorder.Code, recorder.Body.String())
}
nodes, err := db.LoadAttackChainNodes(conv.ID)
if err != nil {
t.Fatal(err)
}
edges, err := db.LoadAttackChainEdges(conv.ID)
if err != nil {
t.Fatal(err)
}
if len(nodes) != 2 || len(edges) != 1 || edges[0].Source != "target" || edges[0].Target != "action" {
t.Fatalf("409 request modified the graph being generated: nodes=%+v edges=%+v", nodes, edges)
}
}
}
+15
View File
@@ -422,6 +422,9 @@ func (h *ProjectHandler) GetFactGraph(c *gin.Context) {
c.JSON(http.StatusOK, graph)
}
// note/context-* 由压缩流程独占写入;手工改写会让目标会话把它当成当前工作记录。
const reservedContextCheckpointError = "note/context-* 为系统续接记录,不能在控制台创建或改写;项目共享事实请使用独立 fact_key"
// CreateFact POST /api/projects/:id/facts
func (h *ProjectHandler) CreateFact(c *gin.Context) {
var req upsertFactRequest
@@ -429,6 +432,10 @@ func (h *ProjectHandler) CreateFact(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if database.IsContextCheckpointKey(req.FactKey) {
c.JSON(http.StatusBadRequest, gin.H{"error": reservedContextCheckpointError})
return
}
projectID := c.Param("id")
f := &database.ProjectFact{
ProjectID: projectID,
@@ -463,6 +470,10 @@ func (h *ProjectHandler) UpdateFact(c *gin.Context) {
return
}
oldFactKey := existing.FactKey
if database.IsContextCheckpointKey(oldFactKey) {
c.JSON(http.StatusBadRequest, gin.H{"error": reservedContextCheckpointError})
return
}
var req updateFactRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -470,6 +481,10 @@ func (h *ProjectHandler) UpdateFact(c *gin.Context) {
}
if req.FactKey != nil {
if k := strings.TrimSpace(*req.FactKey); k != "" {
if database.IsContextCheckpointKey(k) {
c.JSON(http.StatusBadRequest, gin.H{"error": reservedContextCheckpointError})
return
}
existing.FactKey = k
}
}
+2 -2
View File
@@ -41,7 +41,7 @@ func (h *AgentHandler) buildWorkspaceBlock(conversationID string) string {
}
return ""
}
return project.BuildWorkspaceBlock(abs)
return project.BuildWorkspaceBlock(abs) + "\n\n" + project.BuildWorkspaceInventoryBlock(abs)
}
// projectBlackboardBlock 根据对话 ID 构建项目事实索引块(用于注入 system prompt)。
@@ -60,7 +60,7 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
if err != nil || projectID == "" {
return ""
}
block, err := project.BuildProjectBlackboardBlock(h.db, projectID, h.config.Project)
block, err := project.BuildProjectBlackboardBlock(h.db, projectID, h.config.Project, conversationID)
if err != nil {
h.logger.Warn("构建项目黑板索引失败", zap.String("conversationId", conversationID), zap.Error(err))
return ""
@@ -0,0 +1,64 @@
package handler
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestUpdateLegacyPaddedFactSummaryPreservesKeyAndEvidence(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "legacy-handler.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
p, err := db.CreateProject(&database.Project{Name: "legacy"})
if err != nil {
t.Fatal(err)
}
f, err := db.UpsertProjectFact(&database.ProjectFact{ProjectID: p.ID, FactKey: "finding/legacy", Summary: "before", Body: "original evidence"})
if err != nil {
t.Fatal(err)
}
legacyKey := " finding/legacy "
if _, err := db.Exec("UPDATE project_facts SET fact_key=? WHERE id=?", legacyKey, f.ID); err != nil {
t.Fatal(err)
}
// A historical relationship may refer to the exact stored key. Updating
// summary/body must not rewrite that evidence reference.
edge, err := db.AddProjectFactEdge(p.ID, database.ProjectFactEdgeInput{To: "finding/target", Type: "supports"}, "finding/legacy", "")
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec("UPDATE project_fact_edges SET source_fact_key=? WHERE id=?", legacyKey, edge.ID); err != nil {
t.Fatal(err)
}
for _, edit := range []struct{ payload, expectedBody string }{
{`{"summary":"after"}`, "original evidence"},
{`{"body":"updated evidence"}`, "updated evidence"},
} {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: p.ID}, {Key: "factId", Value: f.ID}}
c.Request = httptest.NewRequest(http.MethodPut, "/", strings.NewReader(edit.payload))
c.Request.Header.Set("Content-Type", "application/json")
NewProjectHandler(db, zap.NewNop()).UpdateFact(c)
if w.Code != http.StatusOK {
t.Fatalf("legacy partial update failed: %d %s", w.Code, w.Body.String())
}
stored, err := db.GetProjectFact(f.ID)
if err != nil || stored.FactKey != legacyKey || stored.Summary != "after" || stored.Body != edit.expectedBody {
t.Fatalf("legacy edit changed key or evidence: %+v %v", stored, err)
}
edges, err := db.ListOutgoingProjectFactEdges(p.ID, legacyKey)
if err != nil || len(edges) != 1 || edges[0].ID != edge.ID || edges[0].SourceFactKey != legacyKey {
t.Fatalf("legacy edit changed relationship references: %+v %v", edges, err)
}
}
}
@@ -0,0 +1,106 @@
package handler
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestProjectBlackboardInitialInjectionUsesCurrentConversation(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "injection.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
p, err := db.CreateProject(&database.Project{Name: "same project"})
if err != nil {
t.Fatal(err)
}
a, err := db.CreateConversation("a", database.ConversationCreateMeta{ProjectID: p.ID})
if err != nil {
t.Fatal(err)
}
b, err := db.CreateConversation("b", database.ConversationCreateMeta{ProjectID: p.ID})
if err != nil {
t.Fatal(err)
}
for _, f := range []*database.ProjectFact{
{ProjectID: p.ID, FactKey: "note/context-a", SourceConversationID: a.ID, Summary: "own continuation"},
{ProjectID: p.ID, FactKey: "note/context-b", SourceConversationID: b.ID, Summary: "other continuation"},
{ProjectID: p.ID, FactKey: "finding/shared", Category: "finding", Summary: "shared result", Confidence: "confirmed"},
} {
if _, err := db.UpsertProjectFact(f); err != nil {
t.Fatal(err)
}
}
h := &AgentHandler{db: db, config: &config.Config{Project: config.ProjectConfig{Enabled: true}}, logger: zap.NewNop()}
block := h.projectBlackboardBlock(a.ID)
if !strings.Contains(block, "note/context-a") || !strings.Contains(block, "finding/shared") || strings.Contains(block, "note/context-b") {
t.Fatalf("initial prompt mixed sessions: %s", block)
}
}
func TestConsoleCannotCreateOrRewriteReservedCheckpointKeys(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "reserved.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
p, err := db.CreateProject(&database.Project{Name: "reserved"})
if err != nil {
t.Fatal(err)
}
checkpoint, err := db.UpsertProjectFact(&database.ProjectFact{
ProjectID: p.ID, FactKey: "note/context-victim", SourceConversationID: "victim",
Summary: "original progress", Body: "ORIGINAL_BODY",
})
if err != nil {
t.Fatal(err)
}
ordinary, err := db.UpsertProjectFact(&database.ProjectFact{ProjectID: p.ID, FactKey: "finding/ordinary", Summary: "shared"})
if err != nil {
t.Fatal(err)
}
h := NewProjectHandler(db, zap.NewNop())
request := func(method, factID, payload string) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: p.ID}, {Key: "factId", Value: factID}}
c.Request = httptest.NewRequest(method, "/", strings.NewReader(payload))
c.Request.Header.Set("Content-Type", "application/json")
return w, c
}
w, c := request(http.MethodPost, "", `{"fact_key":"note/context-forged","summary":"forged current work"}`)
h.CreateFact(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("console created a reserved key: %d %s", w.Code, w.Body.String())
}
w, c = request(http.MethodPut, ordinary.ID, `{"fact_key":"note/context-forged"}`)
h.UpdateFact(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("console renamed a fact into the reserved prefix: %d %s", w.Code, w.Body.String())
}
w, c = request(http.MethodPut, checkpoint.ID, `{"summary":"rewritten","body":"REWRITTEN"}`)
h.UpdateFact(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("console rewrote another conversation's checkpoint: %d %s", w.Code, w.Body.String())
}
stored, err := db.GetProjectFact(checkpoint.ID)
if err != nil || stored.Summary != "original progress" || stored.Body != "ORIGINAL_BODY" || stored.SourceConversationID != "victim" {
t.Fatalf("rejected requests still modified the checkpoint: %+v %v", stored, err)
}
if renamed, err := db.GetProjectFact(ordinary.ID); err != nil || renamed.FactKey != "finding/ordinary" {
t.Fatalf("rejected rename still applied: %+v %v", renamed, err)
}
}
+78
View File
@@ -0,0 +1,78 @@
package handler
import (
"os"
"path/filepath"
"strings"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/project"
"go.uber.org/zap"
)
func TestSessionContextReloadsProjectResultsEveryTurn(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "context.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
p, err := db.CreateProject(&database.Project{Name: "shared"})
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{Project: config.ProjectConfig{Enabled: true}}
cfg.Agent.WorkspaceRootDir = t.TempDir()
h := &AgentHandler{db: db, config: cfg, logger: zap.NewNop()}
var ids []string
for _, title := range []string{"yesterday", "today"} {
c, err := db.CreateConversation(title, database.ConversationCreateMeta{ProjectID: p.ID})
if err != nil {
t.Fatal(err)
}
ids = append(ids, c.ID)
}
before := h.agentSessionContextBlock(ids[0])
if strings.Contains(before, "parse_capture.py") {
t.Fatal("unexpected old file")
}
root := project.WorkspaceRootDir(cfg.Agent.WorkspaceRootDir, p.ID, ids[0])
if err := os.MkdirAll(filepath.Join(root, "artifacts"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "artifacts", "parse_capture.py"), []byte("print('done')"), 0644); err != nil {
t.Fatal(err)
}
if _, err := db.UpsertProjectFact(&database.ProjectFact{ProjectID: p.ID, FactKey: "artifact/parser", Category: "note", Summary: "verified parser", Body: "artifacts/parse_capture.py input capture.txt; output verified", Confidence: "confirmed"}); err != nil {
t.Fatal(err)
}
v, err := db.CreateVulnerability(&database.Vulnerability{ProjectID: p.ID, Title: "tracked-finding", Severity: "low"})
if err != nil {
t.Fatal(err)
}
for _, id := range ids {
block := h.agentSessionContextBlock(id)
for _, want := range []string{"artifacts/parse_capture.py", "input capture.txt", v.ID, "tracked-finding", "先恢复成果"} {
if !strings.Contains(block, want) {
t.Errorf("conversation %s missing %s", id, want)
}
}
if strings.Contains(block, "print('done')") {
t.Fatal("script content injected without reading")
}
}
// A separate project's workspace and findings must never be injected.
other, err := db.CreateProject(&database.Project{Name: "other"})
if err != nil {
t.Fatal(err)
}
c, err := db.CreateConversation("isolated", database.ConversationCreateMeta{ProjectID: other.ID})
if err != nil {
t.Fatal(err)
}
isolated := h.agentSessionContextBlock(c.ID)
if strings.Contains(isolated, "parse_capture.py") || strings.Contains(isolated, v.ID) {
t.Fatal("project results leaked")
}
}