mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-20 21:10:13 +02:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08d77bc12b | |||
| ce73a7b3e4 | |||
| f78f424aab | |||
| e19d8e39bd | |||
| ecf594a25b | |||
| d5759f6d83 | |||
| 81b3f64b15 | |||
| 0e0f1352f0 | |||
| ffba311afd | |||
| d9ed36cfb1 | |||
| b7f80b78ee | |||
| 8f8e5cfff5 | |||
| 120f860640 | |||
| 90cd119a83 | |||
| 56d597e0c5 | |||
| 11ab5cde8f | |||
| 46a7d338a4 |
@@ -112,7 +112,7 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
|||||||
- 🔒 Password-protected web UI, audit logs, and SQLite persistence
|
- 🔒 Password-protected web UI, audit logs, and SQLite persistence
|
||||||
- 📚 Knowledge base (RAG) with embedding-based vector retrieval (cosine similarity), optional **Eino Compose** indexing pipeline, and configurable post-retrieval budgets / reranking hooks
|
- 📚 Knowledge base (RAG) with embedding-based vector retrieval (cosine similarity), optional **Eino Compose** indexing pipeline, and configurable post-retrieval budgets / reranking hooks
|
||||||
- 📁 Conversation grouping with pinning, rename, and batch management
|
- 📁 Conversation grouping with pinning, rename, and batch management
|
||||||
- 📂 **Project management**: group conversations and vulnerabilities by project; **shared facts** (project blackboard) persist cross-session context (targets, env, auth notes) with auto-injection for agents and MCP tools (`upsert_project_fact`, `get_project_fact`, …)
|
- 📂 **Project management**: shared facts (blackboard) across sessions, `upsert_project_fact` + `links` to chain paths; attack-chain and project fact graph views
|
||||||
- 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics
|
- 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics
|
||||||
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
|
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
|
||||||
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
|
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
|
||||||
@@ -551,6 +551,11 @@ multi_agent:
|
|||||||
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor optional
|
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor optional
|
||||||
# eino_skills: { disable: false, filesystem_tools: true, skill_tool_name: skill }
|
# eino_skills: { disable: false, filesystem_tools: true, skill_tool_name: skill }
|
||||||
# eino_middleware: plantask_enable, checkpoint_dir, deep_model_retry_max_retries, deep_output_key, ...
|
# eino_middleware: plantask_enable, checkpoint_dir, deep_model_retry_max_retries, deep_output_key, ...
|
||||||
|
project:
|
||||||
|
enabled: true # Enable project blackboard & fact MCP tools
|
||||||
|
fact_index_max_runes: 65000
|
||||||
|
fact_summary_max_runes: 24000
|
||||||
|
default_inject_deprecated: false
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tool Definition Example (`tools/nmap.yaml`)
|
### Tool Definition Example (`tools/nmap.yaml`)
|
||||||
|
|||||||
+6
-1
@@ -111,7 +111,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
|||||||
- 🔒 Web 登录保护、审计日志、SQLite 持久化
|
- 🔒 Web 登录保护、审计日志、SQLite 持久化
|
||||||
- 📚 知识库(RAG):向量嵌入与余弦相似度检索(与 Eino `retriever.Retriever` 语义一致),可选 **Eino Compose** 索引流水线及检索后处理(预算、重排等配置项)
|
- 📚 知识库(RAG):向量嵌入与余弦相似度检索(与 Eino `retriever.Retriever` 语义一致),可选 **Eino Compose** 索引流水线及检索后处理(预算、重排等配置项)
|
||||||
- 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作
|
- 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作
|
||||||
- 📂 **项目管理**:按项目归类对话与漏洞;**共享事实**(项目黑板)在多会话间沉淀目标/环境/认证等认知,自动注入 Agent 上下文,支持 MCP 工具读写(`upsert_project_fact`、`get_project_fact` 等)
|
- 📂 **项目管理**:共享事实(黑板)跨会话沉淀认知,`upsert_project_fact` + `links` 串联攻击路径;聊天攻击链与项目事实图可视化
|
||||||
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
|
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
|
||||||
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
|
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
|
||||||
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
|
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
|
||||||
@@ -549,6 +549,11 @@ multi_agent:
|
|||||||
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor 可选
|
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor 可选
|
||||||
# eino_skills: { disable: false, filesystem_tools: true, skill_tool_name: skill }
|
# eino_skills: { disable: false, filesystem_tools: true, skill_tool_name: skill }
|
||||||
# eino_middleware: plantask_enable、checkpoint_dir、deep_model_retry_max_retries、deep_output_key 等
|
# eino_middleware: plantask_enable、checkpoint_dir、deep_model_retry_max_retries、deep_output_key 等
|
||||||
|
project:
|
||||||
|
enabled: true # 启用项目黑板与事实 MCP 工具
|
||||||
|
fact_index_max_runes: 65000
|
||||||
|
fact_summary_max_runes: 24000
|
||||||
|
default_inject_deprecated: false
|
||||||
```
|
```
|
||||||
|
|
||||||
### 工具模版示例(`tools/nmap.yaml`)
|
### 工具模版示例(`tools/nmap.yaml`)
|
||||||
|
|||||||
+5
-3
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.6.41"
|
version: "v1.6.42"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -308,7 +308,9 @@ roles_dir: roles # 角色配置文件目录(相对于配置文件所在目录
|
|||||||
project:
|
project:
|
||||||
enabled: true
|
enabled: true
|
||||||
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
|
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
|
||||||
fact_index_max_runes: 6500
|
fact_index_max_runes: 65000
|
||||||
fact_summary_max_runes: 2400
|
# 事实关系速览段预算(从索引总预算中预留)
|
||||||
|
fact_index_path_max_runes: 10000
|
||||||
|
fact_summary_max_runes: 24000
|
||||||
default_inject_deprecated: false
|
default_inject_deprecated: false
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cyberstrike-ai/internal/project"
|
"cyberstrike-ai/internal/projectprompt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultSingleAgentSystemPrompt 单代理(Eino ADK / MCP)内置系统提示;可通过 agent.system_prompt_path 覆盖为文件。
|
// DefaultSingleAgentSystemPrompt 单代理(Eino ADK / MCP)内置系统提示;可通过 agent.system_prompt_path 覆盖为文件。
|
||||||
@@ -107,7 +107,7 @@ func DefaultSingleAgentSystemPrompt() string {
|
|||||||
- 若最近一步得到 404/空结果/无效响应,不得直接结束;至少再进行一次“同目标不同策略”的验证(如变更路径、参数、请求方法、上下文来源)。
|
- 若最近一步得到 404/空结果/无效响应,不得直接结束;至少再进行一次“同目标不同策略”的验证(如变更路径、参数、请求方法、上下文来源)。
|
||||||
- 避免无效空转:同一工具+同类参数连续失败 3 次后,必须切换策略(改工具、改入口、改假设)并说明切换原因。
|
- 避免无效空转:同一工具+同类参数连续失败 3 次后,必须切换策略(改工具、改入口、改假设)并说明切换原因。
|
||||||
|
|
||||||
` + project.FactRecordingBlackboardSection(false) + `
|
` + projectprompt.FactRecordingBlackboardSection(false) + `
|
||||||
|
|
||||||
## 技能库(Skills)与知识库
|
## 技能库(Skills)与知识库
|
||||||
|
|
||||||
|
|||||||
@@ -1069,6 +1069,11 @@ func setupRoutes(
|
|||||||
protected.GET("/projects/:id", projectHandler.GetProject)
|
protected.GET("/projects/:id", projectHandler.GetProject)
|
||||||
protected.PUT("/projects/:id", projectHandler.UpdateProject)
|
protected.PUT("/projects/:id", projectHandler.UpdateProject)
|
||||||
protected.DELETE("/projects/:id", projectHandler.DeleteProject)
|
protected.DELETE("/projects/:id", projectHandler.DeleteProject)
|
||||||
|
protected.GET("/projects/:id/fact-graph", projectHandler.GetFactGraph)
|
||||||
|
protected.GET("/projects/:id/fact-edges", projectHandler.ListFactEdges)
|
||||||
|
protected.POST("/projects/:id/fact-edges", projectHandler.CreateFactEdge)
|
||||||
|
protected.DELETE("/projects/:id/fact-edges/:edgeId", projectHandler.DeleteFactEdge)
|
||||||
|
protected.POST("/projects/:id/promote-attack-chain/:conversationId", projectHandler.PromoteAttackChain)
|
||||||
protected.GET("/projects/:id/facts", projectHandler.ListFacts)
|
protected.GET("/projects/:id/facts", projectHandler.ListFacts)
|
||||||
protected.POST("/projects/:id/facts", projectHandler.CreateFact)
|
protected.POST("/projects/:id/facts", projectHandler.CreateFact)
|
||||||
protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact)
|
protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact)
|
||||||
|
|||||||
@@ -89,6 +89,28 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "可选:关联的漏洞记录 ID",
|
"description": "可选:关联的漏洞记录 ID",
|
||||||
},
|
},
|
||||||
|
"links": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "可选:关系边(from → 当前 fact)。finding 至少 1 条 {from:target/*, type:discovered_on};finding 上记录 exploit 用 {from:exploit/*, type:exploits}。省略保留已有边;传 [] 清空全部关系边。",
|
||||||
|
"items": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"from": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "来源 fact_key:存储为 from → 当前 fact",
|
||||||
|
},
|
||||||
|
"type": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "depends_on | leads_to | enables | exploits | discovered_on | contains | part_of | supports",
|
||||||
|
},
|
||||||
|
"confidence": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "confirmed | tentative | deprecated",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"from", "type"},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": []string{"fact_key", "summary"},
|
"required": []string{"fact_key", "summary"},
|
||||||
},
|
},
|
||||||
@@ -124,7 +146,26 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return textResult("错误: "+err.Error(), true), nil
|
return textResult("错误: "+err.Error(), true), nil
|
||||||
}
|
}
|
||||||
|
if _, hasLinks := args["links"]; hasLinks {
|
||||||
|
linkInputs, err := project.ParseFactLinkInputs(args["links"])
|
||||||
|
if err != nil {
|
||||||
|
return textResult("错误: "+err.Error(), true), nil
|
||||||
|
}
|
||||||
|
convID := agent.ConversationIDFromContext(ctx)
|
||||||
|
if err := project.PersistFactLinksFromParsed(db, projectID, created.FactKey, convID, linkInputs, true); err != nil {
|
||||||
|
return textResult("错误: 保存关系边失败: "+err.Error(), true), nil
|
||||||
|
}
|
||||||
|
created, _ = db.GetProjectFactByKey(projectID, created.FactKey)
|
||||||
|
} else if parsed := project.ParseLinksFromBody(created.Body); len(parsed) > 0 {
|
||||||
|
if err := project.PersistFactIncomingLinks(db, projectID, created.FactKey, parsed, true); err != nil {
|
||||||
|
return textResult("错误: 从 body 解析边失败: "+err.Error(), true), nil
|
||||||
|
}
|
||||||
|
created, _ = db.GetProjectFactByKey(projectID, created.FactKey)
|
||||||
|
}
|
||||||
msg := fmt.Sprintf("事实已保存。\nfact_key: %s\nid: %s\nconfidence: %s", created.FactKey, created.ID, created.Confidence)
|
msg := fmt.Sprintf("事实已保存。\nfact_key: %s\nid: %s\nconfidence: %s", created.FactKey, created.ID, created.Confidence)
|
||||||
|
if in, _ := db.ListIncomingProjectFactEdges(projectID, created.FactKey); len(in) > 0 {
|
||||||
|
msg += "\n关系边: " + project.FormatFactLinksText(in)
|
||||||
|
}
|
||||||
if warn := project.SparseBodyWarningIfNeeded(f.Category, f.FactKey, f.Body); warn != "" {
|
if warn := project.SparseBodyWarningIfNeeded(f.Category, f.FactKey, f.Body); warn != "" {
|
||||||
msg += warn
|
msg += warn
|
||||||
}
|
}
|
||||||
@@ -164,6 +205,18 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
|
|||||||
if f.SourceConversationID != "" {
|
if f.SourceConversationID != "" {
|
||||||
msg += fmt.Sprintf("\nsource_conversation_id: %s", f.SourceConversationID)
|
msg += fmt.Sprintf("\nsource_conversation_id: %s", f.SourceConversationID)
|
||||||
}
|
}
|
||||||
|
if in, _ := db.ListIncomingProjectFactEdges(projectID, f.FactKey); len(in) > 0 {
|
||||||
|
msg += "\n关系边(from → 本 fact):\n"
|
||||||
|
for _, e := range in {
|
||||||
|
msg += fmt.Sprintf("- %s ← %s (%s)\n", e.EdgeType, e.SourceFactKey, e.Confidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, _ := db.ListOutgoingProjectFactEdges(projectID, f.FactKey); len(out) > 0 {
|
||||||
|
msg += "指向其他事实:\n"
|
||||||
|
for _, e := range out {
|
||||||
|
msg += fmt.Sprintf("- %s → %s (%s)\n", e.EdgeType, e.TargetFactKey, e.Confidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
msg += "\n\n--- body ---\n" + f.Body
|
msg += "\n\n--- body ---\n" + f.Body
|
||||||
if warn := project.SparseBodyWarningIfNeeded(f.Category, f.FactKey, f.Body); warn != "" {
|
if warn := project.SparseBodyWarningIfNeeded(f.Category, f.FactKey, f.Body); warn != "" {
|
||||||
msg += warn
|
msg += warn
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package attackchain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/project"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var promoteSlugSanitizer = regexp.MustCompile(`[^a-z0-9._/-]+`)
|
||||||
|
|
||||||
|
// PromoteToProjectResult 攻击链沉淀结果。
|
||||||
|
type PromoteToProjectResult struct {
|
||||||
|
FactsCreated int `json:"facts_created"`
|
||||||
|
FactsUpdated int `json:"facts_updated"`
|
||||||
|
EdgesCreated int `json:"edges_created"`
|
||||||
|
FactKeys []string `json:"fact_keys"`
|
||||||
|
Graph *database.ProjectFactGraph `json:"graph,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromoteToProject 将对话攻击链沉淀为项目事实与边。
|
||||||
|
func PromoteToProject(db *database.DB, projectID, conversationID string) (*PromoteToProjectResult, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, fmt.Errorf("database 未初始化")
|
||||||
|
}
|
||||||
|
projectID = strings.TrimSpace(projectID)
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if projectID == "" || conversationID == "" {
|
||||||
|
return nil, fmt.Errorf("project_id 与 conversation_id 必填")
|
||||||
|
}
|
||||||
|
if _, err := db.GetProject(projectID); err != nil {
|
||||||
|
return nil, fmt.Errorf("项目不存在")
|
||||||
|
}
|
||||||
|
conv, err := db.GetConversation(conversationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("对话不存在")
|
||||||
|
}
|
||||||
|
if pid := strings.TrimSpace(conv.ProjectID); pid != "" && pid != projectID {
|
||||||
|
return nil, fmt.Errorf("对话已绑定其他项目")
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes, err := db.LoadAttackChainNodes(conversationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
edges, err := db.LoadAttackChainEdges(conversationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return nil, fmt.Errorf("该对话尚无攻击链,请先在对话中生成攻击链")
|
||||||
|
}
|
||||||
|
|
||||||
|
res := &PromoteToProjectResult{}
|
||||||
|
nodeToKey := make(map[string]string, len(nodes))
|
||||||
|
usedKeys := map[string]int{}
|
||||||
|
|
||||||
|
for _, node := range nodes {
|
||||||
|
key := allocatePromoteFactKey(node, usedKeys)
|
||||||
|
nodeToKey[node.ID] = key
|
||||||
|
category := mapPromoteNodeCategory(node.Type)
|
||||||
|
existing, getErr := db.GetProjectFactByKey(projectID, key)
|
||||||
|
f := &database.ProjectFact{
|
||||||
|
ProjectID: projectID,
|
||||||
|
FactKey: key,
|
||||||
|
Category: category,
|
||||||
|
Summary: strings.TrimSpace(node.Label),
|
||||||
|
Body: formatPromotedFactBody(node, conversationID),
|
||||||
|
Confidence: "tentative",
|
||||||
|
SourceConversationID: conversationID,
|
||||||
|
}
|
||||||
|
if getErr == nil && existing != nil {
|
||||||
|
f.ID = existing.ID
|
||||||
|
f.CreatedAt = existing.CreatedAt
|
||||||
|
if strings.TrimSpace(f.Summary) == "" {
|
||||||
|
f.Summary = existing.Summary
|
||||||
|
}
|
||||||
|
if _, err := db.UpsertProjectFact(f); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.FactsUpdated++
|
||||||
|
} else {
|
||||||
|
if _, err := db.UpsertProjectFact(f); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.FactsCreated++
|
||||||
|
}
|
||||||
|
res.FactKeys = append(res.FactKeys, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, edge := range edges {
|
||||||
|
srcKey, ok1 := nodeToKey[edge.Source]
|
||||||
|
tgtKey, ok2 := nodeToKey[edge.Target]
|
||||||
|
if !ok1 || !ok2 || srcKey == tgtKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
edgeType := mapPromoteEdgeType(edge.Type)
|
||||||
|
incoming, _ := db.ListIncomingProjectFactEdges(projectID, tgtKey)
|
||||||
|
merged := project.MergeLinkFromInputsUnique(promoteFromEdgeInputsFromDB(incoming), []database.ProjectFactEdgeFromInput{{From: srcKey, Type: edgeType}})
|
||||||
|
if err := db.ReplaceIncomingProjectFactEdges(projectID, tgtKey, merged); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.EdgesCreated++
|
||||||
|
if fact, err := db.GetProjectFactByKey(projectID, tgtKey); err == nil {
|
||||||
|
in, _ := db.ListIncomingProjectFactEdges(projectID, tgtKey)
|
||||||
|
fact.Body = project.SyncBodyLinksSection(fact.Body, in)
|
||||||
|
_, _ = db.UpsertProjectFact(fact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
graph, _ := project.BuildProjectFactGraph(db, projectID, "full", true)
|
||||||
|
res.Graph = graph
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promoteFromEdgeInputsFromDB(edges []*database.ProjectFactEdge) []database.ProjectFactEdgeFromInput {
|
||||||
|
out := make([]database.ProjectFactEdgeFromInput, 0, len(edges))
|
||||||
|
for _, e := range edges {
|
||||||
|
out = append(out, database.ProjectFactEdgeFromInput{From: e.SourceFactKey, Type: e.EdgeType, Confidence: e.Confidence})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapPromoteNodeCategory(nodeType string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(nodeType)) {
|
||||||
|
case "target":
|
||||||
|
return project.FactCategoryTarget
|
||||||
|
case "vulnerability":
|
||||||
|
return project.FactCategoryFinding
|
||||||
|
case "action":
|
||||||
|
return project.FactCategoryChain
|
||||||
|
default:
|
||||||
|
return project.FactCategoryNote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapPromoteEdgeType(t string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||||
|
case "discovers", "discovered_on", "targets":
|
||||||
|
return "discovered_on"
|
||||||
|
case "exploits":
|
||||||
|
return "exploits"
|
||||||
|
case "enables":
|
||||||
|
return "enables"
|
||||||
|
case "depends_on":
|
||||||
|
return "depends_on"
|
||||||
|
default:
|
||||||
|
return "leads_to"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allocatePromoteFactKey(node Node, used map[string]int) string {
|
||||||
|
prefix := "chain/"
|
||||||
|
switch strings.ToLower(strings.TrimSpace(node.Type)) {
|
||||||
|
case "target":
|
||||||
|
prefix = "target/"
|
||||||
|
case "vulnerability":
|
||||||
|
prefix = "finding/"
|
||||||
|
case "action":
|
||||||
|
prefix = "chain/"
|
||||||
|
}
|
||||||
|
base := promoteSlugify(node.Label)
|
||||||
|
if base == "" {
|
||||||
|
base = promoteSlugify(node.ID)
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
|
base = uuid.New().String()[:8]
|
||||||
|
}
|
||||||
|
key := prefix + base
|
||||||
|
if n, ok := used[key]; ok {
|
||||||
|
n++
|
||||||
|
used[key] = n
|
||||||
|
key = fmt.Sprintf("%s-%d", key, n)
|
||||||
|
} else {
|
||||||
|
used[key] = 1
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func promoteSlugify(s string) string {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
s = strings.NewReplacer(" ", "-", "—", "-", "–", "-", "/", "-").Replace(s)
|
||||||
|
s = promoteSlugSanitizer.ReplaceAllString(s, "-")
|
||||||
|
s = strings.Trim(s, "-")
|
||||||
|
if len(s) > 64 {
|
||||||
|
s = s[:64]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPromotedFactBody(node Node, conversationID string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("## 来源\n")
|
||||||
|
b.WriteString(fmt.Sprintf("- 对话攻击链沉淀\n- source_conversation_id: %s\n- node_id: %s\n- node_type: %s\n\n", conversationID, node.ID, node.Type))
|
||||||
|
b.WriteString("## 摘要\n")
|
||||||
|
b.WriteString(strings.TrimSpace(node.Label))
|
||||||
|
b.WriteString("\n\n## 关联\n- 结构化关系边(自动同步):\n (见项目攻击路径图)\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ type ProjectConfig struct {
|
|||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
DefaultProjectID string `yaml:"default_project_id,omitempty" json:"default_project_id,omitempty"` // 机器人/批量等无显式项目时绑定的默认项目
|
DefaultProjectID string `yaml:"default_project_id,omitempty" json:"default_project_id,omitempty"` // 机器人/批量等无显式项目时绑定的默认项目
|
||||||
FactIndexMaxRunes int `yaml:"fact_index_max_runes,omitempty" json:"fact_index_max_runes,omitempty"`
|
FactIndexMaxRunes int `yaml:"fact_index_max_runes,omitempty" json:"fact_index_max_runes,omitempty"`
|
||||||
|
FactIndexPathMaxRunes int `yaml:"fact_index_path_max_runes,omitempty" json:"fact_index_path_max_runes,omitempty"`
|
||||||
FactSummaryMaxRunes int `yaml:"fact_summary_max_runes,omitempty" json:"fact_summary_max_runes,omitempty"`
|
FactSummaryMaxRunes int `yaml:"fact_summary_max_runes,omitempty" json:"fact_summary_max_runes,omitempty"`
|
||||||
DefaultInjectDeprecated bool `yaml:"default_inject_deprecated,omitempty" json:"default_inject_deprecated,omitempty"`
|
DefaultInjectDeprecated bool `yaml:"default_inject_deprecated,omitempty" json:"default_inject_deprecated,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -57,6 +58,14 @@ func (c ProjectConfig) FactIndexMaxRunesEffective() int {
|
|||||||
return c.FactIndexMaxRunes
|
return c.FactIndexMaxRunes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FactIndexPathMaxRunesEffective 攻击路径速览段的最大 rune 数(从 fact_index_max_runes 预算中预留)。
|
||||||
|
func (c ProjectConfig) FactIndexPathMaxRunesEffective() int {
|
||||||
|
if c.FactIndexPathMaxRunes <= 0 {
|
||||||
|
return 1000
|
||||||
|
}
|
||||||
|
return c.FactIndexPathMaxRunes
|
||||||
|
}
|
||||||
|
|
||||||
// FactSummaryMaxRunesEffective upsert 时 summary 最大 rune 数(索引一行,宜含验证要点)。
|
// FactSummaryMaxRunesEffective upsert 时 summary 最大 rune 数(索引一行,宜含验证要点)。
|
||||||
func (c ProjectConfig) FactSummaryMaxRunesEffective() int {
|
func (c ProjectConfig) FactSummaryMaxRunesEffective() int {
|
||||||
if c.FactSummaryMaxRunes <= 0 {
|
if c.FactSummaryMaxRunes <= 0 {
|
||||||
|
|||||||
@@ -353,6 +353,22 @@ func (db *DB) initTables() error {
|
|||||||
UNIQUE(project_id, fact_key)
|
UNIQUE(project_id, fact_key)
|
||||||
);`
|
);`
|
||||||
|
|
||||||
|
// 项目事实关系边(黑板 DAG)
|
||||||
|
createProjectFactEdgesTable := `
|
||||||
|
CREATE TABLE IF NOT EXISTS project_fact_edges (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
source_fact_key TEXT NOT NULL,
|
||||||
|
target_fact_key TEXT NOT NULL,
|
||||||
|
edge_type TEXT NOT NULL,
|
||||||
|
confidence TEXT NOT NULL DEFAULT 'tentative',
|
||||||
|
source_conversation_id TEXT,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(project_id, source_fact_key, target_fact_key, edge_type)
|
||||||
|
);`
|
||||||
|
|
||||||
// 创建漏洞表
|
// 创建漏洞表
|
||||||
createVulnerabilitiesTable := `
|
createVulnerabilitiesTable := `
|
||||||
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
||||||
@@ -591,6 +607,9 @@ func (db *DB) initTables() error {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_project_facts_confidence ON project_facts(confidence);
|
CREATE INDEX IF NOT EXISTS idx_project_facts_confidence ON project_facts(confidence);
|
||||||
CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id);
|
CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_fact_edges_project ON project_fact_edges(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_fact_edges_source ON project_fact_edges(project_id, source_fact_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_fact_edges_target ON project_fact_edges(project_id, target_fact_key);
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversations_project_id ON conversations(project_id);
|
CREATE INDEX IF NOT EXISTS idx_conversations_project_id ON conversations(project_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id);
|
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
|
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
|
||||||
@@ -672,6 +691,10 @@ func (db *DB) initTables() error {
|
|||||||
return fmt.Errorf("创建project_facts表失败: %w", err)
|
return fmt.Errorf("创建project_facts表失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := db.Exec(createProjectFactEdgesTable); err != nil {
|
||||||
|
return fmt.Errorf("创建project_fact_edges表失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
|
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
|
||||||
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
|
|||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeprecateProjectFact 将事实标记为 deprecated。
|
// DeprecateProjectFact 将事实标记为 deprecated(关联边同步 deprecated)。
|
||||||
func (db *DB) DeprecateProjectFact(projectID, factKey string) error {
|
func (db *DB) DeprecateProjectFact(projectID, factKey string) error {
|
||||||
res, err := db.Exec(
|
res, err := db.Exec(
|
||||||
`UPDATE project_facts SET confidence = 'deprecated', updated_at = ? WHERE project_id = ? AND fact_key = ?`,
|
`UPDATE project_facts SET confidence = 'deprecated', updated_at = ? WHERE project_id = ? AND fact_key = ?`,
|
||||||
@@ -402,7 +402,7 @@ func (db *DB) DeprecateProjectFact(projectID, factKey string) error {
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return fmt.Errorf("事实不存在")
|
return fmt.Errorf("事实不存在")
|
||||||
}
|
}
|
||||||
return nil
|
return db.DeprecateProjectFactEdgesForKey(projectID, factKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestoreProjectFact 将已废弃事实恢复为 tentative 或 confirmed(重新参与黑板索引)。
|
// RestoreProjectFact 将已废弃事实恢复为 tentative 或 confirmed(重新参与黑板索引)。
|
||||||
@@ -430,9 +430,16 @@ func (db *DB) RestoreProjectFact(projectID, factKey, confidence string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteProjectFact 删除事实。
|
// DeleteProjectFact 删除事实(级联删除相关边)。
|
||||||
func (db *DB) DeleteProjectFact(id string) error {
|
func (db *DB) DeleteProjectFact(id string) error {
|
||||||
_, err := db.Exec(`DELETE FROM project_facts WHERE id = ?`, id)
|
f, err := db.GetProjectFact(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.DeleteProjectFactEdgesForKey(f.ProjectID, f.FactKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`DELETE FROM project_facts WHERE id = ?`, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidProjectFactEdgeTypes 项目事实图允许的边类型。
|
||||||
|
var ValidProjectFactEdgeTypes = map[string]struct{}{
|
||||||
|
"depends_on": {},
|
||||||
|
"leads_to": {},
|
||||||
|
"enables": {},
|
||||||
|
"exploits": {},
|
||||||
|
"discovered_on": {},
|
||||||
|
"contains": {},
|
||||||
|
"part_of": {},
|
||||||
|
"supports": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectFactEdge 项目事实关系边(source → target)。
|
||||||
|
type ProjectFactEdge struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
SourceFactKey string `json:"source_fact_key"`
|
||||||
|
TargetFactKey string `json:"target_fact_key"`
|
||||||
|
EdgeType string `json:"edge_type"`
|
||||||
|
Confidence string `json:"confidence"` // confirmed | tentative | deprecated
|
||||||
|
SourceConversationID string `json:"source_conversation_id,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectFactEdgeInput 写入边时的输入(出边:source → To)。
|
||||||
|
type ProjectFactEdgeInput struct {
|
||||||
|
To string `json:"to"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectFactEdgeFromInput 写入入边时的输入(From → 当前事实)。
|
||||||
|
type ProjectFactEdgeFromInput struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectFactGraphNode 图 API 节点。
|
||||||
|
type ProjectFactGraphNode struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FactKey string `json:"fact_key"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Label string `json:"label"` // 图节点短标签(截断)
|
||||||
|
Summary string `json:"summary"` // 完整摘要(侧栏等详情用)
|
||||||
|
Confidence string `json:"confidence"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectFactGraphEdge 图 API 边。
|
||||||
|
type ProjectFactGraphEdge struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Confidence string `json:"confidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectFactGraph 项目事实图。
|
||||||
|
type ProjectFactGraph struct {
|
||||||
|
Nodes []ProjectFactGraphNode `json:"nodes"`
|
||||||
|
Edges []ProjectFactGraphEdge `json:"edges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateProjectFactEdgeType 校验边类型。
|
||||||
|
func ValidateProjectFactEdgeType(edgeType string) error {
|
||||||
|
edgeType = strings.TrimSpace(strings.ToLower(edgeType))
|
||||||
|
if edgeType == "" {
|
||||||
|
return fmt.Errorf("edge type 不能为空")
|
||||||
|
}
|
||||||
|
if _, ok := ValidProjectFactEdgeTypes[edgeType]; !ok {
|
||||||
|
return fmt.Errorf("无效的 edge type: %s", edgeType)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEdgeConfidence(confidence string) string {
|
||||||
|
confidence = strings.TrimSpace(strings.ToLower(confidence))
|
||||||
|
switch confidence {
|
||||||
|
case "confirmed", "deprecated":
|
||||||
|
return confidence
|
||||||
|
default:
|
||||||
|
return "tentative"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProjectFactEdgesByProject 列出项目全部边。
|
||||||
|
func (db *DB) ListProjectFactEdgesByProject(projectID string) ([]*ProjectFactEdge, error) {
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||||
|
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||||
|
FROM project_fact_edges
|
||||||
|
WHERE project_id = ?
|
||||||
|
ORDER BY created_at ASC, rowid ASC`,
|
||||||
|
projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanProjectFactEdges(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOutgoingProjectFactEdges 列出某事实的全部出边。
|
||||||
|
func (db *DB) ListOutgoingProjectFactEdges(projectID, sourceFactKey string) ([]*ProjectFactEdge, error) {
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||||
|
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||||
|
FROM project_fact_edges
|
||||||
|
WHERE project_id = ? AND source_fact_key = ?
|
||||||
|
ORDER BY created_at ASC, rowid ASC`,
|
||||||
|
projectID, sourceFactKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanProjectFactEdges(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIncomingProjectFactEdges 列出某事实的全部入边。
|
||||||
|
func (db *DB) ListIncomingProjectFactEdges(projectID, targetFactKey string) ([]*ProjectFactEdge, error) {
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||||
|
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||||
|
FROM project_fact_edges
|
||||||
|
WHERE project_id = ? AND target_fact_key = ?
|
||||||
|
ORDER BY created_at ASC, rowid ASC`,
|
||||||
|
projectID, targetFactKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanProjectFactEdges(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceOutgoingProjectFactEdges 替换某事实的全部出边(links 省略时不调用)。
|
||||||
|
func (db *DB) ReplaceOutgoingProjectFactEdges(projectID, sourceFactKey, sourceConversationID string, inputs []ProjectFactEdgeInput) error {
|
||||||
|
sourceFactKey = strings.TrimSpace(sourceFactKey)
|
||||||
|
if sourceFactKey == "" {
|
||||||
|
return fmt.Errorf("source_fact_key 不能为空")
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`DELETE FROM project_fact_edges WHERE project_id = ? AND source_fact_key = ?`,
|
||||||
|
projectID, sourceFactKey,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("清除旧边失败: %w", err)
|
||||||
|
}
|
||||||
|
for _, in := range inputs {
|
||||||
|
target := strings.TrimSpace(in.To)
|
||||||
|
if target == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := ValidateFactKey(target); err != nil {
|
||||||
|
return fmt.Errorf("target fact_key 无效 (%s): %w", target, err)
|
||||||
|
}
|
||||||
|
if target == sourceFactKey {
|
||||||
|
return fmt.Errorf("边不能指向自身: %s", sourceFactKey)
|
||||||
|
}
|
||||||
|
if err := ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
edge := &ProjectFactEdge{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
ProjectID: projectID,
|
||||||
|
SourceFactKey: sourceFactKey,
|
||||||
|
TargetFactKey: target,
|
||||||
|
EdgeType: strings.ToLower(strings.TrimSpace(in.Type)),
|
||||||
|
Confidence: normalizeEdgeConfidence(in.Confidence),
|
||||||
|
SourceConversationID: sourceConversationID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := db.insertProjectFactEdge(edge); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceIncomingProjectFactEdges 替换某事实的全部入边(From 为来源 fact_key)。
|
||||||
|
func (db *DB) ReplaceIncomingProjectFactEdges(projectID, targetFactKey string, inputs []ProjectFactEdgeFromInput) error {
|
||||||
|
targetFactKey = strings.TrimSpace(targetFactKey)
|
||||||
|
if targetFactKey == "" {
|
||||||
|
return fmt.Errorf("target_fact_key 不能为空")
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`DELETE FROM project_fact_edges WHERE project_id = ? AND target_fact_key = ?`,
|
||||||
|
projectID, targetFactKey,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("清除旧入边失败: %w", err)
|
||||||
|
}
|
||||||
|
for _, in := range inputs {
|
||||||
|
source := strings.TrimSpace(in.From)
|
||||||
|
if source == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := ValidateFactKey(source); err != nil {
|
||||||
|
return fmt.Errorf("source fact_key 无效 (%s): %w", source, err)
|
||||||
|
}
|
||||||
|
if source == targetFactKey {
|
||||||
|
return fmt.Errorf("边不能指向自身: %s", targetFactKey)
|
||||||
|
}
|
||||||
|
if err := ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sourceConversationID := ""
|
||||||
|
if srcFact, err := db.GetProjectFactByKey(projectID, source); err == nil && srcFact != nil {
|
||||||
|
sourceConversationID = srcFact.SourceConversationID
|
||||||
|
}
|
||||||
|
edge := &ProjectFactEdge{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
ProjectID: projectID,
|
||||||
|
SourceFactKey: source,
|
||||||
|
TargetFactKey: targetFactKey,
|
||||||
|
EdgeType: strings.ToLower(strings.TrimSpace(in.Type)),
|
||||||
|
Confidence: normalizeEdgeConfidence(in.Confidence),
|
||||||
|
SourceConversationID: sourceConversationID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := db.insertProjectFactEdge(edge); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProjectFactEdge 按 ID 获取边。
|
||||||
|
func (db *DB) GetProjectFactEdge(edgeID string) (*ProjectFactEdge, error) {
|
||||||
|
var e ProjectFactEdge
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||||
|
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||||
|
FROM project_fact_edges WHERE id = ?`, edgeID,
|
||||||
|
).Scan(&e.ID, &e.ProjectID, &e.SourceFactKey, &e.TargetFactKey, &e.EdgeType, &e.Confidence,
|
||||||
|
&e.SourceConversationID, &createdAt, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("边不存在")
|
||||||
|
}
|
||||||
|
e.CreatedAt = parseDBTime(createdAt)
|
||||||
|
e.UpdatedAt = parseDBTime(updatedAt)
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddProjectFactEdge 新增单条边(已存在则更新 confidence)。
|
||||||
|
func (db *DB) AddProjectFactEdge(projectID string, in ProjectFactEdgeInput, sourceFactKey, sourceConversationID string) (*ProjectFactEdge, error) {
|
||||||
|
sourceFactKey = strings.TrimSpace(sourceFactKey)
|
||||||
|
target := strings.TrimSpace(in.To)
|
||||||
|
if sourceFactKey == "" || target == "" {
|
||||||
|
return nil, fmt.Errorf("source 与 target 必填")
|
||||||
|
}
|
||||||
|
if sourceFactKey == target {
|
||||||
|
return nil, fmt.Errorf("边不能指向自身")
|
||||||
|
}
|
||||||
|
if err := ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ValidateFactKey(target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
e := &ProjectFactEdge{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
ProjectID: projectID,
|
||||||
|
SourceFactKey: sourceFactKey,
|
||||||
|
TargetFactKey: target,
|
||||||
|
EdgeType: strings.ToLower(strings.TrimSpace(in.Type)),
|
||||||
|
Confidence: normalizeEdgeConfidence(in.Confidence),
|
||||||
|
SourceConversationID: sourceConversationID,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
_, err := db.Exec(
|
||||||
|
`INSERT INTO project_fact_edges (
|
||||||
|
id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||||
|
source_conversation_id, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(project_id, source_fact_key, target_fact_key, edge_type)
|
||||||
|
DO UPDATE SET confidence = excluded.confidence, updated_at = excluded.updated_at`,
|
||||||
|
e.ID, e.ProjectID, e.SourceFactKey, e.TargetFactKey, e.EdgeType, e.Confidence,
|
||||||
|
nullIfEmpty(e.SourceConversationID), e.CreatedAt, e.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("添加边失败: %w", err)
|
||||||
|
}
|
||||||
|
// 返回最新
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||||
|
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||||
|
FROM project_fact_edges
|
||||||
|
WHERE project_id = ? AND source_fact_key = ? AND target_fact_key = ? AND edge_type = ?`,
|
||||||
|
projectID, sourceFactKey, target, e.EdgeType,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
list, err := scanProjectFactEdges(rows)
|
||||||
|
if err != nil || len(list) == 0 {
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
return list[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProjectFactEdge 删除单条边。
|
||||||
|
func (db *DB) DeleteProjectFactEdge(edgeID string) error {
|
||||||
|
res, err := db.Exec(`DELETE FROM project_fact_edges WHERE id = ?`, edgeID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("边不存在")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) insertProjectFactEdge(e *ProjectFactEdge) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
`INSERT INTO project_fact_edges (
|
||||||
|
id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||||
|
source_conversation_id, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
e.ID, e.ProjectID, e.SourceFactKey, e.TargetFactKey, e.EdgeType, e.Confidence,
|
||||||
|
nullIfEmpty(e.SourceConversationID), e.CreatedAt, e.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("写入边失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameProjectFactKeyEdges 事实 key 变更时同步边上的引用。
|
||||||
|
func (db *DB) RenameProjectFactKeyEdges(projectID, oldKey, newKey string) error {
|
||||||
|
oldKey = strings.TrimSpace(oldKey)
|
||||||
|
newKey = strings.TrimSpace(newKey)
|
||||||
|
if oldKey == "" || newKey == "" || oldKey == newKey {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`UPDATE project_fact_edges SET source_fact_key = ?, updated_at = ?
|
||||||
|
WHERE project_id = ? AND source_fact_key = ?`,
|
||||||
|
newKey, now, projectID, oldKey,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := db.Exec(
|
||||||
|
`UPDATE project_fact_edges SET target_fact_key = ?, updated_at = ?
|
||||||
|
WHERE project_id = ? AND target_fact_key = ?`,
|
||||||
|
newKey, now, projectID, oldKey,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProjectFactEdgesForKey 删除与某 fact_key 相关的全部边。
|
||||||
|
func (db *DB) DeleteProjectFactEdgesForKey(projectID, factKey string) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
`DELETE FROM project_fact_edges
|
||||||
|
WHERE project_id = ? AND (source_fact_key = ? OR target_fact_key = ?)`,
|
||||||
|
projectID, factKey, factKey,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeprecateProjectFactEdgesForKey 将关联边标记为 deprecated。
|
||||||
|
func (db *DB) DeprecateProjectFactEdgesForKey(projectID, factKey string) error {
|
||||||
|
now := time.Now()
|
||||||
|
_, err := db.Exec(
|
||||||
|
`UPDATE project_fact_edges SET confidence = 'deprecated', updated_at = ?
|
||||||
|
WHERE project_id = ? AND (source_fact_key = ? OR target_fact_key = ?)
|
||||||
|
AND confidence != 'deprecated'`,
|
||||||
|
now, projectID, factKey, factKey,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanProjectFactEdges(rows *sql.Rows) ([]*ProjectFactEdge, error) {
|
||||||
|
var out []*ProjectFactEdge
|
||||||
|
for rows.Next() {
|
||||||
|
var e ProjectFactEdge
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&e.ID, &e.ProjectID, &e.SourceFactKey, &e.TargetFactKey, &e.EdgeType, &e.Confidence,
|
||||||
|
&e.SourceConversationID, &createdAt, &updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.CreatedAt = parseDBTime(createdAt)
|
||||||
|
e.UpdatedAt = parseDBTime(updatedAt)
|
||||||
|
out = append(out, &e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -2464,17 +2464,108 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"parameters": []map[string]interface{}{
|
"parameters": []map[string]interface{}{
|
||||||
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||||
{"name": "fact_key", "in": "query", "schema": map[string]interface{}{"type": "string"}},
|
{"name": "fact_key", "in": "query", "schema": map[string]interface{}{"type": "string"}},
|
||||||
|
{"name": "include_links", "in": "query", "schema": map[string]interface{}{"type": "boolean"}},
|
||||||
|
{"name": "include_link_counts", "in": "query", "schema": map[string]interface{}{"type": "boolean"}},
|
||||||
},
|
},
|
||||||
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "事实列表或单条"}},
|
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "事实列表或单条(可含 link_counts / outgoing_links)"}},
|
||||||
},
|
},
|
||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"项目管理"}, "summary": "创建/更新事实", "operationId": "upsertProjectFactREST",
|
"tags": []string{"项目管理"}, "summary": "创建/更新事实", "operationId": "upsertProjectFactREST",
|
||||||
"parameters": []map[string]interface{}{
|
"parameters": []map[string]interface{}{
|
||||||
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||||
},
|
},
|
||||||
|
"requestBody": map[string]interface{}{
|
||||||
|
"required": true,
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"fact_key": map[string]interface{}{"type": "string"},
|
||||||
|
"summary": map[string]interface{}{"type": "string"},
|
||||||
|
"links": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"to": map[string]interface{}{"type": "string"},
|
||||||
|
"type": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"links_text": map[string]interface{}{"type": "string", "description": "type: fact_key 每行一条"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "成功"}},
|
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "成功"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/projects/{id}/fact-graph": map[string]interface{}{
|
||||||
|
"get": map[string]interface{}{
|
||||||
|
"tags": []string{"项目管理"}, "summary": "获取项目事实攻击路径图", "operationId": "getProjectFactGraph",
|
||||||
|
"parameters": []map[string]interface{}{
|
||||||
|
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||||
|
{"name": "view", "in": "query", "schema": map[string]interface{}{"type": "string", "enum": []string{"path", "full"}, "default": "path"}},
|
||||||
|
{"name": "exclude_deprecated", "in": "query", "schema": map[string]interface{}{"type": "boolean", "default": true}},
|
||||||
|
},
|
||||||
|
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "nodes + edges"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/projects/{id}/fact-edges": map[string]interface{}{
|
||||||
|
"get": map[string]interface{}{
|
||||||
|
"tags": []string{"项目管理"}, "summary": "列出项目全部事实边", "operationId": "listProjectFactEdges",
|
||||||
|
"parameters": []map[string]interface{}{
|
||||||
|
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||||
|
},
|
||||||
|
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "边列表"}},
|
||||||
|
},
|
||||||
|
"post": map[string]interface{}{
|
||||||
|
"tags": []string{"项目管理"}, "summary": "添加事实边", "operationId": "createProjectFactEdge",
|
||||||
|
"parameters": []map[string]interface{}{
|
||||||
|
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||||
|
},
|
||||||
|
"requestBody": map[string]interface{}{
|
||||||
|
"required": true,
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"required": []string{"source_fact_key", "target_fact_key", "edge_type"},
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"source_fact_key": map[string]interface{}{"type": "string"},
|
||||||
|
"target_fact_key": map[string]interface{}{"type": "string"},
|
||||||
|
"edge_type": map[string]interface{}{"type": "string"},
|
||||||
|
"confidence": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "边已创建"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/projects/{id}/fact-edges/{edgeId}": map[string]interface{}{
|
||||||
|
"delete": map[string]interface{}{
|
||||||
|
"tags": []string{"项目管理"}, "summary": "删除事实边", "operationId": "deleteProjectFactEdge",
|
||||||
|
"parameters": []map[string]interface{}{
|
||||||
|
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||||
|
{"name": "edgeId", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||||
|
},
|
||||||
|
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "删除成功"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/projects/{id}/promote-attack-chain/{conversationId}": map[string]interface{}{
|
||||||
|
"post": map[string]interface{}{
|
||||||
|
"tags": []string{"项目管理"}, "summary": "将对话攻击链沉淀到项目事实图", "operationId": "promoteAttackChainToProject",
|
||||||
|
"parameters": []map[string]interface{}{
|
||||||
|
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||||
|
{"name": "conversationId", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||||
|
},
|
||||||
|
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "沉淀结果(facts/edges/graph)"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/vulnerabilities": map[string]interface{}{
|
"/api/vulnerabilities": map[string]interface{}{
|
||||||
"get": map[string]interface{}{
|
"get": map[string]interface{}{
|
||||||
"tags": []string{"漏洞管理"},
|
"tags": []string{"漏洞管理"},
|
||||||
|
|||||||
+255
-21
@@ -1,10 +1,12 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/attackchain"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/project"
|
"cyberstrike-ai/internal/project"
|
||||||
|
|
||||||
@@ -223,26 +225,102 @@ func (h *ProjectHandler) DeleteProject(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type factLinkRequest struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type upsertFactRequest struct {
|
type upsertFactRequest struct {
|
||||||
FactKey string `json:"fact_key" binding:"required"`
|
FactKey string `json:"fact_key" binding:"required"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Summary string `json:"summary" binding:"required"`
|
Summary string `json:"summary" binding:"required"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Confidence string `json:"confidence"`
|
Confidence string `json:"confidence"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
RelatedVulnerabilityID string `json:"related_vulnerability_id"`
|
RelatedVulnerabilityID string `json:"related_vulnerability_id"`
|
||||||
|
Links []factLinkRequest `json:"links"`
|
||||||
|
LinksText *string `json:"links_text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateFactRequest 部分更新事实;指针字段省略=不修改,body 传 "" 可清空(仍走 merge 逻辑见 Upsert)。
|
// updateFactRequest 部分更新事实;指针字段省略=不修改,body 传 "" 可清空(仍走 merge 逻辑见 Upsert)。
|
||||||
type updateFactRequest struct {
|
type updateFactRequest struct {
|
||||||
FactKey *string `json:"fact_key"`
|
FactKey *string `json:"fact_key"`
|
||||||
Category *string `json:"category"`
|
Category *string `json:"category"`
|
||||||
Summary *string `json:"summary"`
|
Summary *string `json:"summary"`
|
||||||
Body *string `json:"body"`
|
Body *string `json:"body"`
|
||||||
Confidence *string `json:"confidence"`
|
Confidence *string `json:"confidence"`
|
||||||
Pinned *bool `json:"pinned"`
|
Pinned *bool `json:"pinned"`
|
||||||
RelatedVulnerabilityID *string `json:"related_vulnerability_id"`
|
RelatedVulnerabilityID *string `json:"related_vulnerability_id"`
|
||||||
ClearBody bool `json:"clear_body"`
|
ClearBody bool `json:"clear_body"`
|
||||||
|
Links *[]factLinkRequest `json:"links"`
|
||||||
|
LinksText *string `json:"links_text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func factLinksFromRequest(links []factLinkRequest, linksText *string) (*project.ParsedFactLinks, error) {
|
||||||
|
if len(links) > 0 {
|
||||||
|
parsed := &project.ParsedFactLinks{}
|
||||||
|
for i, l := range links {
|
||||||
|
from := strings.TrimSpace(l.From)
|
||||||
|
edgeType := strings.TrimSpace(l.Type)
|
||||||
|
if from == "" {
|
||||||
|
return nil, fmt.Errorf("links[%d] 须含 from", i)
|
||||||
|
}
|
||||||
|
if edgeType == "" {
|
||||||
|
return nil, fmt.Errorf("links[%d] 须含 type", i)
|
||||||
|
}
|
||||||
|
parsed.Incoming = append(parsed.Incoming, database.ProjectFactEdgeFromInput{
|
||||||
|
From: from, Type: edgeType, Confidence: strings.TrimSpace(l.Confidence),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
if linksText != nil {
|
||||||
|
in, err := project.ParseFactLinksText(*linksText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &project.ParsedFactLinks{Incoming: in}, nil
|
||||||
|
}
|
||||||
|
return &project.ParsedFactLinks{Incoming: []database.ProjectFactEdgeFromInput{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type factWithLinksResponse struct {
|
||||||
|
*database.ProjectFact
|
||||||
|
OutgoingLinks []*database.ProjectFactEdge `json:"outgoing_links,omitempty"`
|
||||||
|
IncomingLinks []*database.ProjectFactEdge `json:"incoming_links,omitempty"`
|
||||||
|
LinkCounts *project.LinkCounts `json:"link_counts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectHandler) applyFactLinksAfterUpsert(projectID string, fact *database.ProjectFact, links []factLinkRequest, linksText *string, explicitLinks, parseBody bool) error {
|
||||||
|
if explicitLinks {
|
||||||
|
parsed, err := factLinksFromRequest(links, linksText)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return project.PersistFactLinksFromParsed(h.db, projectID, fact.FactKey, fact.SourceConversationID, parsed, true)
|
||||||
|
}
|
||||||
|
if parseBody {
|
||||||
|
inputs := project.ParseLinksFromBody(fact.Body)
|
||||||
|
if inputs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return project.PersistFactIncomingLinks(h.db, projectID, fact.FactKey, inputs, true)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectHandler) factResponseWithLinks(projectID string, f *database.ProjectFact, includeLinks bool) interface{} {
|
||||||
|
if !includeLinks || f == nil {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
out, _ := h.db.ListOutgoingProjectFactEdges(projectID, f.FactKey)
|
||||||
|
in, _ := h.db.ListIncomingProjectFactEdges(projectID, f.FactKey)
|
||||||
|
return &factWithLinksResponse{
|
||||||
|
ProjectFact: f,
|
||||||
|
OutgoingLinks: out,
|
||||||
|
IncomingLinks: in,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListFacts GET /api/projects/:id/facts (fact_key 查询参数可获取单条详情)
|
// ListFacts GET /api/projects/:id/facts (fact_key 查询参数可获取单条详情)
|
||||||
@@ -254,7 +332,8 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, f)
|
includeLinks := c.Query("include_links") == "1" || c.Query("include_links") == "true"
|
||||||
|
c.JSON(http.StatusOK, h.factResponseWithLinks(projectID, f, includeLinks))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
|
||||||
@@ -285,7 +364,52 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
list = filtered
|
list = filtered
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, list)
|
includeLinkCounts := c.Query("include_link_counts") == "1" || c.Query("include_link_counts") == "true"
|
||||||
|
if !includeLinkCounts {
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
counts, err := project.LoadProjectFactLinkCounts(h.db, projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]factWithLinksResponse, 0, len(list))
|
||||||
|
for _, f := range list {
|
||||||
|
item := factWithLinksResponse{ProjectFact: f}
|
||||||
|
if c, ok := counts[f.FactKey]; ok {
|
||||||
|
cc := c
|
||||||
|
item.LinkCounts = &cc
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFactGraph GET /api/projects/:id/fact-graph?view=path|full
|
||||||
|
func (h *ProjectHandler) GetFactGraph(c *gin.Context) {
|
||||||
|
projectID := c.Param("id")
|
||||||
|
if _, err := h.db.GetProject(projectID); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "项目不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := c.DefaultQuery("view", "path")
|
||||||
|
excludeDeprecated := true
|
||||||
|
if v := c.Query("exclude_deprecated"); v == "0" || v == "false" {
|
||||||
|
excludeDeprecated = false
|
||||||
|
}
|
||||||
|
graph, err := project.BuildProjectFactGraph(h.db, projectID, view, excludeDeprecated)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if graph.Nodes == nil {
|
||||||
|
graph.Nodes = []database.ProjectFactGraphNode{}
|
||||||
|
}
|
||||||
|
if graph.Edges == nil {
|
||||||
|
graph.Edges = []database.ProjectFactGraphEdge{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, graph)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateFact POST /api/projects/:id/facts
|
// CreateFact POST /api/projects/:id/facts
|
||||||
@@ -295,8 +419,9 @@ func (h *ProjectHandler) CreateFact(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
projectID := c.Param("id")
|
||||||
f := &database.ProjectFact{
|
f := &database.ProjectFact{
|
||||||
ProjectID: c.Param("id"),
|
ProjectID: projectID,
|
||||||
FactKey: req.FactKey,
|
FactKey: req.FactKey,
|
||||||
Category: req.Category,
|
Category: req.Category,
|
||||||
Summary: req.Summary,
|
Summary: req.Summary,
|
||||||
@@ -310,16 +435,24 @@ func (h *ProjectHandler) CreateFact(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, created)
|
explicitLinks := req.Links != nil || req.LinksText != nil
|
||||||
|
if err := h.applyFactLinksAfterUpsert(projectID, created, req.Links, req.LinksText, explicitLinks, !explicitLinks); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, _ = h.db.GetProjectFactByKey(projectID, created.FactKey)
|
||||||
|
c.JSON(http.StatusOK, h.factResponseWithLinks(projectID, created, true))
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateFact PUT /api/projects/:id/facts/:factId
|
// UpdateFact PUT /api/projects/:id/facts/:factId
|
||||||
func (h *ProjectHandler) UpdateFact(c *gin.Context) {
|
func (h *ProjectHandler) UpdateFact(c *gin.Context) {
|
||||||
|
projectID := c.Param("id")
|
||||||
existing, err := h.db.GetProjectFact(c.Param("factId"))
|
existing, err := h.db.GetProjectFact(c.Param("factId"))
|
||||||
if err != nil || existing.ProjectID != c.Param("id") {
|
if err != nil || existing.ProjectID != projectID {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
oldFactKey := existing.FactKey
|
||||||
var req updateFactRequest
|
var req updateFactRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -355,7 +488,29 @@ func (h *ProjectHandler) UpdateFact(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, updated)
|
if oldFactKey != updated.FactKey {
|
||||||
|
if err := h.db.RenameProjectFactKeyEdges(projectID, oldFactKey, updated.FactKey); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Links != nil || req.LinksText != nil {
|
||||||
|
var links []factLinkRequest
|
||||||
|
if req.Links != nil {
|
||||||
|
links = *req.Links
|
||||||
|
}
|
||||||
|
if err := h.applyFactLinksAfterUpsert(projectID, updated, links, req.LinksText, true, false); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if req.ClearBody || req.Body != nil {
|
||||||
|
if err := h.applyFactLinksAfterUpsert(projectID, updated, nil, nil, false, true); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated, _ = h.db.GetProjectFactByKey(projectID, updated.FactKey)
|
||||||
|
c.JSON(http.StatusOK, h.factResponseWithLinks(projectID, updated, true))
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteFact DELETE /api/projects/:id/facts/:factId
|
// DeleteFact DELETE /api/projects/:id/facts/:factId
|
||||||
@@ -408,3 +563,82 @@ func (h *ProjectHandler) RestoreFact(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type createFactEdgeRequest struct {
|
||||||
|
SourceFactKey string `json:"source_fact_key" binding:"required"`
|
||||||
|
TargetFactKey string `json:"target_fact_key" binding:"required"`
|
||||||
|
EdgeType string `json:"edge_type" binding:"required"`
|
||||||
|
Confidence string `json:"confidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFactEdges GET /api/projects/:id/fact-edges
|
||||||
|
func (h *ProjectHandler) ListFactEdges(c *gin.Context) {
|
||||||
|
projectID := c.Param("id")
|
||||||
|
edges, err := h.db.ListProjectFactEdgesByProject(projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if edges == nil {
|
||||||
|
edges = []*database.ProjectFactEdge{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFactEdge POST /api/projects/:id/fact-edges
|
||||||
|
func (h *ProjectHandler) CreateFactEdge(c *gin.Context) {
|
||||||
|
projectID := c.Param("id")
|
||||||
|
var req createFactEdgeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
edge, err := h.db.AddProjectFactEdge(projectID, database.ProjectFactEdgeInput{
|
||||||
|
To: req.TargetFactKey,
|
||||||
|
Type: req.EdgeType,
|
||||||
|
Confidence: req.Confidence,
|
||||||
|
}, req.SourceFactKey, "")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f, err := h.db.GetProjectFactByKey(projectID, req.TargetFactKey); err == nil {
|
||||||
|
in, _ := h.db.ListIncomingProjectFactEdges(projectID, req.TargetFactKey)
|
||||||
|
f.Body = project.SyncBodyLinksSection(f.Body, in)
|
||||||
|
_, _ = h.db.UpsertProjectFact(f)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, edge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFactEdge DELETE /api/projects/:id/fact-edges/:edgeId
|
||||||
|
func (h *ProjectHandler) DeleteFactEdge(c *gin.Context) {
|
||||||
|
projectID := c.Param("id")
|
||||||
|
edgeID := c.Param("edgeId")
|
||||||
|
edge, err := h.db.GetProjectFactEdge(edgeID)
|
||||||
|
if err != nil || edge.ProjectID != projectID {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "边不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.DeleteProjectFactEdge(edgeID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f, err := h.db.GetProjectFactByKey(projectID, edge.TargetFactKey); err == nil {
|
||||||
|
in, _ := h.db.ListIncomingProjectFactEdges(projectID, edge.TargetFactKey)
|
||||||
|
f.Body = project.SyncBodyLinksSection(f.Body, in)
|
||||||
|
_, _ = h.db.UpsertProjectFact(f)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromoteAttackChain POST /api/projects/:id/promote-attack-chain/:conversationId
|
||||||
|
func (h *ProjectHandler) PromoteAttackChain(c *gin.Context) {
|
||||||
|
projectID := c.Param("id")
|
||||||
|
conversationID := c.Param("conversationId")
|
||||||
|
result, err := attackchain.PromoteToProject(h.db, projectID, conversationID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package project
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
@@ -24,11 +23,11 @@ func AppendSystemPromptBlock(base, block string) string {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
factIndexFooterGetDetail = "需要完整内容(攻击链、POC、请求响应等)时必须调用 get_project_fact(fact_key),禁止凭摘要臆造细节。"
|
factIndexFooterGetDetail = "需要完整内容(攻击链、POC、请求响应等)时必须调用 get_project_fact(fact_key),禁止凭摘要臆造细节。"
|
||||||
factIndexFooterWriteHint = "写入事实时:summary 写「什么+在哪+如何验证」;body 写可复现全流程(发现/利用类 fact_key 建议 finding|chain|exploit|poc/ 前缀)。"
|
factIndexFooterWriteHint = "写入事实 links 时用 from(来源 fact_key → 当前 fact),如 finding 上 {from:target/*, type:discovered_on};body 写可复现全流程(发现/利用类 fact_key 建议 finding|chain|exploit|poc/ 前缀)。"
|
||||||
factIndexFooterEmpty = "需要写入请使用 upsert_project_fact;需要详情请调用 get_project_fact(fact_key)。"
|
factIndexFooterEmpty = "需要写入请使用 upsert_project_fact;需要详情请调用 get_project_fact(fact_key)。"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BuildFactIndexBlock 为 Agent 系统提示生成项目黑板索引(仅 key + summary,不含 body)。
|
// BuildFactIndexBlock 为 Agent 系统提示生成项目黑板索引(key + summary + 关系边 + 攻击路径,不含 body)。
|
||||||
func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectConfig) (string, error) {
|
func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectConfig) (string, error) {
|
||||||
if db == nil || !cfg.Enabled {
|
if db == nil || !cfg.Enabled {
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -47,27 +46,38 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
allEdges, _ := db.ListProjectFactEdgesByProject(projectID)
|
||||||
|
_, incomingByTarget := indexEdgeGroupMaps(allEdges)
|
||||||
|
|
||||||
if len(facts) == 0 {
|
if len(facts) == 0 {
|
||||||
return wrapFactIndexBlock(fmt.Sprintf("## 项目黑板索引(project: %s, id: %s)\n(暂无事实)\n%s", proj.Name, proj.ID, factIndexFooterEmpty)), nil
|
return wrapFactIndexBlock(fmt.Sprintf("## 项目黑板索引(project: %s, id: %s)\n(暂无事实)\n%s", proj.Name, proj.ID, factIndexFooterEmpty)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.SliceStable(facts, func(i, j int) bool {
|
sortFactsForIndex(facts)
|
||||||
if facts[i].Pinned != facts[j].Pinned {
|
|
||||||
return facts[i].Pinned
|
|
||||||
}
|
|
||||||
return facts[i].UpdatedAt.After(facts[j].UpdatedAt)
|
|
||||||
})
|
|
||||||
|
|
||||||
maxRunes := cfg.FactIndexMaxRunesEffective()
|
maxRunes := cfg.FactIndexMaxRunesEffective()
|
||||||
|
pathMaxRunes := cfg.FactIndexPathMaxRunesEffective()
|
||||||
|
footer := factIndexFooterGetDetail + "\n" + factIndexFooterWriteHint
|
||||||
|
footerRunes := len([]rune(footer))
|
||||||
|
factsBudget := maxRunes - pathMaxRunes - footerRunes
|
||||||
|
if factsBudget < 800 {
|
||||||
|
factsBudget = maxRunes - footerRunes
|
||||||
|
pathMaxRunes = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
indexedKeys := make(map[string]struct{}, len(facts))
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("## 项目黑板索引(project: %s, id: %s)\n", proj.Name, proj.ID))
|
b.WriteString(fmt.Sprintf("## 项目黑板索引(project: %s, id: %s)\n", proj.Name, proj.ID))
|
||||||
used := len([]rune(b.String()))
|
used := len([]rune(b.String()))
|
||||||
omitted := 0
|
omitted := 0
|
||||||
|
|
||||||
for _, f := range facts {
|
for _, f := range facts {
|
||||||
line := fmt.Sprintf("- [%s] %s — %s (%s)\n", f.FactKey, f.Category, strings.TrimSpace(f.Summary), f.Confidence)
|
indexedKeys[f.FactKey] = struct{}{}
|
||||||
|
line := fmt.Sprintf("- [%s] %s — %s (%s)", f.FactKey, f.Category, strings.TrimSpace(f.Summary), f.Confidence)
|
||||||
|
line += FormatFactIndexLinksHint(f.FactKey, incomingByTarget[f.FactKey])
|
||||||
|
line += "\n"
|
||||||
lineRunes := len([]rune(line))
|
lineRunes := len([]rune(line))
|
||||||
if used+lineRunes > maxRunes {
|
if used+lineRunes > factsBudget {
|
||||||
omitted++
|
omitted++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -78,8 +88,12 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo
|
|||||||
if omitted > 0 {
|
if omitted > 0 {
|
||||||
b.WriteString(fmt.Sprintf("\n(另有 %d 条未列入索引,请使用 list_project_facts 或 search_project_facts 查询。)\n", omitted))
|
b.WriteString(fmt.Sprintf("\n(另有 %d 条未列入索引,请使用 list_project_facts 或 search_project_facts 查询。)\n", omitted))
|
||||||
}
|
}
|
||||||
b.WriteString(factIndexFooterGetDetail)
|
|
||||||
b.WriteByte('\n')
|
if pathSection := BuildFactPathOverviewSection(allEdges, indexedKeys, pathMaxRunes); pathSection != "" {
|
||||||
b.WriteString(factIndexFooterWriteHint)
|
b.WriteString("\n")
|
||||||
|
b.WriteString(pathSection)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(footer)
|
||||||
return wrapFactIndexBlock(b.String()), nil
|
return wrapFactIndexBlock(b.String()), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
bodyDepFactLine = regexp.MustCompile(`(?im)^[\s\-*]*依赖事实\s*[::]\s*([a-z0-9][a-z0-9._/-]*)`)
|
||||||
|
bodyRelFactLine = regexp.MustCompile(`(?im)^[\s\-*]*相关\s*fact_key\s*[::]\s*([a-z0-9][a-z0-9._/-]*)`)
|
||||||
|
bodyAssocSection = regexp.MustCompile(`(?im)^##\s*关联\s*$`)
|
||||||
|
bodySyncLinksHead = "结构化关系边(自动同步)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseLinksFromBody 从 body「关联」段落解析 from 语义的关系边(无显式 links 时的兜底)。
|
||||||
|
func ParseLinksFromBody(body string) []database.ProjectFactEdgeFromInput {
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
if body == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
var out []database.ProjectFactEdgeFromInput
|
||||||
|
add := func(key, edgeType string) {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := database.ValidateFactKey(key); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sig := edgeType + "\x00" + key
|
||||||
|
if _, ok := seen[sig]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[sig] = struct{}{}
|
||||||
|
out = append(out, database.ProjectFactEdgeFromInput{From: key, Type: edgeType})
|
||||||
|
}
|
||||||
|
for _, m := range bodyDepFactLine.FindAllStringSubmatch(body, -1) {
|
||||||
|
if len(m) > 1 {
|
||||||
|
add(m[1], "depends_on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range bodyRelFactLine.FindAllStringSubmatch(body, -1) {
|
||||||
|
if len(m) > 1 {
|
||||||
|
add(m[1], "supports")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 自动同步块:type: key
|
||||||
|
syncBlock := extractBodySyncLinksBlock(body)
|
||||||
|
for _, line := range strings.Split(syncBlock, "\n") {
|
||||||
|
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-"))
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
edgeType, source, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
edgeType = strings.TrimSpace(edgeType)
|
||||||
|
source = strings.TrimSpace(source)
|
||||||
|
if err := database.ValidateProjectFactEdgeType(edgeType); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
add(source, edgeType)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBodySyncLinksBlock(body string) string {
|
||||||
|
lines := strings.Split(body, "\n")
|
||||||
|
var b strings.Builder
|
||||||
|
inAssoc := false
|
||||||
|
inSync := false
|
||||||
|
for _, line := range lines {
|
||||||
|
trim := strings.TrimSpace(line)
|
||||||
|
if bodyAssocSection.MatchString(trim) {
|
||||||
|
inAssoc = true
|
||||||
|
inSync = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inAssoc && strings.HasPrefix(trim, "## ") && !strings.HasPrefix(trim, "## 关联") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if inAssoc && strings.Contains(trim, bodySyncLinksHead) {
|
||||||
|
inSync = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inSync {
|
||||||
|
if trim == "" || strings.HasPrefix(trim, "-") || strings.Contains(trim, ":") {
|
||||||
|
if strings.HasPrefix(trim, "-") || (strings.Contains(trim, ":") && !strings.Contains(trim, "related_vulnerability")) {
|
||||||
|
b.WriteString(trim)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(trim, "##") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncBodyLinksSection 将入边镜像写入 body 的「关联」段(人读用;结构化以 links 为准)。
|
||||||
|
func SyncBodyLinksSection(body string, edges []*database.ProjectFactEdge) string {
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
block := formatBodySyncLinksBlock(edges)
|
||||||
|
if block == "" {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
return "## 关联\n" + block
|
||||||
|
}
|
||||||
|
lines := strings.Split(body, "\n")
|
||||||
|
var out []string
|
||||||
|
inAssoc := false
|
||||||
|
replaced := false
|
||||||
|
for i := 0; i < len(lines); i++ {
|
||||||
|
trim := strings.TrimSpace(lines[i])
|
||||||
|
if bodyAssocSection.MatchString(trim) {
|
||||||
|
inAssoc = true
|
||||||
|
out = append(out, lines[i])
|
||||||
|
// 跳过旧同步块
|
||||||
|
j := i + 1
|
||||||
|
for j < len(lines) {
|
||||||
|
t := strings.TrimSpace(lines[j])
|
||||||
|
if strings.HasPrefix(t, "## ") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if strings.Contains(t, bodySyncLinksHead) {
|
||||||
|
for j < len(lines) {
|
||||||
|
t2 := strings.TrimSpace(lines[j])
|
||||||
|
if t2 != "" && !strings.HasPrefix(t2, "-") && !strings.Contains(t2, ":") && !strings.Contains(t2, bodySyncLinksHead) {
|
||||||
|
if strings.HasPrefix(t2, "##") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
if j < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[j]), "## ") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if j >= len(lines) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if j > i+1 && strings.TrimSpace(lines[j-1]) == "" && strings.HasPrefix(strings.TrimSpace(lines[j]), "## ") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
out = append(out, block)
|
||||||
|
i = j - 1
|
||||||
|
replaced = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, lines[i])
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
if !inAssoc {
|
||||||
|
out = append(out, "", "## 关联", block)
|
||||||
|
} else {
|
||||||
|
out = append(out, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBodySyncLinksBlock(edges []*database.ProjectFactEdge) string {
|
||||||
|
if len(edges) == 0 {
|
||||||
|
return fmt.Sprintf("- %s:\n (暂无)", bodySyncLinksHead)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("- ")
|
||||||
|
b.WriteString(bodySyncLinksHead)
|
||||||
|
b.WriteString(":\n")
|
||||||
|
for _, e := range edges {
|
||||||
|
b.WriteString(fmt.Sprintf(" - %s: %s\n", e.EdgeType, e.SourceFactKey))
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFactLinksForUpsert 合并显式 links、links_text 与 body 解析结果。
|
||||||
|
func ResolveFactLinksForUpsert(explicit []database.ProjectFactEdgeFromInput, linksText *string, body string, explicitSet bool) ([]database.ProjectFactEdgeFromInput, bool, error) {
|
||||||
|
if explicitSet {
|
||||||
|
if len(explicit) > 0 {
|
||||||
|
return explicit, true, nil
|
||||||
|
}
|
||||||
|
if linksText != nil {
|
||||||
|
parsed, err := ParseFactLinksText(*linksText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
if parsed == nil {
|
||||||
|
return []database.ProjectFactEdgeFromInput{}, true, nil
|
||||||
|
}
|
||||||
|
return parsed, true, nil
|
||||||
|
}
|
||||||
|
return []database.ProjectFactEdgeFromInput{}, true, nil
|
||||||
|
}
|
||||||
|
if parsed := ParseLinksFromBody(body); len(parsed) > 0 {
|
||||||
|
return parsed, true, nil
|
||||||
|
}
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeLinkFromInputsUnique 合并多组 from 入边输入并去重。
|
||||||
|
func MergeLinkFromInputsUnique(groups ...[]database.ProjectFactEdgeFromInput) []database.ProjectFactEdgeFromInput {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
var out []database.ProjectFactEdgeFromInput
|
||||||
|
for _, g := range groups {
|
||||||
|
for _, in := range g {
|
||||||
|
sig := in.Type + "\x00" + in.From
|
||||||
|
if _, ok := seen[sig]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := database.ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := database.ValidateFactKey(in.From); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[sig] = struct{}{}
|
||||||
|
out = append(out, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeLinkInputsUnique 合并多组 link 输入并去重(内部出边写入用)。
|
||||||
|
func MergeLinkInputsUnique(groups ...[]database.ProjectFactEdgeInput) []database.ProjectFactEdgeInput {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
var out []database.ProjectFactEdgeInput
|
||||||
|
for _, g := range groups {
|
||||||
|
for _, in := range g {
|
||||||
|
sig := in.Type + "\x00" + in.To
|
||||||
|
if _, ok := seen[sig]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := database.ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := database.ValidateFactKey(in.To); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[sig] = struct{}{}
|
||||||
|
out = append(out, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseLinksFromBodyDependsOn(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
body := "## 关联\n- 依赖事实: target/api\n- 相关 fact_key: auth/session"
|
||||||
|
links := ParseLinksFromBody(body)
|
||||||
|
if len(links) != 2 {
|
||||||
|
t.Fatalf("want 2 links, got %d", len(links))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncBodyLinksSection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
body := "## 结论\nx\n\n## 关联\n- 依赖事实: old/key"
|
||||||
|
edges := []*database.ProjectFactEdge{{EdgeType: "discovered_on", SourceFactKey: "target/a"}}
|
||||||
|
out := SyncBodyLinksSection(body, edges)
|
||||||
|
if !strings.Contains(out, "discovered_on: target/a") {
|
||||||
|
t.Fatalf("missing synced edge: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactGraphIntegration(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "test.db")
|
||||||
|
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
p, err := db.CreateProject(&database.Project{Name: "g"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, spec := range []struct{ key, cat, summary string }{
|
||||||
|
{"target/root", "target", "root"},
|
||||||
|
{"finding/x", "finding", "finding x"},
|
||||||
|
} {
|
||||||
|
_, err := db.UpsertProjectFact(&database.ProjectFact{
|
||||||
|
ProjectID: p.ID, FactKey: spec.key, Category: spec.cat, Summary: spec.summary, Confidence: "confirmed",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.ReplaceIncomingProjectFactEdges(p.ID, "finding/x", []database.ProjectFactEdgeFromInput{
|
||||||
|
{From: "target/root", Type: "discovered_on"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
graph, err := BuildProjectFactGraph(db, p.ID, "path", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(graph.Nodes) < 2 || len(graph.Edges) < 1 {
|
||||||
|
t.Fatalf("expected graph nodes/edges, got %d/%d", len(graph.Nodes), len(graph.Edges))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/projectprompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PathGraphCategories 攻击路径视图包含的事实分类。
|
||||||
|
var PathGraphCategories = map[string]struct{}{
|
||||||
|
FactCategoryTarget: {},
|
||||||
|
FactCategoryFinding: {},
|
||||||
|
FactCategoryChain: {},
|
||||||
|
FactCategoryExploit: {},
|
||||||
|
FactCategoryPOC: {},
|
||||||
|
"vuln": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// GraphNodeType 将 fact category 映射为图节点类型(供前端样式与 ELK 分层)。
|
||||||
|
// 优先使用 category;仅 synthetic 节点(vuln:)或无 category 时才回退到 fact_key 前缀。
|
||||||
|
func GraphNodeType(category, factKey string) string {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(factKey))
|
||||||
|
if strings.HasPrefix(key, "vuln:") {
|
||||||
|
return "vulnerability"
|
||||||
|
}
|
||||||
|
c := strings.ToLower(strings.TrimSpace(category))
|
||||||
|
if c != "" {
|
||||||
|
switch c {
|
||||||
|
case FactCategoryTarget:
|
||||||
|
return "target"
|
||||||
|
case FactCategoryExploit:
|
||||||
|
return "exploit"
|
||||||
|
case FactCategoryPOC:
|
||||||
|
return "poc"
|
||||||
|
case FactCategoryChain:
|
||||||
|
return "chain"
|
||||||
|
case FactCategoryFinding:
|
||||||
|
return "finding"
|
||||||
|
case "vuln":
|
||||||
|
return "vulnerability"
|
||||||
|
case FactCategoryAuth:
|
||||||
|
return "auth"
|
||||||
|
case FactCategoryInfra, FactCategoryBusiness:
|
||||||
|
return "infra"
|
||||||
|
case FactCategoryNote:
|
||||||
|
return "note"
|
||||||
|
case "missing":
|
||||||
|
return "missing"
|
||||||
|
default:
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(key, "target/"):
|
||||||
|
return "target"
|
||||||
|
case strings.HasPrefix(key, "exploit/"), strings.HasPrefix(key, "evidence/"):
|
||||||
|
return "exploit"
|
||||||
|
case strings.HasPrefix(key, "poc/"):
|
||||||
|
return "poc"
|
||||||
|
case strings.HasPrefix(key, "chain/"):
|
||||||
|
return "chain"
|
||||||
|
case strings.HasPrefix(key, "finding/"):
|
||||||
|
return "finding"
|
||||||
|
case strings.HasPrefix(key, "auth/"):
|
||||||
|
return "auth"
|
||||||
|
case strings.HasPrefix(key, "infra/"), strings.HasPrefix(key, "business/"):
|
||||||
|
return "infra"
|
||||||
|
default:
|
||||||
|
return "note"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateGraphLabel(summary string, maxRunes int) string {
|
||||||
|
summary = strings.TrimSpace(summary)
|
||||||
|
if summary == "" {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
r := []rune(summary)
|
||||||
|
if len(r) <= maxRunes {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
return string(r[:maxRunes]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildProjectFactGraph 构建项目事实图(nodes + edges)。
|
||||||
|
func BuildProjectFactGraph(db *database.DB, projectID string, view string, excludeDeprecated bool) (*database.ProjectFactGraph, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, fmt.Errorf("database 未初始化")
|
||||||
|
}
|
||||||
|
projectID = strings.TrimSpace(projectID)
|
||||||
|
if projectID == "" {
|
||||||
|
return nil, fmt.Errorf("project_id 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
view = strings.TrimSpace(strings.ToLower(view))
|
||||||
|
if view == "" {
|
||||||
|
view = "path"
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := database.ProjectFactListFilter{}
|
||||||
|
if excludeDeprecated {
|
||||||
|
filter.ExcludeDeprecated = true
|
||||||
|
}
|
||||||
|
facts, err := db.ListProjectFacts(projectID, filter, 1000, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
edges, err := db.ListProjectFactEdgesByProject(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if excludeDeprecated {
|
||||||
|
edges = filterDeprecatedEdges(edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
factByKey := make(map[string]*database.ProjectFact, len(facts))
|
||||||
|
for _, f := range facts {
|
||||||
|
factByKey[f.FactKey] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
pathMode := view == "path"
|
||||||
|
nodeKeys := make(map[string]struct{})
|
||||||
|
|
||||||
|
if pathMode {
|
||||||
|
for _, f := range facts {
|
||||||
|
if isPathGraphFact(f.Category, f.FactKey) {
|
||||||
|
nodeKeys[f.FactKey] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 路径视图中保留作为依赖目标的 auth/infra 节点
|
||||||
|
for _, e := range edges {
|
||||||
|
if _, ok := nodeKeys[e.SourceFactKey]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f, ok := factByKey[e.TargetFactKey]; ok && isDependencyGraphFact(f.Category, f.FactKey) {
|
||||||
|
nodeKeys[e.TargetFactKey] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, f := range facts {
|
||||||
|
nodeKeys[f.FactKey] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边上引用的 endpoint 纳入节点集
|
||||||
|
for _, e := range edges {
|
||||||
|
if pathMode {
|
||||||
|
if _, ok := nodeKeys[e.SourceFactKey]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := nodeKeys[e.TargetFactKey]; ok {
|
||||||
|
// already included
|
||||||
|
} else if f, ok := factByKey[e.TargetFactKey]; !ok {
|
||||||
|
nodeKeys[e.TargetFactKey] = struct{}{} // 占位节点
|
||||||
|
} else if isPathGraphFact(f.Category, f.FactKey) || isDependencyGraphFact(f.Category, f.FactKey) {
|
||||||
|
nodeKeys[e.TargetFactKey] = struct{}{}
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nodeKeys[e.SourceFactKey] = struct{}{}
|
||||||
|
nodeKeys[e.TargetFactKey] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := make([]database.ProjectFactGraphNode, 0, len(nodeKeys))
|
||||||
|
for key := range nodeKeys {
|
||||||
|
if f, ok := factByKey[key]; ok {
|
||||||
|
nodes = append(nodes, database.ProjectFactGraphNode{
|
||||||
|
ID: f.FactKey,
|
||||||
|
FactKey: f.FactKey,
|
||||||
|
Category: f.Category,
|
||||||
|
Label: truncateGraphLabel(f.Summary, 48),
|
||||||
|
Summary: strings.TrimSpace(f.Summary),
|
||||||
|
Confidence: f.Confidence,
|
||||||
|
Type: GraphNodeType(f.Category, f.FactKey),
|
||||||
|
Pinned: f.Pinned,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nodes = append(nodes, database.ProjectFactGraphNode{
|
||||||
|
ID: key,
|
||||||
|
FactKey: key,
|
||||||
|
Category: "missing",
|
||||||
|
Label: key,
|
||||||
|
Confidence: "tentative",
|
||||||
|
Type: "missing",
|
||||||
|
Pinned: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
graphEdges := make([]database.ProjectFactGraphEdge, 0, len(edges))
|
||||||
|
for _, e := range edges {
|
||||||
|
if pathMode {
|
||||||
|
if _, ok := nodeKeys[e.SourceFactKey]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := nodeKeys[e.TargetFactKey]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, ok := nodeKeys[e.SourceFactKey]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := nodeKeys[e.TargetFactKey]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graphEdges = append(graphEdges, database.ProjectFactGraphEdge{
|
||||||
|
ID: e.ID,
|
||||||
|
Source: e.SourceFactKey,
|
||||||
|
Target: e.TargetFactKey,
|
||||||
|
Type: e.EdgeType,
|
||||||
|
Confidence: e.Confidence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// related_vulnerability_id 合成边(source=fact → target=vuln:<id>)
|
||||||
|
for _, f := range facts {
|
||||||
|
if _, ok := nodeKeys[f.FactKey]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vid := strings.TrimSpace(f.RelatedVulnerabilityID)
|
||||||
|
if vid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vulnNodeID := "vuln:" + vid
|
||||||
|
if _, exists := nodeKeys[vulnNodeID]; !exists {
|
||||||
|
nodeKeys[vulnNodeID] = struct{}{}
|
||||||
|
label := "漏洞"
|
||||||
|
if len(vid) >= 8 {
|
||||||
|
label += " " + vid[:8] + "…"
|
||||||
|
} else {
|
||||||
|
label += " " + vid
|
||||||
|
}
|
||||||
|
nodes = append(nodes, database.ProjectFactGraphNode{
|
||||||
|
ID: vulnNodeID,
|
||||||
|
FactKey: vulnNodeID,
|
||||||
|
Category: "vuln",
|
||||||
|
Label: label,
|
||||||
|
Confidence: f.Confidence,
|
||||||
|
Type: "vulnerability",
|
||||||
|
Pinned: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
graphEdges = append(graphEdges, database.ProjectFactGraphEdge{
|
||||||
|
ID: "vuln-link:" + f.FactKey + ":" + vid,
|
||||||
|
Source: f.FactKey,
|
||||||
|
Target: vulnNodeID,
|
||||||
|
Type: "links_vuln",
|
||||||
|
Confidence: f.Confidence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &database.ProjectFactGraph{Nodes: nodes, Edges: graphEdges}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPathGraphFact(category, factKey string) bool {
|
||||||
|
c := strings.ToLower(strings.TrimSpace(category))
|
||||||
|
if _, ok := PathGraphCategories[c]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if c != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(factKey))
|
||||||
|
for _, p := range []string{"target/", "finding/", "chain/", "exploit/", "poc/", "evidence/"} {
|
||||||
|
if strings.HasPrefix(key, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDependencyGraphFact(category, factKey string) bool {
|
||||||
|
c := strings.ToLower(strings.TrimSpace(category))
|
||||||
|
if c == FactCategoryAuth || c == FactCategoryInfra || c == FactCategoryBusiness {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if c != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(factKey))
|
||||||
|
return strings.HasPrefix(key, "auth/") || strings.HasPrefix(key, "infra/") || strings.HasPrefix(key, "business/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterDeprecatedEdges(edges []*database.ProjectFactEdge) []*database.ProjectFactEdge {
|
||||||
|
out := make([]*database.ProjectFactEdge, 0, len(edges))
|
||||||
|
for _, e := range edges {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(e.Confidence), "deprecated") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedFactLinks 解析 links 参数(from → 当前 fact)。
|
||||||
|
type ParsedFactLinks struct {
|
||||||
|
Incoming []database.ProjectFactEdgeFromInput
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFactLinkInputs 从 MCP links 参数解析;空数组表示清空全部入边。
|
||||||
|
func ParseFactLinkInputs(raw interface{}) (*ParsedFactLinks, error) {
|
||||||
|
if raw == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
items, ok := raw.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("links 须为数组")
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return &ParsedFactLinks{
|
||||||
|
Incoming: []database.ProjectFactEdgeFromInput{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
parsed := &ParsedFactLinks{}
|
||||||
|
for i, item := range items {
|
||||||
|
m, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("links[%d] 格式无效", i)
|
||||||
|
}
|
||||||
|
from, _ := m["from"].(string)
|
||||||
|
edgeType, _ := m["type"].(string)
|
||||||
|
from = strings.TrimSpace(from)
|
||||||
|
edgeType = strings.TrimSpace(edgeType)
|
||||||
|
if from == "" {
|
||||||
|
return nil, fmt.Errorf("links[%d] 须含 from", i)
|
||||||
|
}
|
||||||
|
if edgeType == "" {
|
||||||
|
return nil, fmt.Errorf("links[%d] 须含 type", i)
|
||||||
|
}
|
||||||
|
conf, _ := m["confidence"].(string)
|
||||||
|
parsed.Incoming = append(parsed.Incoming, database.ProjectFactEdgeFromInput{
|
||||||
|
From: from, Type: edgeType, Confidence: strings.TrimSpace(conf),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFactLinksText 解析 UI 文本:`type: source_fact_key` 每行一条(from 语义)。
|
||||||
|
func ParseFactLinksText(text string) ([]database.ProjectFactEdgeFromInput, error) {
|
||||||
|
return ParseFactIncomingLinksText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatFactLinksText 将入边格式化为 UI 文本。
|
||||||
|
func FormatFactLinksText(edges []*database.ProjectFactEdge) string {
|
||||||
|
return FormatFactIncomingLinksText(edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFactIncomingLinksText 解析 UI 入边文本:`type: source_fact_key` 每行一条。
|
||||||
|
func ParseFactIncomingLinksText(text string) ([]database.ProjectFactEdgeFromInput, error) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var out []database.ProjectFactEdgeFromInput
|
||||||
|
for i, line := range strings.Split(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
edgeType, source, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("第 %d 行格式无效,应为 type: fact_key", i+1)
|
||||||
|
}
|
||||||
|
edgeType = strings.TrimSpace(edgeType)
|
||||||
|
source = strings.TrimSpace(source)
|
||||||
|
if edgeType == "" || source == "" {
|
||||||
|
return nil, fmt.Errorf("第 %d 行 type 或 fact_key 为空", i+1)
|
||||||
|
}
|
||||||
|
out = append(out, database.ProjectFactEdgeFromInput{From: source, Type: edgeType})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatFactIncomingLinksText 将入边格式化为 UI 文本。
|
||||||
|
func FormatFactIncomingLinksText(edges []*database.ProjectFactEdge) string {
|
||||||
|
if len(edges) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for i, e := range edges {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.WriteString(e.EdgeType)
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(e.SourceFactKey)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactEdgeRecordingGuidance 写入边时的 Agent 规范。
|
||||||
|
func FactEdgeRecordingGuidance() string {
|
||||||
|
return projectprompt.FactEdgeRecordingGuidance()
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplyFactOutgoingLinks 替换某事实的出边(links 为 nil 时不修改)。
|
||||||
|
func ApplyFactOutgoingLinks(db *database.DB, projectID, sourceFactKey, sourceConversationID string, links []database.ProjectFactEdgeInput) error {
|
||||||
|
if links == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return db.ReplaceOutgoingProjectFactEdges(projectID, sourceFactKey, sourceConversationID, links)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFactLinkInputs 合并 links 数组与 links_text 文本(数组优先)。
|
||||||
|
func ResolveFactLinkInputs(links []database.ProjectFactEdgeFromInput, linksText string) ([]database.ProjectFactEdgeFromInput, error) {
|
||||||
|
if len(links) > 0 {
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
return ParseFactLinksText(linksText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyFactIncomingLinks 替换某事实的入边(links 为 nil 时不修改)。
|
||||||
|
func ApplyFactIncomingLinks(db *database.DB, projectID, targetFactKey string, links []database.ProjectFactEdgeFromInput) error {
|
||||||
|
if links == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return db.ReplaceIncomingProjectFactEdges(projectID, targetFactKey, links)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersistFactIncomingLinks 写入入边并可选同步当前事实 body「关联」段。
|
||||||
|
func PersistFactIncomingLinks(db *database.DB, projectID, targetFactKey string, links []database.ProjectFactEdgeFromInput, syncBody bool) error {
|
||||||
|
if links == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := ApplyFactIncomingLinks(db, projectID, targetFactKey, links); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !syncBody {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f, err := db.GetProjectFactByKey(projectID, targetFactKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
in, err := db.ListIncomingProjectFactEdges(projectID, targetFactKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.Body = SyncBodyLinksSection(f.Body, in)
|
||||||
|
_, err = db.UpsertProjectFact(f)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersistFactLinksFromParsed 写入解析后的 links(parsed 为 nil 表示不修改)。
|
||||||
|
func PersistFactLinksFromParsed(db *database.DB, projectID, factKey, sourceConversationID string, parsed *ParsedFactLinks, syncBody bool) error {
|
||||||
|
if parsed == nil || parsed.Incoming == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return PersistFactIncomingLinks(db, projectID, factKey, parsed.Incoming, syncBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersistFactOutgoingLinks 写入出边(图连线等低层 API;body 同步请用 PersistFactIncomingLinks)。
|
||||||
|
func PersistFactOutgoingLinks(db *database.DB, projectID, sourceFactKey, sourceConversationID string, links []database.ProjectFactEdgeInput, syncBody bool) error {
|
||||||
|
if links == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ApplyFactOutgoingLinks(db, projectID, sourceFactKey, sourceConversationID, links)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkCountMap 项目内各 fact 的入/出边计数。
|
||||||
|
type LinkCountMap map[string]LinkCounts
|
||||||
|
|
||||||
|
// LinkCounts 单 fact 的入/出边数。
|
||||||
|
type LinkCounts struct {
|
||||||
|
Outgoing int `json:"outgoing"`
|
||||||
|
Incoming int `json:"incoming"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadProjectFactLinkCounts 批量加载边计数。
|
||||||
|
func LoadProjectFactLinkCounts(db *database.DB, projectID string) (LinkCountMap, error) {
|
||||||
|
edges, err := db.ListProjectFactEdgesByProject(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := LinkCountMap{}
|
||||||
|
for _, e := range edges {
|
||||||
|
c := m[e.SourceFactKey]
|
||||||
|
c.Outgoing++
|
||||||
|
m[e.SourceFactKey] = c
|
||||||
|
c = m[e.TargetFactKey]
|
||||||
|
c.Incoming++
|
||||||
|
m[e.TargetFactKey] = c
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseFactLinksText(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
inputs, err := ParseFactLinksText("discovered_on: target/api\nleads_to: finding/swagger")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(inputs) != 2 {
|
||||||
|
t.Fatalf("want 2 links, got %d", len(inputs))
|
||||||
|
}
|
||||||
|
if inputs[0].Type != "discovered_on" || inputs[0].From != "target/api" {
|
||||||
|
t.Fatalf("unexpected first link: %+v", inputs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFactIncomingLinksText(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
inputs, err := ParseFactIncomingLinksText("leads_to: finding/swagger\ndepends_on: target/api")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(inputs) != 2 {
|
||||||
|
t.Fatalf("want 2 links, got %d", len(inputs))
|
||||||
|
}
|
||||||
|
if inputs[0].Type != "leads_to" || inputs[0].From != "finding/swagger" {
|
||||||
|
t.Fatalf("unexpected first link: %+v", inputs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatFactIncomingLinksText(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
text := FormatFactIncomingLinksText([]*database.ProjectFactEdge{
|
||||||
|
{EdgeType: "leads_to", SourceFactKey: "finding/a"},
|
||||||
|
{EdgeType: "depends_on", SourceFactKey: "target/b"},
|
||||||
|
})
|
||||||
|
want := "leads_to: finding/a\ndepends_on: target/b"
|
||||||
|
if text != want {
|
||||||
|
t.Fatalf("got %q want %q", text, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFactLinkInputsEmptyClears(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
parsed, err := ParseFactLinkInputs([]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if parsed == nil || parsed.Incoming == nil || len(parsed.Incoming) != 0 {
|
||||||
|
t.Fatalf("empty array should clear incoming links, got %v", parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFactLinkInputsFrom(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
raw := []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"from": "target/primary_domain",
|
||||||
|
"type": "discovered_on",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
parsed, err := ParseFactLinkInputs(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(parsed.Incoming) != 1 || parsed.Incoming[0].From != "target/primary_domain" {
|
||||||
|
t.Fatalf("unexpected incoming: %+v", parsed.Incoming)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFactLinkInputsRequiresFrom(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
raw := []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"to": "target/primary_domain",
|
||||||
|
"type": "discovered_on",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := ParseFactLinkInputs(raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when from is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphNodeType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if GraphNodeType("chain", "chain/x") != "chain" {
|
||||||
|
t.Fatal("chain category")
|
||||||
|
}
|
||||||
|
if GraphNodeType("finding", "finding/x") != "finding" {
|
||||||
|
t.Fatal("finding category")
|
||||||
|
}
|
||||||
|
if GraphNodeType("exploit", "exploit/x") != "exploit" {
|
||||||
|
t.Fatal("exploit category")
|
||||||
|
}
|
||||||
|
if GraphNodeType("finding", "evidence/x") != "finding" {
|
||||||
|
t.Fatal("category should override evidence key prefix")
|
||||||
|
}
|
||||||
|
if GraphNodeType("note", "target/x") != "note" {
|
||||||
|
t.Fatal("category should override target key prefix")
|
||||||
|
}
|
||||||
|
if GraphNodeType("vuln", "finding/x") != "vulnerability" {
|
||||||
|
t.Fatal("vuln category maps to vulnerability node type")
|
||||||
|
}
|
||||||
|
if GraphNodeType("", "target/x") != "target" {
|
||||||
|
t.Fatal("empty category falls back to target key prefix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildProjectFactGraphPreservesStoredEdgeDirection(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(dir, "test.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
p, err := db.CreateProject(&database.Project{Name: "path-edges"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, spec := range []struct{ key, cat string }{
|
||||||
|
{"target/primary_domain", "target"},
|
||||||
|
{"chain/full_attack_path", "chain"},
|
||||||
|
{"finding/mysql_public", "finding"},
|
||||||
|
{"exploit/mysql_creds_extract", "exploit"},
|
||||||
|
} {
|
||||||
|
if _, err := db.UpsertProjectFact(&database.ProjectFact{
|
||||||
|
ProjectID: p.ID, FactKey: spec.key, Category: spec.cat, Summary: spec.key, Confidence: "confirmed",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.ReplaceIncomingProjectFactEdges(p.ID, "finding/mysql_public", []database.ProjectFactEdgeFromInput{
|
||||||
|
{From: "target/primary_domain", Type: "discovered_on"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.ReplaceIncomingProjectFactEdges(p.ID, "finding/mysql_public", []database.ProjectFactEdgeFromInput{
|
||||||
|
{From: "target/primary_domain", Type: "discovered_on"},
|
||||||
|
{From: "exploit/mysql_creds_extract", Type: "exploits"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.ReplaceIncomingProjectFactEdges(p.ID, "chain/full_attack_path", []database.ProjectFactEdgeFromInput{
|
||||||
|
{From: "target/primary_domain", Type: "discovered_on"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.ReplaceIncomingProjectFactEdges(p.ID, "exploit/mysql_creds_extract", []database.ProjectFactEdgeFromInput{
|
||||||
|
{From: "chain/full_attack_path", Type: "leads_to"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
graph, err := BuildProjectFactGraph(db, p.ID, "path", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]struct{}{
|
||||||
|
"target/primary_domain|discovered_on|finding/mysql_public": {},
|
||||||
|
"exploit/mysql_creds_extract|exploits|finding/mysql_public": {},
|
||||||
|
"target/primary_domain|discovered_on|chain/full_attack_path": {},
|
||||||
|
"chain/full_attack_path|leads_to|exploit/mysql_creds_extract": {},
|
||||||
|
}
|
||||||
|
for _, e := range graph.Edges {
|
||||||
|
key := e.Source + "|" + e.Type + "|" + e.Target
|
||||||
|
delete(want, key)
|
||||||
|
}
|
||||||
|
if len(want) > 0 {
|
||||||
|
t.Fatalf("missing expected stored-direction edges: %v", want)
|
||||||
|
}
|
||||||
|
countInOut := func(factKey string) (out, in int) {
|
||||||
|
for _, e := range graph.Edges {
|
||||||
|
if e.Source == factKey {
|
||||||
|
out++
|
||||||
|
}
|
||||||
|
if e.Target == factKey {
|
||||||
|
in++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, in
|
||||||
|
}
|
||||||
|
if out, in := countInOut("chain/full_attack_path"); out != 1 || in != 1 {
|
||||||
|
t.Fatalf("chain/full_attack_path want out=1 in=1 got out=%d in=%d", out, in)
|
||||||
|
}
|
||||||
|
if out, in := countInOut("exploit/mysql_creds_extract"); out != 1 || in != 1 {
|
||||||
|
t.Fatalf("exploit/mysql_creds_extract want out=1 in=1 got out=%d in=%d", out, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersistFactLinksFromUsesFromAsIncoming(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(dir, "test.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
p, err := db.CreateProject(&database.Project{Name: "from-links"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, spec := range []struct{ key, cat string }{
|
||||||
|
{"target/primary_domain", "target"},
|
||||||
|
{"finding/sqli", "finding"},
|
||||||
|
} {
|
||||||
|
if _, err := db.UpsertProjectFact(&database.ProjectFact{
|
||||||
|
ProjectID: p.ID, FactKey: spec.key, Category: spec.cat, Summary: spec.key, Confidence: "confirmed",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed := &ParsedFactLinks{
|
||||||
|
Incoming: []database.ProjectFactEdgeFromInput{
|
||||||
|
{From: "target/primary_domain", Type: "discovered_on"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := PersistFactLinksFromParsed(db, p.ID, "finding/sqli", "", parsed, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
graph, err := BuildProjectFactGraph(db, p.ID, "path", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "target/primary_domain|discovered_on|finding/sqli"
|
||||||
|
for _, e := range graph.Edges {
|
||||||
|
key := e.Source + "|" + e.Type + "|" + e.Target
|
||||||
|
if key == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("expected edge %s, got %+v", want, graph.Edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatOutgoingLinksHint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
hint := FormatOutgoingLinksHint([]*database.ProjectFactEdge{
|
||||||
|
{EdgeType: "discovered_on", TargetFactKey: "target/a"},
|
||||||
|
})
|
||||||
|
if hint == "" || hint[0] != ' ' {
|
||||||
|
t.Fatalf("unexpected hint: %q", hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaceIncomingAllowsNotYetCreatedSource(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(dir, "test.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
p, err := db.CreateProject(&database.Project{Name: "parallel-links"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.UpsertProjectFact(&database.ProjectFact{
|
||||||
|
ProjectID: p.ID, FactKey: "exploit/sqli", Category: "exploit", Summary: "exploit", Confidence: "confirmed",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.ReplaceIncomingProjectFactEdges(p.ID, "exploit/sqli", []database.ProjectFactEdgeFromInput{
|
||||||
|
{From: "finding/sqli_endpoint", Type: "exploits"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("incoming edge should not require source fact to exist yet: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.UpsertProjectFact(&database.ProjectFact{
|
||||||
|
ProjectID: p.ID, FactKey: "finding/sqli_endpoint", Category: "finding", Summary: "finding", Confidence: "confirmed",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in, err := db.ListIncomingProjectFactEdges(p.ID, "exploit/sqli")
|
||||||
|
if err != nil || len(in) != 1 || in[0].SourceFactKey != "finding/sqli_endpoint" {
|
||||||
|
t.Fatalf("expected persisted edge from finding, got %+v err=%v", in, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateProjectFactEdgeType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if err := database.ValidateProjectFactEdgeType("leads_to"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.ValidateProjectFactEdgeType("invalid"); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
var factIndexEdgeTypeOrder = []string{
|
||||||
|
"discovered_on", "leads_to", "enables", "depends_on", "exploits", "contains", "part_of", "supports",
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterIndexEdges(edges []*database.ProjectFactEdge) []*database.ProjectFactEdge {
|
||||||
|
if len(edges) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]*database.ProjectFactEdge, 0, len(edges))
|
||||||
|
for _, e := range edges {
|
||||||
|
if e == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(e.Confidence), "deprecated") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
edgeType := strings.ToLower(strings.TrimSpace(e.EdgeType))
|
||||||
|
if _, ok := database.ValidProjectFactEdgeTypes[edgeType]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func edgeConfidenceSuffix(confidence string) string {
|
||||||
|
c := strings.ToLower(strings.TrimSpace(confidence))
|
||||||
|
if c == "" || c == "confirmed" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " (" + c + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRelationHintPart(e *database.ProjectFactEdge) string {
|
||||||
|
return fmt.Sprintf("%s←%s%s", e.EdgeType, e.SourceFactKey, edgeConfidenceSuffix(e.Confidence))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOutgoingHintPart(e *database.ProjectFactEdge) string {
|
||||||
|
return fmt.Sprintf("%s→%s%s", e.EdgeType, e.TargetFactKey, edgeConfidenceSuffix(e.Confidence))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatIncomingHintPart(e *database.ProjectFactEdge) string {
|
||||||
|
return formatRelationHintPart(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinEdgeHintParts(edges []*database.ProjectFactEdge, formatter func(*database.ProjectFactEdge) string) string {
|
||||||
|
parts := make([]string, 0, len(edges))
|
||||||
|
for _, e := range edges {
|
||||||
|
parts = append(parts, formatter(e))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatOutgoingLinksHint 黑板索引用出边摘要(全部有效边类型,不截断)。
|
||||||
|
func FormatOutgoingLinksHint(edges []*database.ProjectFactEdge) string {
|
||||||
|
edges = filterIndexEdges(edges)
|
||||||
|
if len(edges) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " {出边: " + joinEdgeHintParts(edges, formatOutgoingHintPart) + "}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatIncomingLinksHint 黑板索引用入边摘要(全部有效边类型,不截断)。
|
||||||
|
func FormatIncomingLinksHint(edges []*database.ProjectFactEdge) string {
|
||||||
|
edges = filterIndexEdges(edges)
|
||||||
|
if len(edges) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " {入边: " + joinEdgeHintParts(edges, formatIncomingHintPart) + "}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatFactIndexLinksHint 黑板索引行内关系边(from → 当前 fact,与 upsert links 一致)。
|
||||||
|
func FormatFactIndexLinksHint(_ string, incoming []*database.ProjectFactEdge) string {
|
||||||
|
in := filterIndexEdges(incoming)
|
||||||
|
if len(in) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " {关系边: " + joinEdgeHintParts(in, formatRelationHintPart) + "}"
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexEdgeGroupMaps(edges []*database.ProjectFactEdge) (outgoing, incoming map[string][]*database.ProjectFactEdge) {
|
||||||
|
outgoing = map[string][]*database.ProjectFactEdge{}
|
||||||
|
incoming = map[string][]*database.ProjectFactEdge{}
|
||||||
|
for _, e := range filterIndexEdges(edges) {
|
||||||
|
outgoing[e.SourceFactKey] = append(outgoing[e.SourceFactKey], e)
|
||||||
|
incoming[e.TargetFactKey] = append(incoming[e.TargetFactKey], e)
|
||||||
|
}
|
||||||
|
return outgoing, incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
func relationOverviewLine(e *database.ProjectFactEdge) string {
|
||||||
|
return fmt.Sprintf("- %s → %s%s · %s", e.SourceFactKey, e.TargetFactKey, edgeConfidenceSuffix(e.Confidence), e.EdgeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexEdgeSortKey(e *database.ProjectFactEdge) (int, int, string) {
|
||||||
|
confRank := 0
|
||||||
|
if strings.EqualFold(strings.TrimSpace(e.Confidence), "tentative") {
|
||||||
|
confRank = 1
|
||||||
|
}
|
||||||
|
typeRank := len(factIndexEdgeTypeOrder) + 1
|
||||||
|
for i, t := range factIndexEdgeTypeOrder {
|
||||||
|
if strings.EqualFold(e.EdgeType, t) {
|
||||||
|
typeRank = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return confRank, typeRank, e.SourceFactKey + ">" + e.TargetFactKey + ">" + e.EdgeType
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortIndexOverviewEdges(edges []*database.ProjectFactEdge) {
|
||||||
|
sort.SliceStable(edges, func(i, j int) bool {
|
||||||
|
ci, ti, ki := indexEdgeSortKey(edges[i])
|
||||||
|
cj, tj, kj := indexEdgeSortKey(edges[j])
|
||||||
|
if ci != cj {
|
||||||
|
return ci < cj
|
||||||
|
}
|
||||||
|
if ti != tj {
|
||||||
|
return ti < tj
|
||||||
|
}
|
||||||
|
return ki < kj
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildFactPathOverviewSection 生成事实关系速览(全部有效边类型,不含 body)。
|
||||||
|
func BuildFactPathOverviewSection(edges []*database.ProjectFactEdge, indexedKeys map[string]struct{}, maxRunes int) string {
|
||||||
|
if maxRunes <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
candidates := filterIndexEdges(edges)
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
filtered := make([]*database.ProjectFactEdge, 0, len(candidates))
|
||||||
|
for _, e := range candidates {
|
||||||
|
if len(indexedKeys) > 0 {
|
||||||
|
if _, ok := indexedKeys[e.SourceFactKey]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := indexedKeys[e.TargetFactKey]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filtered = append(filtered, e)
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sortIndexOverviewEdges(filtered)
|
||||||
|
|
||||||
|
header := "### 攻击路径(事实关系)\n"
|
||||||
|
header += "source → target · type(与攻击路径图/库中方向一致;写入时在目标 fact 的 links 用 from 声明来源)\n"
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(header)
|
||||||
|
used := len([]rune(header))
|
||||||
|
omitted := 0
|
||||||
|
|
||||||
|
for _, e := range filtered {
|
||||||
|
line := relationOverviewLine(e) + "\n"
|
||||||
|
lineRunes := len([]rune(line))
|
||||||
|
if used+lineRunes > maxRunes {
|
||||||
|
omitted++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(line)
|
||||||
|
used += lineRunes
|
||||||
|
}
|
||||||
|
if omitted > 0 {
|
||||||
|
extra := fmt.Sprintf("(另有 %d 条关系边未列入,请 get_project_fact 查看完整关系。)\n", omitted)
|
||||||
|
if used+len([]rune(extra)) <= maxRunes {
|
||||||
|
b.WriteString(extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if used <= len([]rune(header)) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func factIndexSortPriority(f *database.ProjectFact) int {
|
||||||
|
if f == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
score := 0
|
||||||
|
if f.Pinned {
|
||||||
|
score += 1000
|
||||||
|
}
|
||||||
|
c := strings.ToLower(strings.TrimSpace(f.Category))
|
||||||
|
switch c {
|
||||||
|
case FactCategoryTarget:
|
||||||
|
score += 400
|
||||||
|
case FactCategoryFinding, FactCategoryChain:
|
||||||
|
score += 300
|
||||||
|
case FactCategoryExploit, FactCategoryPOC:
|
||||||
|
score += 250
|
||||||
|
case "auth", "infra", "business":
|
||||||
|
score += 200
|
||||||
|
case "note":
|
||||||
|
score += 50
|
||||||
|
default:
|
||||||
|
key := strings.ToLower(strings.TrimSpace(f.FactKey))
|
||||||
|
if strings.HasPrefix(key, "target/") {
|
||||||
|
score += 400
|
||||||
|
} else if strings.HasPrefix(key, "finding/") || strings.HasPrefix(key, "chain/") {
|
||||||
|
score += 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(f.Confidence), "confirmed") {
|
||||||
|
score += 80
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortFactsForIndex(facts []*database.ProjectFact) {
|
||||||
|
sort.SliceStable(facts, func(i, j int) bool {
|
||||||
|
pi, pj := factIndexSortPriority(facts[i]), factIndexSortPriority(facts[j])
|
||||||
|
if pi != pj {
|
||||||
|
return pi > pj
|
||||||
|
}
|
||||||
|
return facts[i].UpdatedAt.After(facts[j].UpdatedAt)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatIncomingLinksHint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
hint := FormatIncomingLinksHint([]*database.ProjectFactEdge{
|
||||||
|
{EdgeType: "discovered_on", SourceFactKey: "finding/x", Confidence: "tentative"},
|
||||||
|
})
|
||||||
|
if !strings.Contains(hint, "入边:") {
|
||||||
|
t.Fatalf("expected 入边 label: %q", hint)
|
||||||
|
}
|
||||||
|
if !strings.Contains(hint, "discovered_on←finding/x") {
|
||||||
|
t.Fatalf("unexpected hint: %q", hint)
|
||||||
|
}
|
||||||
|
if !strings.Contains(hint, "tentative") {
|
||||||
|
t.Fatalf("expected tentative in hint: %q", hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatIncomingLinksHint_allEdges(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
edges := make([]*database.ProjectFactEdge, 0, 5)
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
edges = append(edges, &database.ProjectFactEdge{
|
||||||
|
EdgeType: "discovered_on",
|
||||||
|
SourceFactKey: fmt.Sprintf("finding/f%d", i),
|
||||||
|
Confidence: "tentative",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hint := FormatIncomingLinksHint(edges)
|
||||||
|
if strings.Contains(hint, "+") {
|
||||||
|
t.Fatalf("should not truncate with +N: %q", hint)
|
||||||
|
}
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
if !strings.Contains(hint, fmt.Sprintf("finding/f%d", i)) {
|
||||||
|
t.Fatalf("missing edge f%d in hint: %q", i, hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatFactIndexLinksHint_incomingOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
in := []*database.ProjectFactEdge{
|
||||||
|
{EdgeType: "discovered_on", SourceFactKey: "target/dev", Confidence: "tentative"},
|
||||||
|
{EdgeType: "exploits", SourceFactKey: "exploit/rce", Confidence: "confirmed"},
|
||||||
|
}
|
||||||
|
hint := FormatFactIndexLinksHint("finding/sqli", in)
|
||||||
|
if !strings.Contains(hint, "关系边:") {
|
||||||
|
t.Fatalf("missing 关系边 label: %q", hint)
|
||||||
|
}
|
||||||
|
if !strings.Contains(hint, "discovered_on←target/dev") {
|
||||||
|
t.Fatalf("missing discovered_on: %q", hint)
|
||||||
|
}
|
||||||
|
if !strings.Contains(hint, "exploits←exploit/rce") {
|
||||||
|
t.Fatalf("missing exploits: %q", hint)
|
||||||
|
}
|
||||||
|
if strings.Contains(hint, "出边") || strings.Contains(hint, "入边") {
|
||||||
|
t.Fatalf("should not use legacy 出边/入边 labels: %q", hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatFactIndexLinksHint_includesAuxiliaryEdgeTypes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
in := []*database.ProjectFactEdge{{EdgeType: "supports", SourceFactKey: "note/log"}}
|
||||||
|
hint := FormatFactIndexLinksHint("finding/x", in)
|
||||||
|
if !strings.Contains(hint, "supports←note/log") {
|
||||||
|
t.Fatalf("supports edge should be included: %q", hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFactPathOverviewSection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
edges := []*database.ProjectFactEdge{
|
||||||
|
{EdgeType: "discovered_on", SourceFactKey: "target/dev", TargetFactKey: "finding/sqli", Confidence: "tentative"},
|
||||||
|
{EdgeType: "exploits", SourceFactKey: "exploit/rce", TargetFactKey: "finding/sqli", Confidence: "confirmed"},
|
||||||
|
{EdgeType: "supports", SourceFactKey: "note/log", TargetFactKey: "finding/sqli"},
|
||||||
|
}
|
||||||
|
keys := map[string]struct{}{
|
||||||
|
"target/dev": {}, "finding/sqli": {}, "exploit/rce": {}, "note/log": {},
|
||||||
|
}
|
||||||
|
section := BuildFactPathOverviewSection(edges, keys, 800)
|
||||||
|
if !strings.Contains(section, "### 攻击路径(事实关系)") {
|
||||||
|
t.Fatalf("missing header: %q", section)
|
||||||
|
}
|
||||||
|
if !strings.Contains(section, "target/dev → finding/sqli") {
|
||||||
|
t.Fatalf("missing discovered_on line: %q", section)
|
||||||
|
}
|
||||||
|
if !strings.Contains(section, "exploit/rce → finding/sqli") {
|
||||||
|
t.Fatalf("missing exploits line: %q", section)
|
||||||
|
}
|
||||||
|
if !strings.Contains(section, "note/log → finding/sqli") {
|
||||||
|
t.Fatalf("supports edge should be included: %q", section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFactIndexBlock_withLinksAndPathOverview(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "facts.db")
|
||||||
|
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
proj, err := db.CreateProject(&database.Project{Name: "path-proj"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = db.UpsertProjectFact(&database.ProjectFact{
|
||||||
|
ProjectID: proj.ID,
|
||||||
|
FactKey: "target/dev",
|
||||||
|
Category: "target",
|
||||||
|
Summary: "dev 子域",
|
||||||
|
Confidence: "confirmed",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = db.UpsertProjectFact(&database.ProjectFact{
|
||||||
|
ProjectID: proj.ID,
|
||||||
|
FactKey: "finding/sqli",
|
||||||
|
Category: "finding",
|
||||||
|
Summary: "时间盲注",
|
||||||
|
Confidence: "tentative",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = db.AddProjectFactEdge(proj.ID, database.ProjectFactEdgeInput{
|
||||||
|
To: "finding/sqli",
|
||||||
|
Type: "discovered_on",
|
||||||
|
}, "target/dev", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := BuildFactIndexBlock(db, proj.ID, config.ProjectConfig{Enabled: true, FactIndexMaxRunes: 6500, FactIndexPathMaxRunes: 1000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(block, "关系边: discovered_on←target/dev") {
|
||||||
|
t.Fatalf("finding line should include relation hint: %q", block)
|
||||||
|
}
|
||||||
|
if !strings.Contains(block, "### 攻击路径(事实关系)") {
|
||||||
|
t.Fatalf("missing relation overview: %q", block)
|
||||||
|
}
|
||||||
|
if !strings.Contains(block, "target/dev → finding/sqli") {
|
||||||
|
t.Fatalf("missing overview edge: %q", block)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,100 +1,23 @@
|
|||||||
package project
|
package project
|
||||||
|
|
||||||
import (
|
import "cyberstrike-ai/internal/projectprompt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"cyberstrike-ai/internal/mcp/builtin"
|
// FactRecordingIncrementalRhythmMarkdown 见 projectprompt。
|
||||||
)
|
|
||||||
|
|
||||||
// 边渗透边记录:统一节奏文案(agents/*.md 须与 FactRecordingIncrementalRhythmMarkdown 保持一致)。
|
|
||||||
const (
|
|
||||||
factRhythmCore = "勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。"
|
|
||||||
factRhythmCoordinatorSuffix = "委派/子任务返回新认知或漏洞时,由协调者及时写入,勿假定子代理已记。"
|
|
||||||
factRhythmSubAgentSuffix = "若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FactRecordingIncrementalRhythmMarkdown 返回边渗透边记录节奏(Markdown,供 agents/*.md 与文档对齐)。
|
|
||||||
func FactRecordingIncrementalRhythmMarkdown(coordinator, subAgent bool) string {
|
func FactRecordingIncrementalRhythmMarkdown(coordinator, subAgent bool) string {
|
||||||
var b strings.Builder
|
return projectprompt.FactRecordingIncrementalRhythmMarkdown(coordinator, subAgent)
|
||||||
b.WriteString("- **边渗透边记录(强制节奏)**:")
|
|
||||||
b.WriteString(factRhythmCore)
|
|
||||||
if coordinator {
|
|
||||||
b.WriteString(factRhythmCoordinatorSuffix)
|
|
||||||
}
|
|
||||||
if subAgent {
|
|
||||||
b.WriteString(factRhythmSubAgentSuffix)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func factRecordingIncrementalRhythmBuiltin(coordinator, subAgent bool) string {
|
// FactRecordingBlackboardSection 见 projectprompt。
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 ")
|
|
||||||
b.WriteString(builtin.ToolUpsertProjectFact)
|
|
||||||
b.WriteString("(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 ")
|
|
||||||
b.WriteString(builtin.ToolRecordVulnerability)
|
|
||||||
b.WriteString(";与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。")
|
|
||||||
if coordinator {
|
|
||||||
b.WriteString(factRhythmCoordinatorSuffix)
|
|
||||||
}
|
|
||||||
if subAgent {
|
|
||||||
b.WriteString(factRhythmSubAgentSuffix)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// FactRecordingBlackboardSection 项目黑板与漏洞记录的完整系统提示块(单/多 Agent 主代理共用)。
|
|
||||||
// coordinatorDelegate 为 true 时追加「协调者代子代理落库」说明(Deep / plan_execute / supervisor)。
|
|
||||||
func FactRecordingBlackboardSection(coordinatorDelegate bool) string {
|
func FactRecordingBlackboardSection(coordinatorDelegate bool) string {
|
||||||
var b strings.Builder
|
return projectprompt.FactRecordingBlackboardSection(coordinatorDelegate)
|
||||||
b.WriteString("## 项目黑板(事实)与漏洞记录(分离)\n\n")
|
|
||||||
b.WriteString("当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 fact_key + 摘要)。**摘要不足时必须调用 ")
|
|
||||||
b.WriteString(builtin.ToolGetProjectFact)
|
|
||||||
b.WriteString("(fact_key) 获取 body,禁止凭摘要臆造细节。**\n\n")
|
|
||||||
b.WriteString(factRecordingIncrementalRhythmBuiltin(coordinatorDelegate, false))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
b.WriteString("- **环境/目标/认证等认知**(非正式漏洞条目):使用 ")
|
|
||||||
b.WriteString(builtin.ToolUpsertProjectFact)
|
|
||||||
b.WriteString(",fact_key 建议 `category/slug`(如 target/primary_domain),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。\n")
|
|
||||||
b.WriteString("- **发现与利用上下文**(审计复现):fact_key 建议 finding/、chain/、exploit/、poc/ 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 related_vulnerability_id),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。\n")
|
|
||||||
b.WriteString("- **可交付漏洞**:使用 ")
|
|
||||||
b.WriteString(builtin.ToolRecordVulnerability)
|
|
||||||
b.WriteString(",含标题、严重程度、类型、目标、证明(POC)、影响、修复建议。记前可先 ")
|
|
||||||
b.WriteString(builtin.ToolListVulnerabilities)
|
|
||||||
b.WriteString(" 查重,详情用 ")
|
|
||||||
b.WriteString(builtin.ToolGetVulnerability)
|
|
||||||
b.WriteString("(id)(默认仅当前项目/会话)。\n")
|
|
||||||
b.WriteString("- 同一发现可能需**各记一次**(事实记**完整攻击链与 exploit 细节**供复现,漏洞记正式 findings)。误报用 ")
|
|
||||||
b.WriteString(builtin.ToolDeprecateProjectFact)
|
|
||||||
b.WriteString(" 或漏洞状态 false_positive。\n")
|
|
||||||
b.WriteString("- 事实多时用 ")
|
|
||||||
b.WriteString(builtin.ToolListProjectFacts)
|
|
||||||
b.WriteString(" / ")
|
|
||||||
b.WriteString(builtin.ToolSearchProjectFacts)
|
|
||||||
b.WriteString(" 检索。\n\n")
|
|
||||||
b.WriteString(FactRecordingGuidanceBlock())
|
|
||||||
b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。")
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FactRecordingSubAgentSection 子代理边渗透边记录(无工具时输出待落库条目)。
|
// FactRecordingSubAgentSection 见 projectprompt。
|
||||||
func FactRecordingSubAgentSection() string {
|
func FactRecordingSubAgentSection() string {
|
||||||
return "## 边渗透边记录\n\n" + factRecordingIncrementalRhythmBuiltin(false, true) + "\n"
|
return projectprompt.FactRecordingSubAgentSection()
|
||||||
}
|
}
|
||||||
|
|
||||||
// FactRecordingBlackboardSectionMarkdown 与 FactRecordingBlackboardSection 等价的 Markdown(工具名为字面量,供 agents/*.md)。
|
// FactRecordingBlackboardSectionMarkdown 见 projectprompt。
|
||||||
func FactRecordingBlackboardSectionMarkdown(coordinatorDelegate bool) string {
|
func FactRecordingBlackboardSectionMarkdown(coordinatorDelegate bool) string {
|
||||||
var b strings.Builder
|
return projectprompt.FactRecordingBlackboardSectionMarkdown(coordinatorDelegate)
|
||||||
b.WriteString("## 项目黑板(事实)与漏洞记录(分离)\n\n")
|
|
||||||
b.WriteString("当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 `fact_key` + 摘要)。**摘要不足时必须调用 `get_project_fact(fact_key)` 获取 body,禁止凭摘要臆造细节。**\n\n")
|
|
||||||
b.WriteString(FactRecordingIncrementalRhythmMarkdown(coordinatorDelegate, false))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
b.WriteString("- **环境/目标/认证等认知**(非正式漏洞):使用 **`upsert_project_fact`**,`fact_key` 建议 `category/slug`(如 `target/primary_domain`),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。\n")
|
|
||||||
b.WriteString("- **发现与利用上下文**(审计复现):`fact_key` 建议 `finding/`、`chain/`、`exploit/`、`poc/` 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 `related_vulnerability_id`),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。\n")
|
|
||||||
b.WriteString("- **可交付漏洞**:使用 **`record_vulnerability`**(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度 critical / high / medium / low / info。\n")
|
|
||||||
b.WriteString("- 同一发现可能需**各记一次**(事实记可复现攻击链,漏洞记正式 findings)。误报用 **`deprecate_project_fact`** 或漏洞状态 false_positive。\n")
|
|
||||||
b.WriteString("- 事实多时用 **`list_project_facts`** / **`search_project_facts`** 检索。\n\n")
|
|
||||||
b.WriteString(FactRecordingGuidanceBlock())
|
|
||||||
b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。")
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package project
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/projectprompt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 事实 category 常量(写入 upsert_project_fact 的 category 字段)。
|
// 事实 category 常量(写入 upsert_project_fact 的 category 字段)。
|
||||||
@@ -90,7 +92,8 @@ const attackChainFactBodyTemplate = `## 结论(可验证,一句话)
|
|||||||
|
|
||||||
## 关联
|
## 关联
|
||||||
- related_vulnerability_id: <可选,对应 record_vulnerability 的 id>
|
- related_vulnerability_id: <可选,对应 record_vulnerability 的 id>
|
||||||
- 依赖事实: <fact_key,如 auth/session_cookie>
|
- links(upsert 参数): [{ "from": "<fact_key>", "type": "discovered_on|..." }](from → 当前 fact)
|
||||||
|
- 依赖事实(body 可读镜像): <fact_key,如 auth/session_cookie>
|
||||||
|
|
||||||
## 备注与不确定性
|
## 备注与不确定性
|
||||||
<待验证假设、环境差异、绕过尝试记录>`
|
<待验证假设、环境差异、绕过尝试记录>`
|
||||||
@@ -109,15 +112,7 @@ const envFactBodyTemplate = `## 摘要
|
|||||||
|
|
||||||
// FactRecordingGuidanceBlock 写入系统提示:要求事实沉淀攻击链上下文而非仅结论。
|
// FactRecordingGuidanceBlock 写入系统提示:要求事实沉淀攻击链上下文而非仅结论。
|
||||||
func FactRecordingGuidanceBlock() string {
|
func FactRecordingGuidanceBlock() string {
|
||||||
return `### 事实写入规范(审计复现 / 知识沉淀)
|
return projectprompt.FactRecordingGuidanceBlock()
|
||||||
|
|
||||||
- **summary**:索引用一行,须含「什么 + 在哪 + 如何触发/验证」要点,禁止只写结论(如仅写「存在 SQLi」)。
|
|
||||||
- **body**:完整可复现上下文,写入 ` + "`upsert_project_fact`" + ` 的 body 字段;索引不含 body,后续会话须靠 ` + "`get_project_fact`" + ` 取回。
|
|
||||||
- **category / fact_key 建议**:
|
|
||||||
- 环境认知:` + "`target/`" + `、` + "`auth/`" + `、` + "`infra/`" + `、` + "`business/`" + `(body 用环境模板即可)
|
|
||||||
- 发现与利用:` + "`finding/`" + `、` + "`chain/`" + `、` + "`exploit/`" + `、` + "`poc/`" + `(**必须**用攻击链模板填满 body:入口、逐步攻击链、原始请求/响应或命令、证据、关联漏洞 ID)
|
|
||||||
- **与漏洞记录分工**:` + "`record_vulnerability`" + ` 记可交付 findings;事实记**复现所需的全部上下文**(含失败尝试、绕过、依赖会话),二者可各记一次。
|
|
||||||
- 更新同一发现时保持相同 ` + "`fact_key`" + ` 覆盖写入,勿散落多个 key 导致上下文丢失。`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SparseBodyWarning 攻击链类事实 body 不足时的工具返回提示(不阻断保存)。
|
// SparseBodyWarning 攻击链类事实 body 不足时的工具返回提示(不阻断保存)。
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Package projectprompt 提供项目黑板相关的系统提示文本(纯字符串,无 database 依赖)。
|
||||||
|
// 供 agent / multiagent 等包引用,避免 agent → project 导入环导致 gopls 元数据失败。
|
||||||
|
package projectprompt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
factRhythmCore = "勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。"
|
||||||
|
factRhythmCoordinatorSuffix = "委派/子任务返回新认知或漏洞时,由协调者及时写入,勿假定子代理已记。"
|
||||||
|
factRhythmSubAgentSuffix = "若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FactRecordingIncrementalRhythmMarkdown 返回边渗透边记录节奏(Markdown,供 agents/*.md 与文档对齐)。
|
||||||
|
func FactRecordingIncrementalRhythmMarkdown(coordinator, subAgent bool) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("- **边渗透边记录(强制节奏)**:")
|
||||||
|
b.WriteString(factRhythmCore)
|
||||||
|
if coordinator {
|
||||||
|
b.WriteString(factRhythmCoordinatorSuffix)
|
||||||
|
}
|
||||||
|
if subAgent {
|
||||||
|
b.WriteString(factRhythmSubAgentSuffix)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func factRecordingIncrementalRhythmBuiltin(coordinator, subAgent bool) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 ")
|
||||||
|
b.WriteString(builtin.ToolUpsertProjectFact)
|
||||||
|
b.WriteString("(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 ")
|
||||||
|
b.WriteString(builtin.ToolRecordVulnerability)
|
||||||
|
b.WriteString(";与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。")
|
||||||
|
if coordinator {
|
||||||
|
b.WriteString(factRhythmCoordinatorSuffix)
|
||||||
|
}
|
||||||
|
if subAgent {
|
||||||
|
b.WriteString(factRhythmSubAgentSuffix)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func factEdgeRecordingGuidance() string {
|
||||||
|
return `### 事实关系边(links)
|
||||||
|
|
||||||
|
- 写入 **finding / chain / exploit / poc** 时,**必须**在 ` + "`upsert_project_fact`" + ` 中提供 ` + "`links`" + `(**推荐 ` + "`from`" + `**:来源 fact 指向当前 fact,即 ` + "`from`" + ` → 当前 ` + "`fact_key`" + `)。
|
||||||
|
- **最少要求**:finding 类至少 1 条 from=target/* + type=discovered_on(即 target → finding);在 finding 上记录 exploit 用 from=exploit/* + type=exploits(即 exploit → finding)。
|
||||||
|
- **常用 type**:` + "`discovered_on`" + `(发现在哪)、` + "`depends_on`" + `(复现前置)、` + "`leads_to`" + `(认知推进)、` + "`enables`" + `(扩大攻击面)、` + "`exploits`" + `(利用关系)、` + "`contains`" + `(资产包含)、` + "`part_of`" + `(属于链/组)、` + "`supports`" + `(证据支撑)。
|
||||||
|
- 更新时:**省略 links 保留已有边**;传入 links 则**替换**全部关系边(from → 当前 fact)。
|
||||||
|
- body 中「依赖事实」段落可与 links 并存(人读);结构化关系以 links 为准。`
|
||||||
|
}
|
||||||
|
|
||||||
|
func factRecordingGuidanceBlock() string {
|
||||||
|
return `### 事实写入规范(审计复现 / 知识沉淀)
|
||||||
|
|
||||||
|
- **summary**:索引用一行,须含「什么 + 在哪 + 如何触发/验证」要点,禁止只写结论(如仅写「存在 SQLi」)。
|
||||||
|
- **body**:完整可复现上下文,写入 ` + "`upsert_project_fact`" + ` 的 body 字段;索引不含 body,后续会话须靠 ` + "`get_project_fact`" + ` 取回。
|
||||||
|
- **category / fact_key 建议**:
|
||||||
|
- 环境认知:` + "`target/`" + `、` + "`auth/`" + `、` + "`infra/`" + `、` + "`business/`" + `(body 用环境模板即可)
|
||||||
|
- 发现与利用:` + "`finding/`" + `、` + "`chain/`" + `、` + "`exploit/`" + `、` + "`poc/`" + `(**必须**用攻击链模板填满 body:入口、逐步攻击链、原始请求/响应或命令、证据、关联漏洞 ID)
|
||||||
|
- **与漏洞记录分工**:` + "`record_vulnerability`" + ` 记可交付 findings;事实记**复现所需的全部上下文**(含失败尝试、绕过、依赖会话),二者可各记一次。
|
||||||
|
- 更新同一发现时保持相同 ` + "`fact_key`" + ` 覆盖写入,勿散落多个 key 导致上下文丢失。`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactRecordingBlackboardSection 项目黑板与漏洞记录的完整系统提示块(单/多 Agent 主代理共用)。
|
||||||
|
func FactRecordingBlackboardSection(coordinatorDelegate bool) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("## 项目黑板(事实)与漏洞记录(分离)\n\n")
|
||||||
|
b.WriteString("当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 fact_key + 摘要)。**摘要不足时必须调用 ")
|
||||||
|
b.WriteString(builtin.ToolGetProjectFact)
|
||||||
|
b.WriteString("(fact_key) 获取 body,禁止凭摘要臆造细节。**\n\n")
|
||||||
|
b.WriteString(factRecordingIncrementalRhythmBuiltin(coordinatorDelegate, false))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString("- **环境/目标/认证等认知**(非正式漏洞条目):使用 ")
|
||||||
|
b.WriteString(builtin.ToolUpsertProjectFact)
|
||||||
|
b.WriteString(",fact_key 建议 `category/slug`(如 target/primary_domain),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。\n")
|
||||||
|
b.WriteString("- **发现与利用上下文**(审计复现):fact_key 建议 finding/、chain/、exploit/、poc/ 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 related_vulnerability_id),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。\n")
|
||||||
|
b.WriteString("- **可交付漏洞**:使用 ")
|
||||||
|
b.WriteString(builtin.ToolRecordVulnerability)
|
||||||
|
b.WriteString(",含标题、严重程度、类型、目标、证明(POC)、影响、修复建议。记前可先 ")
|
||||||
|
b.WriteString(builtin.ToolListVulnerabilities)
|
||||||
|
b.WriteString(" 查重,详情用 ")
|
||||||
|
b.WriteString(builtin.ToolGetVulnerability)
|
||||||
|
b.WriteString("(id)(默认仅当前项目/会话)。\n")
|
||||||
|
b.WriteString("- 同一发现可能需**各记一次**(事实记**完整攻击链与 exploit 细节**供复现,漏洞记正式 findings)。误报用 ")
|
||||||
|
b.WriteString(builtin.ToolDeprecateProjectFact)
|
||||||
|
b.WriteString(" 或漏洞状态 false_positive。\n")
|
||||||
|
b.WriteString("- 事实多时用 ")
|
||||||
|
b.WriteString(builtin.ToolListProjectFacts)
|
||||||
|
b.WriteString(" / ")
|
||||||
|
b.WriteString(builtin.ToolSearchProjectFacts)
|
||||||
|
b.WriteString(" 检索。\n\n")
|
||||||
|
b.WriteString(factEdgeRecordingGuidance())
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(factRecordingGuidanceBlock())
|
||||||
|
b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactRecordingSubAgentSection 子代理边渗透边记录(无工具时输出待落库条目)。
|
||||||
|
func FactRecordingSubAgentSection() string {
|
||||||
|
return "## 边渗透边记录\n\n" + factRecordingIncrementalRhythmBuiltin(false, true) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactRecordingBlackboardSectionMarkdown 与 FactRecordingBlackboardSection 等价的 Markdown(工具名为字面量,供 agents/*.md)。
|
||||||
|
func FactRecordingBlackboardSectionMarkdown(coordinatorDelegate bool) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("## 项目黑板(事实)与漏洞记录(分离)\n\n")
|
||||||
|
b.WriteString("当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 `fact_key` + 摘要)。**摘要不足时必须调用 `get_project_fact(fact_key)` 获取 body,禁止凭摘要臆造细节。**\n\n")
|
||||||
|
b.WriteString(FactRecordingIncrementalRhythmMarkdown(coordinatorDelegate, false))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString("- **环境/目标/认证等认知**(非正式漏洞):使用 **`upsert_project_fact`**,`fact_key` 建议 `category/slug`(如 `target/primary_domain`),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。\n")
|
||||||
|
b.WriteString("- **发现与利用上下文**(审计复现):`fact_key` 建议 `finding/`、`chain/`、`exploit/`、`poc/` 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 `related_vulnerability_id`),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。\n")
|
||||||
|
b.WriteString("- **可交付漏洞**:使用 **`record_vulnerability`**(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度 critical / high / medium / low / info。\n")
|
||||||
|
b.WriteString("- 同一发现可能需**各记一次**(事实记可复现攻击链,漏洞记正式 findings)。误报用 **`deprecate_project_fact`** 或漏洞状态 false_positive。\n")
|
||||||
|
b.WriteString("- 事实多时用 **`list_project_facts`** / **`search_project_facts`** 检索。\n\n")
|
||||||
|
b.WriteString(factEdgeRecordingGuidance())
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(factRecordingGuidanceBlock())
|
||||||
|
b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactEdgeRecordingGuidance 写入边时的 Agent 规范(供 project 包复用)。
|
||||||
|
func FactEdgeRecordingGuidance() string { return factEdgeRecordingGuidance() }
|
||||||
|
|
||||||
|
// FactRecordingGuidanceBlock 事实写入规范块(供 project 包复用)。
|
||||||
|
func FactRecordingGuidanceBlock() string { return factRecordingGuidanceBlock() }
|
||||||
+626
-21
@@ -23860,9 +23860,17 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
.projects-placeholder-icon {
|
.projects-placeholder-icon {
|
||||||
font-size: 3rem;
|
display: flex;
|
||||||
margin-bottom: 16px;
|
align-items: center;
|
||||||
opacity: 0.85;
|
justify-content: center;
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #3b82f6;
|
||||||
|
background: linear-gradient(145deg, #eff6ff 0%, #dbeafe 100%);
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.12);
|
||||||
}
|
}
|
||||||
.projects-detail-placeholder h3 {
|
.projects-detail-placeholder h3 {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
@@ -23883,7 +23891,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid var(--border-color, #e2e8f0);
|
border: 1px solid var(--border-color, #e2e8f0);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
@@ -24066,6 +24074,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
color: #0066ff;
|
color: #0066ff;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.projects-panel {
|
.projects-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -24309,11 +24318,17 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
#project-panel-vulns .projects-table-wrap {
|
#project-panel-vulns .projects-table-wrap {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
#project-panel-conversations .projects-table-wrap,
|
||||||
|
#project-panel-vulns .projects-table-wrap {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
#project-panel-facts .projects-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
#project-panel-facts .projects-table-wrap .data-table--projects thead th,
|
#project-panel-facts .projects-table-wrap .data-table--projects thead th,
|
||||||
#project-panel-conversations .projects-table-wrap .data-table--projects thead th,
|
#project-panel-conversations .projects-table-wrap .data-table--projects thead th,
|
||||||
#project-panel-vulns .projects-table-wrap .data-table--projects thead th {
|
#project-panel-vulns .projects-table-wrap .data-table--projects thead th {
|
||||||
@@ -24332,12 +24347,6 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
.projects-panel-toolbar--hint .projects-fact-toolbar-hint {
|
.projects-panel-toolbar--hint .projects-fact-toolbar-hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
#project-panel-facts .data-table--projects th:nth-child(1) { width: 20%; }
|
|
||||||
#project-panel-facts .data-table--projects th:nth-child(2) { width: 9%; }
|
|
||||||
#project-panel-facts .data-table--projects th:nth-child(3) { width: 30%; }
|
|
||||||
#project-panel-facts .data-table--projects th:nth-child(4) { width: 9%; }
|
|
||||||
#project-panel-facts .data-table--projects th:nth-child(5) { width: 10%; }
|
|
||||||
#project-panel-facts .data-table--projects th:nth-child(6) { width: 10%; }
|
|
||||||
#project-panel-facts .data-table--projects .cell-fact-key {
|
#project-panel-facts .data-table--projects .cell-fact-key {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
@@ -24345,6 +24354,16 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
#project-panel-facts .data-table--projects .cell-fact-category {
|
#project-panel-facts .data-table--projects .cell-fact-category {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
#project-panel-facts .data-table--projects .cell-summary {
|
||||||
|
max-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#project-panel-facts .data-table--projects .cell-fact-links {
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
#project-panel-facts .projects-fact-key-chip {
|
#project-panel-facts .projects-fact-key-chip {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -24463,23 +24482,23 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
#project-panel-facts .data-table--projects th:nth-child(1),
|
#project-panel-facts .data-table--projects th:nth-child(1),
|
||||||
#project-panel-facts .data-table--projects td:nth-child(1) {
|
#project-panel-facts .data-table--projects td:nth-child(1) {
|
||||||
width: 19%;
|
width: 13%;
|
||||||
}
|
}
|
||||||
#project-panel-facts .data-table--projects th:nth-child(2),
|
#project-panel-facts .data-table--projects th:nth-child(2),
|
||||||
#project-panel-facts .data-table--projects td:nth-child(2) {
|
#project-panel-facts .data-table--projects td:nth-child(2) {
|
||||||
width: 9%;
|
width: 7%;
|
||||||
}
|
}
|
||||||
#project-panel-facts .data-table--projects th:nth-child(3),
|
#project-panel-facts .data-table--projects th:nth-child(3),
|
||||||
#project-panel-facts .data-table--projects td:nth-child(3) {
|
#project-panel-facts .data-table--projects td:nth-child(3) {
|
||||||
width: 28%;
|
width: 22%;
|
||||||
}
|
}
|
||||||
#project-panel-facts .data-table--projects th:nth-child(4),
|
#project-panel-facts .data-table--projects th:nth-child(4),
|
||||||
#project-panel-facts .data-table--projects td:nth-child(4) {
|
#project-panel-facts .data-table--projects td:nth-child(4) {
|
||||||
width: 8%;
|
width: 5%;
|
||||||
}
|
}
|
||||||
#project-panel-facts .data-table--projects th:nth-child(5),
|
#project-panel-facts .data-table--projects th:nth-child(5),
|
||||||
#project-panel-facts .data-table--projects td:nth-child(5) {
|
#project-panel-facts .data-table--projects td:nth-child(5) {
|
||||||
width: 9%;
|
width: 8%;
|
||||||
}
|
}
|
||||||
#project-panel-facts .data-table--projects th:nth-child(6),
|
#project-panel-facts .data-table--projects th:nth-child(6),
|
||||||
#project-panel-facts .data-table--projects td:nth-child(6) {
|
#project-panel-facts .data-table--projects td:nth-child(6) {
|
||||||
@@ -24487,8 +24506,593 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
#project-panel-facts .data-table--projects th:nth-child(7),
|
#project-panel-facts .data-table--projects th:nth-child(7),
|
||||||
#project-panel-facts .data-table--projects td:nth-child(7) {
|
#project-panel-facts .data-table--projects td:nth-child(7) {
|
||||||
width: 19%;
|
width: 9%;
|
||||||
}
|
}
|
||||||
|
#project-panel-facts .data-table--projects th.col-actions,
|
||||||
|
#project-panel-facts .data-table--projects td.col-actions {
|
||||||
|
width: 28%;
|
||||||
|
min-width: 196px;
|
||||||
|
max-width: 240px;
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
z-index: 3;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: -6px 0 10px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
#project-panel-facts .data-table--projects thead th.col-actions {
|
||||||
|
z-index: 6;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
#project-panel-facts .data-table--projects tbody tr:hover td.col-actions {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
#project-panel-facts .data-table--projects .col-actions .projects-table-actions {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 项目事实攻击路径图 */
|
||||||
|
#project-panel-graph.projects-panel--graph {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
#project-panel-graph .projects-graph-toolbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
#project-panel-graph .project-fact-graph-layout {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#project-panel-graph .project-fact-graph-container {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#project-panel-graph .project-fact-graph-footer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #eef2f7;
|
||||||
|
}
|
||||||
|
.projects-graph-toolbar-row {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.projects-graph-search-field {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
.projects-graph-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 3px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.projects-graph-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 6px 11px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #475569;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.projects-graph-action-btn svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.projects-graph-action-btn:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #fff;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
.projects-graph-action-btn--connect {
|
||||||
|
color: #4338ca;
|
||||||
|
background: #eef2ff;
|
||||||
|
border-color: #c7d2fe;
|
||||||
|
}
|
||||||
|
.projects-graph-action-btn--connect:hover,
|
||||||
|
.projects-graph-action-btn--connect-active {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.35);
|
||||||
|
}
|
||||||
|
.projects-graph-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px 14px;
|
||||||
|
}
|
||||||
|
.projects-graph-legend-group {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 10px;
|
||||||
|
}
|
||||||
|
.projects-graph-legend-heading {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.projects-graph-legend-divider {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.projects-graph-legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.projects-graph-legend-item--edge i {
|
||||||
|
display: inline-block;
|
||||||
|
width: 22px;
|
||||||
|
height: 0;
|
||||||
|
border-top: 2.5px solid var(--legend-color, #cbd5e1);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.projects-graph-legend-item--edge.projects-graph-legend-item--dashed i {
|
||||||
|
border-top-style: dashed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.projects-graph-legend-item--node i {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 1.5px solid var(--legend-color, #cbd5e1);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, var(--legend-bg, #f8fafc) 100%);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
.projects-graph-legend-item--node-dashed i {
|
||||||
|
border-style: dashed;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.project-fact-graph-layout {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.project-fact-graph-container {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 240px;
|
||||||
|
border: 1px solid var(--border-color, #e2e8f0);
|
||||||
|
border-radius: 14px;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 1px 1px, rgba(148, 163, 184, 0.35) 1px, transparent 0);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 1px 3px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
.project-fact-graph-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(ellipse at center, transparent 55%, rgba(241, 245, 249, 0.65) 100%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.project-fact-graph-container .loading-spinner,
|
||||||
|
.project-fact-graph-container .project-fact-graph-empty,
|
||||||
|
.project-fact-graph-container .error-message {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.project-fact-graph-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 420px;
|
||||||
|
padding: 40px 32px;
|
||||||
|
}
|
||||||
|
.project-fact-graph-empty-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
.project-fact-graph-empty-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.project-fact-graph-empty-hint {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
max-width: 420px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.project-fact-graph-empty-steps {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.project-fact-graph-empty-steps li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.project-fact-graph-empty-steps li::marker {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.project-fact-graph-empty-cta {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.project-fact-graph-sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: min(300px, calc(100% - 24px));
|
||||||
|
z-index: 12;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||||
|
animation: projectGraphSidebarIn 0.2s ease;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.project-fact-graph-sidebar[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
@keyframes projectGraphSidebarIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.project-fact-graph-sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.project-fact-graph-sidebar-title-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.project-fact-graph-sidebar-header h4 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.project-fact-graph-node-category {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.project-fact-graph-node-category--target { color: #4338ca; background: #eef2ff; border-color: #c7d2fe; }
|
||||||
|
.project-fact-graph-node-category--finding { color: #be123c; background: #fff1f2; border-color: #fecdd3; }
|
||||||
|
.project-fact-graph-node-category--vulnerability { color: #7e22ce; background: #f5f3ff; border-color: #ddd6fe; }
|
||||||
|
.project-fact-graph-node-category--exploit,
|
||||||
|
.project-fact-graph-node-category--poc { color: #c2410c; background: #ffedd5; border-color: #fdba74; }
|
||||||
|
.project-fact-graph-node-category--chain { color: #6d28d9; background: #f5f3ff; border-color: #ddd6fe; }
|
||||||
|
.project-fact-graph-node-category--auth { color: #0f766e; background: #f0fdfa; border-color: #99f6e4; }
|
||||||
|
.project-fact-graph-node-category--infra { color: #475569; background: #f1f5f9; border-color: #cbd5e1; }
|
||||||
|
.project-fact-graph-node-category--business { color: #0369a1; background: #f0f9ff; border-color: #bae6fd; }
|
||||||
|
.project-fact-graph-node-category--note { color: #64748b; background: #f8fafc; border-color: #e2e8f0; }
|
||||||
|
.project-fact-graph-node-category--missing { color: #94a3b8; background: #f1f5f9; border-color: #e2e8f0; font-style: italic; }
|
||||||
|
.project-fact-graph-sidebar-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.project-fact-graph-sidebar-close:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.project-fact-graph-node-meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #64748b;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.project-fact-graph-node-summary {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.project-fact-graph-node-vuln-hint {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edges-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edges-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edges-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #94a3b8;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edges-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edges-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edge-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edge-item:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edge-item.is-selected {
|
||||||
|
border-color: #818cf8;
|
||||||
|
background: #eef2ff;
|
||||||
|
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.25);
|
||||||
|
}
|
||||||
|
.project-fact-graph-edge-dir {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edge-type {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: #4338ca;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edge-arrow {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edge-peer {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #334155;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edge-delete {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edge-delete:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #f87171;
|
||||||
|
}
|
||||||
|
.project-fact-graph-edge-synthetic {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.projects-incoming-links-readonly {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.projects-incoming-links-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.projects-incoming-links-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #334155;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.projects-incoming-links-item code {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.projects-incoming-links-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.projects-edge-type {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.projects-edge-arrow {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.project-fact-graph-sidebar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
.project-fact-graph-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.project-fact-graph-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
.projects-graph-stat-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.projects-graph-stat-badge strong {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
#project-panel-graph .projects-fact-toolbar-filters {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.projects-fact-link-badge {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
}
|
||||||
|
.projects-fact-link-badge--empty {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.project-fact-graph-sidebar {
|
||||||
|
width: min(280px, calc(100% - 24px));
|
||||||
|
}
|
||||||
|
.projects-graph-actions {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1400px) {
|
@media (max-width: 1400px) {
|
||||||
.projects-detail-header {
|
.projects-detail-header {
|
||||||
padding: 16px 18px 14px;
|
padding: 16px 18px 14px;
|
||||||
@@ -24513,11 +25117,12 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
#project-panel-facts .data-table--projects th:nth-child(3),
|
#project-panel-facts .data-table--projects th:nth-child(3),
|
||||||
#project-panel-facts .data-table--projects td:nth-child(3) {
|
#project-panel-facts .data-table--projects td:nth-child(3) {
|
||||||
width: 24%;
|
width: 22%;
|
||||||
}
|
}
|
||||||
#project-panel-facts .data-table--projects th:nth-child(7),
|
#project-panel-facts .data-table--projects th.col-actions,
|
||||||
#project-panel-facts .data-table--projects td:nth-child(7) {
|
#project-panel-facts .data-table--projects td.col-actions {
|
||||||
width: 23%;
|
min-width: 188px;
|
||||||
|
max-width: 220px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* —— 项目设置:左右分栏 + 底部危险区,无内层滚动 —— */
|
/* —— 项目设置:左右分栏 + 底部危险区,无内层滚动 —— */
|
||||||
|
|||||||
@@ -258,10 +258,73 @@
|
|||||||
"vulnerabilityManagement": "Vulnerability management",
|
"vulnerabilityManagement": "Vulnerability management",
|
||||||
"addFactCta": "+ Add fact",
|
"addFactCta": "+ Add fact",
|
||||||
"tabFacts": "Fact board",
|
"tabFacts": "Fact board",
|
||||||
|
"tabGraph": "Attack path",
|
||||||
"tabConversations": "Bound conversations",
|
"tabConversations": "Bound conversations",
|
||||||
"tabVulns": "Related vulnerabilities",
|
"tabVulns": "Related vulnerabilities",
|
||||||
"tabSettings": "Settings",
|
"tabSettings": "Settings",
|
||||||
"factToolbarHint": "Index includes key and summary only (must include what + where + how to verify); put attack chain / POC in body, and reproduce via get_project_fact.",
|
"factToolbarHint": "Index includes key and summary only (must include what + where + how to verify); put attack chain / POC in body, and reproduce via get_project_fact.",
|
||||||
|
"graphToolbarHint": "Graph arrows match stored fact links (source → target). Nodes are layered target→infra→finding→exploit. Dashed edges are tentative.",
|
||||||
|
"graphView": "View",
|
||||||
|
"graphViewPath": "Attack path",
|
||||||
|
"graphViewFull": "Full graph",
|
||||||
|
"graphSearchSr": "Search nodes",
|
||||||
|
"graphSearchPlaceholder": "Search nodes…",
|
||||||
|
"graphRefresh": "Refresh",
|
||||||
|
"graphCenter": "Center",
|
||||||
|
"graphEmpty": "No graph data yet. Add links on finding/exploit facts (discovered_on → target/*) to build the path.",
|
||||||
|
"graphEmptyTitle": "Build your attack path",
|
||||||
|
"graphEmptyStep1": "Add target facts (domains, endpoints, scope)",
|
||||||
|
"graphEmptyStep2": "Record findings/exploits with links between facts",
|
||||||
|
"graphEmptyStep3": "Use Connect mode or edit facts to add relationships",
|
||||||
|
"graphEmptyCta": "Add first fact",
|
||||||
|
"graphStats": "Nodes: {{nodes}} | Edges: {{edges}}",
|
||||||
|
"graphStatsNodes": "Nodes",
|
||||||
|
"graphStatsEdges": "Edges",
|
||||||
|
"graphLegendNodes": "Nodes",
|
||||||
|
"graphLegendEdges": "Edges",
|
||||||
|
"graphLegendNodeTarget": "TARGET",
|
||||||
|
"graphLegendNodeInfra": "INFRA",
|
||||||
|
"graphLegendNodeFinding": "FINDING",
|
||||||
|
"graphLegendNodeVuln": "VULN",
|
||||||
|
"graphLegendNodeExploit": "EXPLOIT",
|
||||||
|
"graphLegendNodeMissing": "MISSING",
|
||||||
|
"graphLegendDiscovered": "discovered_on",
|
||||||
|
"graphLegendLeads": "leads_to",
|
||||||
|
"graphLegendExploits": "exploits",
|
||||||
|
"graphLegendTentative": "Tentative (dashed)",
|
||||||
|
"factLinksLabel": "Links (from → this fact)",
|
||||||
|
"factLinksPlaceholder": "discovered_on: target/primary_domain\nexploits: exploit/upload-rce",
|
||||||
|
"factLinksHint": "One per line: type: source_fact_key (source → this fact). Common types: discovered_on, depends_on, leads_to, enables, exploits. Saving replaces all links.",
|
||||||
|
"factIncomingLinksLabel": "Incoming links (read-only)",
|
||||||
|
"factIncomingLinksHint": "Derived from outgoing links on source facts. e.g. finding discovered_on → target/* appears as incoming on the target; edit the source fact's outgoing links.",
|
||||||
|
"factIncomingLinksEmpty": "No incoming links",
|
||||||
|
"graphEdgeFromSelf": "From this node",
|
||||||
|
"graphEdgeToSelf": "To this node",
|
||||||
|
"linksColumn": "Links",
|
||||||
|
"linkCountsTitle": "Outgoing / incoming edge counts",
|
||||||
|
"graphConnect": "Connect",
|
||||||
|
"graphConnectActive": "Connecting…",
|
||||||
|
"graphConnectPickTarget": "Source {{source}} selected — click target node",
|
||||||
|
"graphEdgeTypePrompt": "Edge type (discovered_on / leads_to / depends_on / enables / exploits)",
|
||||||
|
"graphConnectFailed": "Failed to create edge",
|
||||||
|
"graphConnectSuccess": "Edge created",
|
||||||
|
"graphEdgesTitle": "Links",
|
||||||
|
"graphEdgesHint": "Arrow direction matches the database and edit modal (source → target). Click an edge to focus it.",
|
||||||
|
"graphEdgesEmpty": "No links yet",
|
||||||
|
"graphEdgeOutgoing": "Outgoing",
|
||||||
|
"graphEdgeIncoming": "Incoming",
|
||||||
|
"graphEdgeSynthetic": "Auto-generated from fact link; edit the fact to remove",
|
||||||
|
"confirmDeleteGraphEdge": "Delete this link?",
|
||||||
|
"graphEdgeDeleteFailed": "Failed to delete edge",
|
||||||
|
"graphEdgeDeleteSuccess": "Edge deleted",
|
||||||
|
"graphDeleteEdge": "Delete",
|
||||||
|
"viewVulnerability": "View vulnerability",
|
||||||
|
"graphVulnSidebarHint": "Linked vulnerability node. Use the button below to open it in Vulnerability Management.",
|
||||||
|
"promoteAttackChain": "Promote chain",
|
||||||
|
"promoteAttackChainTitle": "Promote conversation attack chain to project facts",
|
||||||
|
"confirmPromoteAttackChain": "Promote this conversation's attack chain into the project? Facts and edges will be created or updated.",
|
||||||
|
"promoteAttackChainFailed": "Promote failed",
|
||||||
|
"promoteAttackChainSuccess": "Promoted: {{facts_created}} new / {{facts_updated}} updated / {{edges_created}} edges",
|
||||||
"searchFactsSr": "Search facts",
|
"searchFactsSr": "Search facts",
|
||||||
"searchFactsPlaceholder": "Search key, summary, body…",
|
"searchFactsPlaceholder": "Search key, summary, body…",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
|
|||||||
@@ -246,10 +246,73 @@
|
|||||||
"vulnerabilityManagement": "漏洞管理",
|
"vulnerabilityManagement": "漏洞管理",
|
||||||
"addFactCta": "+ 添加事实",
|
"addFactCta": "+ 添加事实",
|
||||||
"tabFacts": "事实黑板",
|
"tabFacts": "事实黑板",
|
||||||
|
"tabGraph": "攻击路径",
|
||||||
"tabConversations": "关联对话",
|
"tabConversations": "关联对话",
|
||||||
"tabVulns": "关联漏洞",
|
"tabVulns": "关联漏洞",
|
||||||
"tabSettings": "设置",
|
"tabSettings": "设置",
|
||||||
"factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 body,Agent 通过 get_project_fact 复现",
|
"factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 body,Agent 通过 get_project_fact 复现",
|
||||||
|
"graphToolbarHint": "攻击路径图箭头与事实存储方向一致(source → target);节点按 target→infra→finding→exploit 分层排布。虚线边为待确认。",
|
||||||
|
"graphView": "视图",
|
||||||
|
"graphViewPath": "攻击路径",
|
||||||
|
"graphViewFull": "完整关系",
|
||||||
|
"graphSearchSr": "搜索节点",
|
||||||
|
"graphSearchPlaceholder": "搜索节点…",
|
||||||
|
"graphRefresh": "刷新",
|
||||||
|
"graphCenter": "居中",
|
||||||
|
"graphEmpty": "暂无路径图数据。为 finding/exploit 类事实添加关系边(discovered_on → target/*)后将在此展示。",
|
||||||
|
"graphEmptyTitle": "构建攻击路径图",
|
||||||
|
"graphEmptyStep1": "添加 target 类事实(目标、域名、入口)",
|
||||||
|
"graphEmptyStep2": "记录 finding / exploit 并在 links 中连边",
|
||||||
|
"graphEmptyStep3": "使用「连边」模式或编辑事实手动补关系",
|
||||||
|
"graphEmptyCta": "添加第一条事实",
|
||||||
|
"graphStats": "节点: {{nodes}} | 边: {{edges}}",
|
||||||
|
"graphStatsNodes": "节点",
|
||||||
|
"graphStatsEdges": "边",
|
||||||
|
"graphLegendNodes": "节点",
|
||||||
|
"graphLegendEdges": "连线",
|
||||||
|
"graphLegendNodeTarget": "TARGET · 目标",
|
||||||
|
"graphLegendNodeInfra": "INFRA · 基础设施",
|
||||||
|
"graphLegendNodeFinding": "FINDING · 发现",
|
||||||
|
"graphLegendNodeVuln": "VULN · 漏洞",
|
||||||
|
"graphLegendNodeExploit": "EXPLOIT · 利用",
|
||||||
|
"graphLegendNodeMissing": "MISSING · 缺失",
|
||||||
|
"graphLegendDiscovered": "discovered_on",
|
||||||
|
"graphLegendLeads": "leads_to",
|
||||||
|
"graphLegendExploits": "exploits",
|
||||||
|
"graphLegendTentative": "待确认(虚线)",
|
||||||
|
"factLinksLabel": "关系边(from → 本事实)",
|
||||||
|
"factLinksPlaceholder": "discovered_on: target/primary_domain\nexploits: exploit/upload-rce",
|
||||||
|
"factLinksHint": "每行一条:type: source_fact_key(来源 → 当前事实)。常用 type:discovered_on、depends_on、leads_to、enables、exploits。保存时替换全部关系边。",
|
||||||
|
"factIncomingLinksLabel": "入边(只读)",
|
||||||
|
"factIncomingLinksHint": "由来源事实的出边产生。例如 finding 的 discovered_on → target/*,在目标上会显示为入边;请编辑来源事实的出边。",
|
||||||
|
"factIncomingLinksEmpty": "暂无入边",
|
||||||
|
"graphEdgeFromSelf": "本节点指出",
|
||||||
|
"graphEdgeToSelf": "指向本节点",
|
||||||
|
"linksColumn": "关系",
|
||||||
|
"linkCountsTitle": "出边数 / 入边数",
|
||||||
|
"graphConnect": "连边",
|
||||||
|
"graphConnectActive": "连边中…",
|
||||||
|
"graphConnectPickTarget": "已选 {{source}},请点击目标节点",
|
||||||
|
"graphEdgeTypePrompt": "边类型(discovered_on / leads_to / depends_on / enables / exploits)",
|
||||||
|
"graphConnectFailed": "创建边失败",
|
||||||
|
"graphConnectSuccess": "边已创建",
|
||||||
|
"graphEdgesTitle": "关系边",
|
||||||
|
"graphEdgesHint": "箭头方向与数据库/编辑弹窗一致(source → target);点击连线可定位。",
|
||||||
|
"graphEdgesEmpty": "暂无关系边",
|
||||||
|
"graphEdgeOutgoing": "出边",
|
||||||
|
"graphEdgeIncoming": "入边",
|
||||||
|
"graphEdgeSynthetic": "由事实关联自动生成,请编辑事实解除",
|
||||||
|
"confirmDeleteGraphEdge": "确定删除此关系边?",
|
||||||
|
"graphEdgeDeleteFailed": "删除边失败",
|
||||||
|
"graphEdgeDeleteSuccess": "边已删除",
|
||||||
|
"graphDeleteEdge": "删边",
|
||||||
|
"viewVulnerability": "查看漏洞",
|
||||||
|
"graphVulnSidebarHint": "关联漏洞节点,点击下方按钮在漏洞管理中查看详情。",
|
||||||
|
"promoteAttackChain": "沉淀攻击链",
|
||||||
|
"promoteAttackChainTitle": "将对话攻击链沉淀为项目事实与边",
|
||||||
|
"confirmPromoteAttackChain": "将该对话的攻击链沉淀到本项目?会创建/更新事实与关系边。",
|
||||||
|
"promoteAttackChainFailed": "沉淀失败",
|
||||||
|
"promoteAttackChainSuccess": "已沉淀:新建 {{facts_created}} / 更新 {{facts_updated}} / 边 {{edges_created}}",
|
||||||
"searchFactsSr": "搜索事实",
|
"searchFactsSr": "搜索事实",
|
||||||
"searchFactsPlaceholder": "搜索 key、摘要、body…",
|
"searchFactsPlaceholder": "搜索 key、摘要、body…",
|
||||||
"category": "分类",
|
"category": "分类",
|
||||||
|
|||||||
@@ -0,0 +1,680 @@
|
|||||||
|
/**
|
||||||
|
* 项目事实图渲染(Cytoscape + ELK),供项目管理页使用。
|
||||||
|
* 节点采用 SVG 卡片背景(图标 + 多行文字),避免 Cytoscape 原生 label 定位问题。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let _cy = null;
|
||||||
|
let _graphData = null;
|
||||||
|
let _onNodeSelect = null;
|
||||||
|
let _onEdgeSelect = null;
|
||||||
|
let _resizeObs = null;
|
||||||
|
|
||||||
|
const EDGE_COLORS = {
|
||||||
|
discovered_on: '#4F46E5',
|
||||||
|
leads_to: '#64748B',
|
||||||
|
enables: '#E11D48',
|
||||||
|
exploits: '#DC2626',
|
||||||
|
depends_on: '#0D9488',
|
||||||
|
contains: '#6366F1',
|
||||||
|
part_of: '#6366F1',
|
||||||
|
supports: '#94A3B8',
|
||||||
|
links_vuln: '#BE123C',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CARD_PAD = 14;
|
||||||
|
const CARD_TEXT_PAD_RIGHT = 12;
|
||||||
|
const CARD_ICON = 36;
|
||||||
|
const CARD_ICON_GAP = 12;
|
||||||
|
const CARD_TEXT_X = CARD_PAD + CARD_ICON + CARD_ICON_GAP;
|
||||||
|
const CARD_MIN_W = 300;
|
||||||
|
const CARD_TARGET_W = 360;
|
||||||
|
const CARD_MIN_H = 88;
|
||||||
|
const CARD_MAX_H = 176;
|
||||||
|
const CARD_HEADER_FS = 11;
|
||||||
|
const CARD_HEADER_LH = 16;
|
||||||
|
const CARD_KEY_FS = 10;
|
||||||
|
const CARD_KEY_LH = 14;
|
||||||
|
const CARD_SUMMARY_FS = 13;
|
||||||
|
const CARD_SUMMARY_LH = 18;
|
||||||
|
const CARD_SECTION_GAP = 6;
|
||||||
|
const CARD_FONT =
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif';
|
||||||
|
const CARD_KEY_FONT =
|
||||||
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace';
|
||||||
|
|
||||||
|
function nodeTheme(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'target':
|
||||||
|
return { typeLabel: '目标', typeEn: 'TARGET', accent: '#4F46E5', bgEnd: '#F5F3FF', icon: 'target' };
|
||||||
|
case 'finding':
|
||||||
|
return { typeLabel: '发现', typeEn: 'FINDING', accent: '#E11D48', bgEnd: '#FFF1F2', icon: 'finding', cardStyle: 'default' };
|
||||||
|
case 'exploit':
|
||||||
|
return { typeLabel: '利用', typeEn: 'EXPLOIT', accent: '#B45309', bgEnd: '#FFFBEB', icon: 'vulnerability', cardStyle: 'default' };
|
||||||
|
case 'vulnerability':
|
||||||
|
return { typeLabel: '漏洞', typeEn: 'VULN', accent: '#9333EA', bgEnd: '#F5F3FF', icon: 'vuln', cardStyle: 'default' };
|
||||||
|
case 'auth':
|
||||||
|
return { typeLabel: '认证', typeEn: 'AUTH', accent: '#0D9488', bgEnd: '#F0FDFA', icon: 'default' };
|
||||||
|
case 'infra':
|
||||||
|
return { typeLabel: '基础设施', typeEn: 'INFRA', accent: '#64748B', bgEnd: '#F8FAFC', icon: 'default' };
|
||||||
|
case 'chain':
|
||||||
|
return { typeLabel: '攻击链', typeEn: 'CHAIN', accent: '#7C3AED', bgEnd: '#F5F3FF', icon: 'vulnerability' };
|
||||||
|
case 'poc':
|
||||||
|
return { typeLabel: 'POC', typeEn: 'POC', accent: '#C2410C', bgEnd: '#FFEDD5', icon: 'vulnerability' };
|
||||||
|
case 'business':
|
||||||
|
return { typeLabel: '业务', typeEn: 'BUSINESS', accent: '#0369A1', bgEnd: '#F0F9FF', icon: 'default' };
|
||||||
|
case 'missing':
|
||||||
|
return { typeLabel: '缺失', typeEn: 'MISSING', accent: '#CBD5E1', bgEnd: '#F1F5F9', icon: 'default' };
|
||||||
|
default:
|
||||||
|
return { typeLabel: '备注', typeEn: 'NOTE', accent: '#94A3B8', bgEnd: '#F8FAFC', icon: 'default' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return escapeXml(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatusBadge(confidence) {
|
||||||
|
const conf = (confidence || '').toLowerCase();
|
||||||
|
if (conf === 'tentative') return '待确认';
|
||||||
|
if (conf === 'deprecated') return '已废弃';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeaderText(theme, statusBadge) {
|
||||||
|
const line = (theme.typeEn || '') + ' · ' + (theme.typeLabel || '');
|
||||||
|
return statusBadge ? line + ' · ' + statusBadge : line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWideChar(ch) {
|
||||||
|
const code = ch.codePointAt(0) || 0;
|
||||||
|
if (code >= 0x4e00 && code <= 0x9fff) return true;
|
||||||
|
if (code >= 0x3400 && code <= 0x4dbf) return true;
|
||||||
|
if (code >= 0xf900 && code <= 0xfaff) return true;
|
||||||
|
if (code >= 0xff00 && code <= 0xffef) return true;
|
||||||
|
return /[·:,。;!?【】()《》、「」]/.test(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function charWidth(ch, fontSize, bold) {
|
||||||
|
const scale = bold ? 1.05 : 1;
|
||||||
|
if (ch === ' ') return fontSize * 0.3 * scale;
|
||||||
|
if (isWideChar(ch)) return fontSize * scale;
|
||||||
|
return fontSize * 0.58 * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineWidth(text, fontSize, bold) {
|
||||||
|
let width = 0;
|
||||||
|
for (const ch of text) width += charWidth(ch, fontSize, bold);
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapTextLines(text, maxWidth, fontSize, maxLines, bold) {
|
||||||
|
const raw = String(text || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!raw) return ['—'];
|
||||||
|
const safeWidth = Math.max(40, maxWidth - 4);
|
||||||
|
const chars = [...raw];
|
||||||
|
const lines = [];
|
||||||
|
let index = 0;
|
||||||
|
while (index < chars.length && lines.length < maxLines) {
|
||||||
|
let line = '';
|
||||||
|
let width = 0;
|
||||||
|
while (index < chars.length) {
|
||||||
|
const ch = chars[index];
|
||||||
|
const nextWidth = charWidth(ch, fontSize, bold);
|
||||||
|
if (line && width + nextWidth > safeWidth) break;
|
||||||
|
line += ch;
|
||||||
|
width += nextWidth;
|
||||||
|
index += 1;
|
||||||
|
if (width >= safeWidth) break;
|
||||||
|
}
|
||||||
|
if (line) lines.push(line);
|
||||||
|
}
|
||||||
|
if (index < chars.length && lines.length) {
|
||||||
|
let last = lines[lines.length - 1];
|
||||||
|
while (last.length > 1 && lineWidth(last + '…', fontSize, bold) > safeWidth) {
|
||||||
|
last = last.slice(0, -1);
|
||||||
|
}
|
||||||
|
lines[lines.length - 1] = last + '…';
|
||||||
|
}
|
||||||
|
return lines.length ? lines : ['—'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardTextWidth(nodeWidth) {
|
||||||
|
return nodeWidth - CARD_TEXT_X - CARD_PAD - CARD_TEXT_PAD_RIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeNodeLayout(type, summary, statusBadge, theme, factKey) {
|
||||||
|
const width = type === 'target' ? CARD_TARGET_W : CARD_MIN_W;
|
||||||
|
const textW = cardTextWidth(width);
|
||||||
|
const t = theme || nodeTheme(type);
|
||||||
|
const headerLines = wrapTextLines(buildHeaderText(t, statusBadge), textW, CARD_HEADER_FS, 2, true);
|
||||||
|
const keyText = String(factKey || '').trim();
|
||||||
|
const keyLines = keyText ? wrapTextLines(keyText, textW, CARD_KEY_FS, 2, false) : [];
|
||||||
|
const summaryLines = wrapTextLines(summary, textW, CARD_SUMMARY_FS, keyLines.length ? 3 : 4, true);
|
||||||
|
const keyBlockHeight = keyLines.length
|
||||||
|
? CARD_SECTION_GAP + keyLines.length * CARD_KEY_LH + CARD_SECTION_GAP
|
||||||
|
: CARD_SECTION_GAP;
|
||||||
|
const height = Math.min(
|
||||||
|
CARD_MAX_H,
|
||||||
|
Math.max(
|
||||||
|
CARD_MIN_H,
|
||||||
|
CARD_PAD +
|
||||||
|
headerLines.length * CARD_HEADER_LH +
|
||||||
|
keyBlockHeight +
|
||||||
|
summaryLines.length * CARD_SUMMARY_LH +
|
||||||
|
CARD_PAD,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
headerLines,
|
||||||
|
keyLines,
|
||||||
|
summaryLines,
|
||||||
|
searchLabel: [headerLines.join(' '), keyLines.join(' '), summaryLines.join(' ')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgIconGroup(kind, color, x, y) {
|
||||||
|
const scale = (CARD_ICON / 24).toFixed(3);
|
||||||
|
if (kind === 'target') {
|
||||||
|
return (
|
||||||
|
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
|
||||||
|
`<circle cx="12" cy="12" r="6" fill="none" stroke="${color}" stroke-width="2"/>` +
|
||||||
|
`<circle cx="12" cy="12" r="2.5" fill="${color}"/></g>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (kind === 'finding') {
|
||||||
|
return (
|
||||||
|
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
|
||||||
|
`<circle cx="10" cy="10" r="6" fill="none" stroke="${color}" stroke-width="2"/>` +
|
||||||
|
`<line x1="14.5" y1="14.5" x2="19" y2="19" stroke="${color}" stroke-width="2" stroke-linecap="round"/></g>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (kind === 'vuln') {
|
||||||
|
return (
|
||||||
|
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
|
||||||
|
`<path d="M12 2.5l7.5 3v6.2c0 4.6-3.1 8.1-7.5 9.3-4.4-1.2-7.5-4.7-7.5-9.3V5.5z" fill="${color}" fill-opacity="0.12" stroke="${color}" stroke-width="2"/>` +
|
||||||
|
`<line x1="12" y1="8.5" x2="12" y2="12.5" stroke="${color}" stroke-width="2" stroke-linecap="round"/>` +
|
||||||
|
`<circle cx="12" cy="15.5" r="1.1" fill="${color}"/></g>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (kind === 'vulnerability') {
|
||||||
|
return (
|
||||||
|
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
|
||||||
|
`<path d="M12 3l9 16H3z" fill="none" stroke="${color}" stroke-width="2"/>` +
|
||||||
|
`<line x1="12" y1="9" x2="12" y2="13" stroke="${color}" stroke-width="2"/>` +
|
||||||
|
`<circle cx="12" cy="16" r="1" fill="${color}"/></g>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
|
||||||
|
`<circle cx="12" cy="12" r="5" fill="${color}" opacity="0.85"/></g>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodeCardSvgUrl(theme, layout, confidence) {
|
||||||
|
const { width, height, headerLines, keyLines, summaryLines } = layout;
|
||||||
|
const accent = theme.accent;
|
||||||
|
const bgEnd = theme.bgEnd;
|
||||||
|
const conf = (confidence || '').toLowerCase();
|
||||||
|
const isTentative = conf === 'tentative';
|
||||||
|
const isDeprecated = conf === 'deprecated';
|
||||||
|
const iconX = CARD_PAD;
|
||||||
|
const iconY = (height - CARD_ICON) / 2;
|
||||||
|
const headerY = CARD_PAD + CARD_HEADER_FS;
|
||||||
|
const keyY = CARD_PAD + headerLines.length * CARD_HEADER_LH + CARD_SECTION_GAP + CARD_KEY_FS;
|
||||||
|
const summaryY =
|
||||||
|
CARD_PAD +
|
||||||
|
headerLines.length * CARD_HEADER_LH +
|
||||||
|
(keyLines.length
|
||||||
|
? CARD_SECTION_GAP + keyLines.length * CARD_KEY_LH + CARD_SECTION_GAP
|
||||||
|
: CARD_SECTION_GAP) +
|
||||||
|
CARD_SUMMARY_FS;
|
||||||
|
|
||||||
|
const stroke = isTentative
|
||||||
|
? `stroke="${accent}" stroke-width="1.5" stroke-dasharray="8 5" stroke-opacity="0.9"`
|
||||||
|
: `stroke="${accent}" stroke-width="1.5" stroke-opacity="0.72"`;
|
||||||
|
|
||||||
|
const headerSvg = headerLines
|
||||||
|
.map(
|
||||||
|
(line, i) =>
|
||||||
|
`<text x="${CARD_TEXT_X}" y="${headerY + i * CARD_HEADER_LH}" font-size="${CARD_HEADER_FS}" font-weight="700" fill="${accent}" fill-opacity="0.88" font-family='${CARD_FONT}'>${escapeXml(line)}</text>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const keySvg = keyLines
|
||||||
|
.map(
|
||||||
|
(line, i) =>
|
||||||
|
`<text x="${CARD_TEXT_X}" y="${keyY + i * CARD_KEY_LH}" font-size="${CARD_KEY_FS}" font-weight="500" fill="#64748b" font-family='${CARD_KEY_FONT}'>${escapeXml(line)}</text>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const summarySvg = summaryLines
|
||||||
|
.map(
|
||||||
|
(line, i) =>
|
||||||
|
`<text x="${CARD_TEXT_X}" y="${summaryY + i * CARD_SUMMARY_LH}" font-size="${CARD_SUMMARY_FS}" font-weight="600" fill="#0f172a" font-family='${CARD_FONT}'>${escapeXml(line)}</text>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const textClipW = width - CARD_TEXT_X - CARD_PAD - 2;
|
||||||
|
const textClipH = height - CARD_PAD * 2 + 4;
|
||||||
|
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
||||||
|
`<defs><linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">` +
|
||||||
|
`<stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="${bgEnd}"/></linearGradient>` +
|
||||||
|
`<clipPath id="textClip"><rect x="${CARD_TEXT_X}" y="${CARD_PAD - 2}" width="${textClipW}" height="${textClipH}"/></clipPath></defs>` +
|
||||||
|
`<g${isDeprecated ? ' opacity="0.55"' : ''}>` +
|
||||||
|
`<rect x="0.75" y="0.75" width="${width - 1.5}" height="${height - 1.5}" rx="12" fill="url(#bg)" ${stroke}/>` +
|
||||||
|
svgIconGroup(theme.icon, accent, iconX, iconY) +
|
||||||
|
`<g clip-path="url(#textClip)">${headerSvg}${keySvg}${summarySvg}</g>` +
|
||||||
|
`</g></svg>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
|
||||||
|
} catch (e) {
|
||||||
|
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (_resizeObs) {
|
||||||
|
_resizeObs.disconnect();
|
||||||
|
_resizeObs = null;
|
||||||
|
}
|
||||||
|
if (_cy) {
|
||||||
|
_cy.destroy();
|
||||||
|
_cy = null;
|
||||||
|
}
|
||||||
|
_graphData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeContainerResize(container) {
|
||||||
|
if (_resizeObs) {
|
||||||
|
_resizeObs.disconnect();
|
||||||
|
_resizeObs = null;
|
||||||
|
}
|
||||||
|
if (!container || typeof ResizeObserver === 'undefined') return;
|
||||||
|
_resizeObs = new ResizeObserver(() => {
|
||||||
|
if (_cy) {
|
||||||
|
try {
|
||||||
|
_cy.resize();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('graph resize', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_resizeObs.observe(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerGraph() {
|
||||||
|
if (!_cy) return;
|
||||||
|
try {
|
||||||
|
_cy.resize();
|
||||||
|
_cy.fit(undefined, 56);
|
||||||
|
if (_cy.zoom() < 0.65) {
|
||||||
|
_cy.zoom(0.65);
|
||||||
|
_cy.center();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('centerGraph', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ELK 分层(仅影响节点纵向位置,不修改边的 source/target)
|
||||||
|
function pathGraphNodeLayer(type, factKey) {
|
||||||
|
const key = (factKey || '').toLowerCase();
|
||||||
|
if (key.startsWith('vuln:')) return '4';
|
||||||
|
const t = (type || '').toLowerCase();
|
||||||
|
if (t === 'target') return '0';
|
||||||
|
if (t === 'infra' || t === 'auth' || t === 'business') return '1';
|
||||||
|
if (t === 'exploit' || t === 'poc') return '3';
|
||||||
|
if (t === 'vulnerability' || t === 'vuln') return '3';
|
||||||
|
if (t === 'chain' || t === 'finding') return '2';
|
||||||
|
if (t === 'note') return '2';
|
||||||
|
return '2';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyElkLayout(validEdges, isComplex) {
|
||||||
|
const layoutOptions = {
|
||||||
|
name: 'breadthfirst',
|
||||||
|
directed: true,
|
||||||
|
spacingFactor: isComplex ? 3.0 : 2.5,
|
||||||
|
padding: 40,
|
||||||
|
};
|
||||||
|
const elkInstance = typeof ELK !== 'undefined' ? new ELK() : null;
|
||||||
|
if (!elkInstance) {
|
||||||
|
const layout = _cy.layout(layoutOptions);
|
||||||
|
layout.one('layoutstop', () => setTimeout(centerGraph, 100));
|
||||||
|
layout.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nodeGap = isComplex ? 45 : 60;
|
||||||
|
const layerGap = isComplex ? 70 : 95;
|
||||||
|
const elkGraph = {
|
||||||
|
id: 'root',
|
||||||
|
layoutOptions: {
|
||||||
|
'elk.algorithm': 'layered',
|
||||||
|
'elk.direction': 'DOWN',
|
||||||
|
'elk.spacing.nodeNode': String(nodeGap),
|
||||||
|
'elk.layered.spacing.nodeNodeBetweenLayers': String(layerGap),
|
||||||
|
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
|
||||||
|
},
|
||||||
|
children: (_graphData.nodes || []).map((node) => {
|
||||||
|
const n = _cy ? _cy.getElementById(node.id) : null;
|
||||||
|
const w = n.length ? n.data('nodeWidth') : node.type === 'target' ? CARD_TARGET_W : CARD_MIN_W;
|
||||||
|
const h = n.length ? n.data('nodeHeight') : CARD_MIN_H;
|
||||||
|
const nodeKey = node.fact_key || node.id;
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
layoutOptions: {
|
||||||
|
'org.eclipse.elk.layered.layering.layerId': pathGraphNodeLayer(node.type, nodeKey),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
edges: validEdges.map((edge) => ({
|
||||||
|
id: edge.id,
|
||||||
|
sources: [edge.source],
|
||||||
|
targets: [edge.target],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
elkInstance
|
||||||
|
.layout(elkGraph)
|
||||||
|
.then((laidOut) => {
|
||||||
|
(laidOut.children || []).forEach((elkNode) => {
|
||||||
|
const cyNode = _cy.getElementById(elkNode.id);
|
||||||
|
if (cyNode.length && elkNode.x != null) {
|
||||||
|
cyNode.position({
|
||||||
|
x: elkNode.x + (elkNode.width || 0) / 2,
|
||||||
|
y: elkNode.y + (elkNode.height || 0) / 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTimeout(centerGraph, 120);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
const layout = _cy.layout(layoutOptions);
|
||||||
|
layout.one('layoutstop', () => setTimeout(centerGraph, 100));
|
||||||
|
layout.run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(container, graphData, options) {
|
||||||
|
if (!container || typeof cytoscape === 'undefined') {
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '<div class="error-message">Cytoscape 未加载</div>';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
destroy();
|
||||||
|
_graphData = graphData || { nodes: [], edges: [] };
|
||||||
|
_onNodeSelect = options && options.onNodeSelect;
|
||||||
|
_onEdgeSelect = options && options.onEdgeSelect;
|
||||||
|
|
||||||
|
const nodes = _graphData.nodes || [];
|
||||||
|
const edges = _graphData.edges || [];
|
||||||
|
if (!nodes.length) {
|
||||||
|
const title = (options && options.emptyTitle) || '';
|
||||||
|
const hint = (options && options.emptyText) || '暂无事实关系';
|
||||||
|
const steps = (options && options.emptySteps) || [];
|
||||||
|
const actionLabel = options && options.emptyActionLabel;
|
||||||
|
const stepsHtml = steps.length
|
||||||
|
? '<ol class="project-fact-graph-empty-steps">' +
|
||||||
|
steps.map((s) => '<li>' + escapeHtml(String(s)) + '</li>').join('') +
|
||||||
|
'</ol>'
|
||||||
|
: '';
|
||||||
|
const actionHtml =
|
||||||
|
actionLabel && options.onEmptyAction
|
||||||
|
? '<button type="button" class="btn-primary btn-small project-fact-graph-empty-cta">' +
|
||||||
|
escapeHtml(actionLabel) +
|
||||||
|
'</button>'
|
||||||
|
: '';
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="project-fact-graph-empty">' +
|
||||||
|
'<div class="project-fact-graph-empty-icon" aria-hidden="true">' +
|
||||||
|
'<svg width="48" height="48" viewBox="0 0 24 24" fill="none"><circle cx="6" cy="6" r="2.5" fill="#4F46E5" opacity="0.9"/><circle cx="18" cy="6" r="2.5" fill="#E11D48" opacity="0.9"/><circle cx="12" cy="18" r="2.5" fill="#0D9488" opacity="0.9"/>' +
|
||||||
|
'<path d="M8 7l4 9M16 7l-4 9M8 7h8" stroke="#CBD5E1" stroke-width="1.5" stroke-linecap="round"/></svg>' +
|
||||||
|
'</div>' +
|
||||||
|
(title ? '<h4 class="project-fact-graph-empty-title">' + escapeHtml(title) + '</h4>' : '') +
|
||||||
|
'<p class="project-fact-graph-empty-hint">' + escapeHtml(hint) + '</p>' +
|
||||||
|
stepsHtml +
|
||||||
|
actionHtml +
|
||||||
|
'</div>';
|
||||||
|
const cta = container.querySelector('.project-fact-graph-empty-cta');
|
||||||
|
if (cta && typeof options.onEmptyAction === 'function') {
|
||||||
|
cta.addEventListener('click', options.onEmptyAction);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
const isComplex = nodes.length > 15 || edges.length > 25;
|
||||||
|
const elements = [];
|
||||||
|
const nodeIds = new Set();
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
nodeIds.add(node.id);
|
||||||
|
const visualType = resolveGraphNodeType(node);
|
||||||
|
const theme = nodeTheme(visualType);
|
||||||
|
const factKey = node.fact_key || node.id;
|
||||||
|
const summary = (node.summary || node.label || '').trim() || '—';
|
||||||
|
const statusBadge = buildStatusBadge(node.confidence);
|
||||||
|
const layout = computeNodeLayout(visualType, summary, statusBadge, theme, factKey);
|
||||||
|
elements.push({
|
||||||
|
data: {
|
||||||
|
id: node.id,
|
||||||
|
label: layout.searchLabel,
|
||||||
|
factKey: node.fact_key || node.id,
|
||||||
|
category: node.category || '',
|
||||||
|
type: visualType,
|
||||||
|
typeLabel: theme.typeLabel,
|
||||||
|
typeEn: theme.typeEn,
|
||||||
|
accentColor: theme.accent,
|
||||||
|
statusBadge: statusBadge,
|
||||||
|
confidence: node.confidence || '',
|
||||||
|
nodeWidth: layout.width,
|
||||||
|
nodeHeight: layout.height,
|
||||||
|
cardSvgUrl: buildNodeCardSvgUrl(theme, layout, node.confidence),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const validEdges = [];
|
||||||
|
edges.forEach((edge, idx) => {
|
||||||
|
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) return;
|
||||||
|
const id = edge.id || 'e-' + idx;
|
||||||
|
validEdges.push({ ...edge, id });
|
||||||
|
elements.push({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
source: edge.source,
|
||||||
|
target: edge.target,
|
||||||
|
type: edge.type || 'leads_to',
|
||||||
|
confidence: edge.confidence || 'confirmed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
_cy = cytoscape({
|
||||||
|
container,
|
||||||
|
elements,
|
||||||
|
style: [
|
||||||
|
{
|
||||||
|
selector: 'node',
|
||||||
|
style: {
|
||||||
|
label: '',
|
||||||
|
width: (ele) => ele.data('nodeWidth') || CARD_MIN_W,
|
||||||
|
height: (ele) => ele.data('nodeHeight') || CARD_MIN_H,
|
||||||
|
shape: 'round-rectangle',
|
||||||
|
'background-color': '#ffffff',
|
||||||
|
'background-image': (ele) => ele.data('cardSvgUrl') || 'none',
|
||||||
|
'background-width': (ele) => (ele.data('nodeWidth') || CARD_MIN_W) + 'px',
|
||||||
|
'background-height': (ele) => (ele.data('nodeHeight') || CARD_MIN_H) + 'px',
|
||||||
|
'background-position-x': '50%',
|
||||||
|
'background-position-y': '50%',
|
||||||
|
'background-fit': 'none',
|
||||||
|
'border-width': 0,
|
||||||
|
'background-opacity': 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'edge',
|
||||||
|
style: {
|
||||||
|
width: 2.2,
|
||||||
|
'line-color': (ele) => EDGE_COLORS[ele.data('type')] || '#CBD5E1',
|
||||||
|
'target-arrow-color': (ele) => EDGE_COLORS[ele.data('type')] || '#CBD5E1',
|
||||||
|
'target-arrow-shape': 'triangle',
|
||||||
|
'curve-style': 'bezier',
|
||||||
|
opacity: (ele) => (ele.data('confidence') === 'tentative' ? 0.55 : 0.9),
|
||||||
|
'line-style': (ele) => (ele.data('confidence') === 'tentative' ? 'dashed' : 'solid'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'edge:selected',
|
||||||
|
style: {
|
||||||
|
width: 3.5,
|
||||||
|
opacity: 1,
|
||||||
|
'line-color': '#4F46E5',
|
||||||
|
'target-arrow-color': '#4F46E5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'node:selected',
|
||||||
|
style: {
|
||||||
|
'border-width': 3,
|
||||||
|
'border-color': '#4F46E5',
|
||||||
|
'border-opacity': 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
minZoom: 0.35,
|
||||||
|
maxZoom: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
_cy.on('tap', 'node', (evt) => {
|
||||||
|
const d = evt.target.data();
|
||||||
|
const key = d.factKey || d.id;
|
||||||
|
if (_connectMode && _connectPick) {
|
||||||
|
_connectPick(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof _onNodeSelect === 'function') {
|
||||||
|
_onNodeSelect(key, d);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_cy.on('tap', 'edge', (evt) => {
|
||||||
|
if (_connectMode && _connectPick) return;
|
||||||
|
const d = evt.target.data();
|
||||||
|
if (typeof _onEdgeSelect === 'function') {
|
||||||
|
_onEdgeSelect(d.id, d);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_cy.on('tap', (evt) => {
|
||||||
|
if (evt.target === _cy) {
|
||||||
|
clearEdgeSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
applyElkLayout(validEdges, isComplex);
|
||||||
|
observeContainerResize(container);
|
||||||
|
return _cy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterBySearch(query) {
|
||||||
|
if (!_cy) return;
|
||||||
|
const q = (query || '').trim().toLowerCase();
|
||||||
|
_cy.nodes().forEach((n) => {
|
||||||
|
if (!q) {
|
||||||
|
n.style('opacity', 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = (
|
||||||
|
(n.data('label') || '') +
|
||||||
|
' ' +
|
||||||
|
(n.data('factKey') || '') +
|
||||||
|
' ' +
|
||||||
|
(n.data('typeLabel') || '')
|
||||||
|
).toLowerCase();
|
||||||
|
n.style('opacity', text.includes(q) ? 1 : 0.15);
|
||||||
|
});
|
||||||
|
_cy.edges().forEach((e) => {
|
||||||
|
e.style('opacity', q ? 0.12 : 0.9);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _connectMode = false;
|
||||||
|
let _connectPick = null;
|
||||||
|
|
||||||
|
function selectEdge(edgeId) {
|
||||||
|
if (!_cy || !edgeId) return;
|
||||||
|
_cy.elements().unselect();
|
||||||
|
const edge = _cy.getElementById(edgeId);
|
||||||
|
if (edge.length) edge.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearEdgeSelection() {
|
||||||
|
if (!_cy) return;
|
||||||
|
_cy.elements().unselect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnectMode(enabled, onPick) {
|
||||||
|
_connectMode = !!enabled;
|
||||||
|
_connectPick = typeof onPick === 'function' ? onPick : null;
|
||||||
|
if (_cy) {
|
||||||
|
_cy.userPanningEnabled(!_connectMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 GraphNodeType 一致:优先 category,vuln: 合成节点例外;无 category 时回退 type/key。 */
|
||||||
|
function resolveGraphNodeType(node) {
|
||||||
|
if (!node) return 'note';
|
||||||
|
const key = String(node.fact_key || node.id || '').toLowerCase();
|
||||||
|
if (key.startsWith('vuln:')) return 'vulnerability';
|
||||||
|
const cat = String(node.category || '').toLowerCase();
|
||||||
|
if (cat) {
|
||||||
|
if (cat === 'vuln') return 'vulnerability';
|
||||||
|
if (cat === 'missing') return 'missing';
|
||||||
|
return cat;
|
||||||
|
}
|
||||||
|
const t = String(node.type || '').toLowerCase();
|
||||||
|
if (t === 'vuln') return 'vulnerability';
|
||||||
|
if (t) return t;
|
||||||
|
if (key.startsWith('target/')) return 'target';
|
||||||
|
if (key.startsWith('exploit/') || key.startsWith('evidence/')) return 'exploit';
|
||||||
|
if (key.startsWith('poc/')) return 'poc';
|
||||||
|
if (key.startsWith('chain/')) return 'chain';
|
||||||
|
if (key.startsWith('finding/')) return 'finding';
|
||||||
|
if (key.startsWith('auth/')) return 'auth';
|
||||||
|
if (key.startsWith('infra/') || key.startsWith('business/')) return 'infra';
|
||||||
|
return 'note';
|
||||||
|
}
|
||||||
|
|
||||||
|
global.ProjectFactGraph = {
|
||||||
|
render,
|
||||||
|
destroy,
|
||||||
|
center: centerGraph,
|
||||||
|
filterBySearch,
|
||||||
|
setConnectMode,
|
||||||
|
selectEdge,
|
||||||
|
clearEdgeSelection,
|
||||||
|
nodeTheme,
|
||||||
|
resolveGraphNodeType,
|
||||||
|
};
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
+355
-5
@@ -64,6 +64,8 @@ Host: ...
|
|||||||
## 关联
|
## 关联
|
||||||
- related_vulnerability_id: <可选>
|
- related_vulnerability_id: <可选>
|
||||||
- 依赖事实: <fact_key,如 auth/session_cookie>
|
- 依赖事实: <fact_key,如 auth/session_cookie>
|
||||||
|
- 结构化关系边(自动同步;links 文本格式 type: source_fact_key):
|
||||||
|
- discovered_on: target/primary_domain
|
||||||
|
|
||||||
## 备注与不确定性
|
## 备注与不确定性
|
||||||
<待验证假设、环境差异、绕过尝试记录>`;
|
<待验证假设、环境差异、绕过尝试记录>`;
|
||||||
@@ -730,20 +732,316 @@ async function selectProject(id) {
|
|||||||
|
|
||||||
function switchProjectTab(tab) {
|
function switchProjectTab(tab) {
|
||||||
currentProjectTab = tab;
|
currentProjectTab = tab;
|
||||||
['facts', 'conversations', 'vulns', 'settings'].forEach((t) => {
|
['facts', 'graph', 'conversations', 'vulns', 'settings'].forEach((t) => {
|
||||||
const btn = document.getElementById(`project-tab-${t}`);
|
const btn = document.getElementById(`project-tab-${t}`);
|
||||||
const panel = document.getElementById(`project-panel-${t}`);
|
const panel = document.getElementById(`project-panel-${t}`);
|
||||||
if (btn) btn.classList.toggle('is-active', t === tab);
|
if (btn) btn.classList.toggle('is-active', t === tab);
|
||||||
if (panel) panel.hidden = t !== tab;
|
if (panel) panel.hidden = t !== tab;
|
||||||
});
|
});
|
||||||
if (tab === 'facts') loadProjectFacts();
|
if (tab === 'facts') loadProjectFacts();
|
||||||
|
if (tab === 'graph') loadProjectFactGraph();
|
||||||
if (tab === 'conversations') loadProjectConversations();
|
if (tab === 'conversations') loadProjectConversations();
|
||||||
if (tab === 'vulns') loadProjectVulnerabilities();
|
if (tab === 'vulns') loadProjectVulnerabilities();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _selectedGraphFactKey = null;
|
||||||
|
let _selectedGraphEdgeId = null;
|
||||||
|
let _currentGraphData = null;
|
||||||
|
let _graphConnectMode = false;
|
||||||
|
let _graphConnectSource = null;
|
||||||
|
|
||||||
|
function toggleProjectFactGraphConnectMode() {
|
||||||
|
_graphConnectMode = !_graphConnectMode;
|
||||||
|
_graphConnectSource = null;
|
||||||
|
const btn = document.getElementById('project-graph-connect-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.toggle('is-active', _graphConnectMode);
|
||||||
|
btn.textContent = _graphConnectMode ? tp('projects.graphConnectActive') : tp('projects.graphConnect');
|
||||||
|
btn.classList.toggle('projects-graph-action-btn--connect-active', _graphConnectMode);
|
||||||
|
}
|
||||||
|
if (typeof ProjectFactGraph !== 'undefined') {
|
||||||
|
ProjectFactGraph.setConnectMode(_graphConnectMode, handleGraphConnectNodePick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGraphConnectNodePick(factKey) {
|
||||||
|
if (!factKey || String(factKey).startsWith('vuln:')) return;
|
||||||
|
if (!_graphConnectSource) {
|
||||||
|
_graphConnectSource = factKey;
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification(tpFmt('projects.graphConnectPickTarget', `已选源节点 ${factKey},请点击目标节点`, { source: factKey }), 'info');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_graphConnectSource === factKey) return;
|
||||||
|
const edgeType = window.prompt(tp('projects.graphEdgeTypePrompt'), 'leads_to');
|
||||||
|
if (!edgeType) {
|
||||||
|
_graphConnectSource = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await apiFetch(`/api/projects/${currentProjectId}/fact-edges`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
source_fact_key: _graphConnectSource,
|
||||||
|
target_fact_key: factKey,
|
||||||
|
edge_type: edgeType.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
_graphConnectSource = null;
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
return alert(err.error || tp('projects.graphConnectFailed'));
|
||||||
|
}
|
||||||
|
if (typeof showNotification === 'function') showNotification(tp('projects.graphConnectSuccess'), 'success');
|
||||||
|
loadProjectFactGraph();
|
||||||
|
loadProjectFacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIncomingLinksForModal(links) {
|
||||||
|
if (!links || !links.length) return '';
|
||||||
|
return links
|
||||||
|
.map((e) => `${e.edge_type || e.type}: ${e.source_fact_key || e.from}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function loadProjectFactGraph() {
|
||||||
|
const container = document.getElementById('project-fact-graph-container');
|
||||||
|
const statsEl = document.getElementById('project-fact-graph-stats');
|
||||||
|
if (!container || !currentProjectId) return;
|
||||||
|
container.innerHTML = `<div class="loading-spinner">${escapeHtml(tp('common.loading'))}</div>`;
|
||||||
|
closeProjectFactGraphSidebar();
|
||||||
|
const view = document.getElementById('project-graph-view')?.value || 'path';
|
||||||
|
const hideDeprecated = document.getElementById('project-facts-filter-hide-deprecated')?.checked !== false;
|
||||||
|
const params = new URLSearchParams({ view });
|
||||||
|
if (!hideDeprecated) params.set('exclude_deprecated', '0');
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/projects/${currentProjectId}/fact-graph?${params}`);
|
||||||
|
if (!res.ok) throw new Error(tp('common.loadFailed'));
|
||||||
|
const data = await res.json();
|
||||||
|
_currentGraphData = data;
|
||||||
|
if (typeof ProjectFactGraph !== 'undefined') {
|
||||||
|
ProjectFactGraph.render(container, data, {
|
||||||
|
emptyText: tp('projects.graphEmpty'),
|
||||||
|
emptyTitle: tp('projects.graphEmptyTitle'),
|
||||||
|
emptySteps: [
|
||||||
|
tp('projects.graphEmptyStep1'),
|
||||||
|
tp('projects.graphEmptyStep2'),
|
||||||
|
tp('projects.graphEmptyStep3'),
|
||||||
|
],
|
||||||
|
emptyActionLabel: tp('projects.graphEmptyCta'),
|
||||||
|
onEmptyAction: () => showAddFactModal(),
|
||||||
|
onNodeSelect: (factKey) => showProjectFactGraphNode(factKey, _currentGraphData),
|
||||||
|
onEdgeSelect: (edgeId) => showProjectFactGraphEdge(edgeId, _currentGraphData),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const nodeCount = (data.nodes || []).length;
|
||||||
|
const edgeCount = (data.edges || []).length;
|
||||||
|
if (statsEl) {
|
||||||
|
statsEl.innerHTML =
|
||||||
|
`<span class="projects-graph-stat-badge"><strong>${nodeCount}</strong> ${escapeHtml(tp('projects.graphStatsNodes'))}</span>` +
|
||||||
|
`<span class="projects-graph-stat-badge"><strong>${edgeCount}</strong> ${escapeHtml(tp('projects.graphStatsEdges'))}</span>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<div class="error-message">${escapeHtml(e.message || tp('common.loadFailed'))}</div>`;
|
||||||
|
if (statsEl) statsEl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterProjectFactGraph() {
|
||||||
|
const q = document.getElementById('project-graph-search')?.value || '';
|
||||||
|
if (typeof ProjectFactGraph !== 'undefined') {
|
||||||
|
ProjectFactGraph.filterBySearch(q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerProjectFactGraph() {
|
||||||
|
if (typeof ProjectFactGraph !== 'undefined') ProjectFactGraph.center();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProjectFactGraphSidebar() {
|
||||||
|
_selectedGraphFactKey = null;
|
||||||
|
_selectedGraphEdgeId = null;
|
||||||
|
if (typeof ProjectFactGraph !== 'undefined') ProjectFactGraph.clearEdgeSelection();
|
||||||
|
const sidebar = document.getElementById('project-fact-graph-sidebar');
|
||||||
|
if (sidebar) sidebar.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSyntheticGraphEdge(edge) {
|
||||||
|
if (!edge) return true;
|
||||||
|
const id = String(edge.id || '');
|
||||||
|
const type = String(edge.type || '');
|
||||||
|
return id.startsWith('vuln-link:') || type === 'links_vuln';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGraphEdgesForFact(factKey, graphData) {
|
||||||
|
if (!factKey || !graphData?.edges) return [];
|
||||||
|
return graphData.edges.filter((e) => e.source === factKey || e.target === factKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId) {
|
||||||
|
const edges = getGraphEdgesForFact(factKey, graphData);
|
||||||
|
if (!edges.length) {
|
||||||
|
return `<p class="project-fact-graph-edges-empty">${escapeHtml(tp('projects.graphEdgesEmpty'))}</p>`;
|
||||||
|
}
|
||||||
|
return edges
|
||||||
|
.map((e) => {
|
||||||
|
const isOut = e.source === factKey;
|
||||||
|
const dirLabel = isOut ? tp('projects.graphEdgeFromSelf') : tp('projects.graphEdgeToSelf');
|
||||||
|
const src = e.source || '';
|
||||||
|
const tgt = e.target || '';
|
||||||
|
const selected = e.id === selectedEdgeId ? ' is-selected' : '';
|
||||||
|
const synthetic = isSyntheticGraphEdge(e);
|
||||||
|
const deleteBtn = synthetic
|
||||||
|
? `<span class="project-fact-graph-edge-synthetic" title="${escapeHtml(tp('projects.graphEdgeSynthetic'))}">—</span>`
|
||||||
|
: `<button type="button" class="project-fact-graph-edge-delete" data-edge-id="${escapeHtml(e.id)}" onclick="event.stopPropagation(); deleteProjectFactEdge(this.dataset.edgeId)" title="${escapeHtml(tp('projects.graphDeleteEdge'))}">×</button>`;
|
||||||
|
return `<div class="project-fact-graph-edge-item${selected}" data-edge-id="${escapeHtml(e.id)}" onclick="focusProjectFactGraphEdge(${JSON.stringify(e.id)})">
|
||||||
|
<span class="project-fact-graph-edge-dir">${escapeHtml(dirLabel)}</span>
|
||||||
|
<span class="project-fact-graph-edge-type">${escapeHtml(e.type || '')}</span>
|
||||||
|
<span class="project-fact-graph-edge-peer" title="${escapeHtml(src + ' → ' + tgt)}">${escapeHtml(src)} → ${escapeHtml(tgt)}</span>
|
||||||
|
${deleteBtn}
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjectFactGraphEdges(factKey, graphData, selectedEdgeId) {
|
||||||
|
const wrap = document.getElementById('project-fact-graph-edges-wrap');
|
||||||
|
const list = document.getElementById('project-fact-graph-edges-list');
|
||||||
|
if (!wrap || !list) return;
|
||||||
|
const edges = getGraphEdgesForFact(factKey, graphData);
|
||||||
|
wrap.hidden = false;
|
||||||
|
list.innerHTML = renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId);
|
||||||
|
if (selectedEdgeId) {
|
||||||
|
const selectedEl = list.querySelector('[data-edge-id="' + String(selectedEdgeId).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]');
|
||||||
|
if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
if (!edges.length) wrap.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function graphVulnIdFromKey(factKey) {
|
||||||
|
const key = String(factKey || '');
|
||||||
|
if (!key.startsWith('vuln:')) return null;
|
||||||
|
return key.slice(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProjectFactGraphNode(factKey, graphData, selectedEdgeId) {
|
||||||
|
if (!factKey) {
|
||||||
|
closeProjectFactGraphSidebar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_selectedGraphFactKey = factKey;
|
||||||
|
_selectedGraphEdgeId = selectedEdgeId || null;
|
||||||
|
const node = (graphData?.nodes || []).find((n) => n.fact_key === factKey || n.id === factKey);
|
||||||
|
const vulnId = graphVulnIdFromKey(factKey);
|
||||||
|
const isVulnNode = !!vulnId;
|
||||||
|
const sidebar = document.getElementById('project-fact-graph-sidebar');
|
||||||
|
const titleEl = document.getElementById('project-fact-graph-node-title');
|
||||||
|
const metaEl = document.getElementById('project-fact-graph-node-meta');
|
||||||
|
const categoryEl = document.getElementById('project-fact-graph-node-category');
|
||||||
|
const detailBtn = document.getElementById('project-fact-graph-detail-btn');
|
||||||
|
const editBtn = document.getElementById('project-fact-graph-edit-btn');
|
||||||
|
if (!sidebar || !titleEl || !metaEl) return;
|
||||||
|
titleEl.textContent = isVulnNode ? vulnId : factKey;
|
||||||
|
titleEl.title = isVulnNode ? vulnId : factKey;
|
||||||
|
if (categoryEl) {
|
||||||
|
const visualType =
|
||||||
|
typeof ProjectFactGraph !== 'undefined' && ProjectFactGraph.resolveGraphNodeType
|
||||||
|
? ProjectFactGraph.resolveGraphNodeType(node)
|
||||||
|
: node?.type || node?.category || 'note';
|
||||||
|
const theme =
|
||||||
|
typeof ProjectFactGraph !== 'undefined' && ProjectFactGraph.nodeTheme
|
||||||
|
? ProjectFactGraph.nodeTheme(visualType)
|
||||||
|
: { typeEn: String(visualType).toUpperCase(), typeLabel: visualType };
|
||||||
|
categoryEl.textContent = theme.typeEn || String(visualType).toUpperCase();
|
||||||
|
categoryEl.hidden = false;
|
||||||
|
categoryEl.className = 'project-fact-graph-node-category project-fact-graph-node-category--' + visualType;
|
||||||
|
categoryEl.title = theme.typeLabel || visualType;
|
||||||
|
}
|
||||||
|
const conf = node?.confidence || '';
|
||||||
|
const summary = (node?.summary || node?.label || '').trim();
|
||||||
|
if (summary || conf || isVulnNode) {
|
||||||
|
const parts = [];
|
||||||
|
if (summary) {
|
||||||
|
parts.push(`<span class="project-fact-graph-node-summary">${escapeHtml(summary)}</span>`);
|
||||||
|
}
|
||||||
|
if (isVulnNode) {
|
||||||
|
parts.push(
|
||||||
|
`<span class="project-fact-graph-node-vuln-hint">${escapeHtml(tp('projects.graphVulnSidebarHint'))}</span>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (conf) {
|
||||||
|
parts.push(formatConfidenceBadge(conf));
|
||||||
|
}
|
||||||
|
metaEl.innerHTML = parts.join('');
|
||||||
|
} else {
|
||||||
|
metaEl.textContent = '';
|
||||||
|
}
|
||||||
|
if (detailBtn) {
|
||||||
|
detailBtn.textContent = isVulnNode ? tp('projects.viewVulnerability') : tp('projects.details');
|
||||||
|
}
|
||||||
|
if (editBtn) {
|
||||||
|
editBtn.hidden = isVulnNode;
|
||||||
|
}
|
||||||
|
renderProjectFactGraphEdges(factKey, graphData, _selectedGraphEdgeId);
|
||||||
|
if (_selectedGraphEdgeId && typeof ProjectFactGraph !== 'undefined') {
|
||||||
|
ProjectFactGraph.selectEdge(_selectedGraphEdgeId);
|
||||||
|
} else if (typeof ProjectFactGraph !== 'undefined') {
|
||||||
|
ProjectFactGraph.clearEdgeSelection();
|
||||||
|
}
|
||||||
|
sidebar.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProjectFactGraphEdge(edgeId, graphData) {
|
||||||
|
const edge = (graphData?.edges || []).find((e) => e.id === edgeId);
|
||||||
|
if (!edge) return;
|
||||||
|
const anchorKey = edge.source && !String(edge.source).startsWith('vuln:') ? edge.source : edge.target;
|
||||||
|
showProjectFactGraphNode(anchorKey, graphData, edgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusProjectFactGraphEdge(edgeId) {
|
||||||
|
if (!edgeId || !_currentGraphData) return;
|
||||||
|
showProjectFactGraphEdge(edgeId, _currentGraphData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProjectFactEdge(edgeId) {
|
||||||
|
if (!edgeId || !currentProjectId) return;
|
||||||
|
const edge = (_currentGraphData?.edges || []).find((e) => e.id === edgeId);
|
||||||
|
if (isSyntheticGraphEdge(edge)) return;
|
||||||
|
if (!confirm(tp('projects.confirmDeleteGraphEdge'))) return;
|
||||||
|
const res = await apiFetch(`/api/projects/${currentProjectId}/fact-edges/${encodeURIComponent(edgeId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
return alert(err.error || tp('projects.graphEdgeDeleteFailed'));
|
||||||
|
}
|
||||||
|
if (typeof showNotification === 'function') showNotification(tp('projects.graphEdgeDeleteSuccess'), 'success');
|
||||||
|
const keepKey = _selectedGraphFactKey;
|
||||||
|
await loadProjectFactGraph();
|
||||||
|
if (keepKey) showProjectFactGraphNode(keepKey, _currentGraphData);
|
||||||
|
loadProjectFacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSelectedGraphFactDetail() {
|
||||||
|
if (!_selectedGraphFactKey) return;
|
||||||
|
const vulnId = graphVulnIdFromKey(_selectedGraphFactKey);
|
||||||
|
if (vulnId) {
|
||||||
|
openVulnerabilityDetail(vulnId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewProjectFactBody(_selectedGraphFactKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSelectedGraphFact() {
|
||||||
|
if (_selectedGraphFactKey) showEditFactModal(_selectedGraphFactKey);
|
||||||
|
}
|
||||||
|
|
||||||
function buildProjectFactsQueryParams() {
|
function buildProjectFactsQueryParams() {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('limit', '200');
|
params.set('limit', '200');
|
||||||
|
params.set('include_link_counts', 'true');
|
||||||
const search = document.getElementById('project-facts-search')?.value?.trim();
|
const search = document.getElementById('project-facts-search')?.value?.trim();
|
||||||
const category = document.getElementById('project-facts-filter-category')?.value?.trim();
|
const category = document.getElementById('project-facts-filter-category')?.value?.trim();
|
||||||
const confidence = document.getElementById('project-facts-filter-confidence')?.value?.trim();
|
const confidence = document.getElementById('project-facts-filter-confidence')?.value?.trim();
|
||||||
@@ -768,11 +1066,11 @@ function debouncedLoadProjectFacts() {
|
|||||||
async function loadProjectFacts() {
|
async function loadProjectFacts() {
|
||||||
const tbody = document.getElementById('project-facts-tbody');
|
const tbody = document.getElementById('project-facts-tbody');
|
||||||
if (!tbody || !currentProjectId) return;
|
if (!tbody || !currentProjectId) return;
|
||||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tp('common.loading'))}</td></tr>`;
|
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${escapeHtml(tp('common.loading'))}</td></tr>`;
|
||||||
const qs = buildProjectFactsQueryParams().toString();
|
const qs = buildProjectFactsQueryParams().toString();
|
||||||
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`);
|
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
|
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const facts = await res.json();
|
const facts = await res.json();
|
||||||
@@ -782,7 +1080,7 @@ async function loadProjectFacts() {
|
|||||||
document.getElementById('project-facts-filter-category')?.value ||
|
document.getElementById('project-facts-filter-category')?.value ||
|
||||||
document.getElementById('project-facts-filter-confidence')?.value ||
|
document.getElementById('project-facts-filter-confidence')?.value ||
|
||||||
document.getElementById('project-facts-filter-sparse')?.checked;
|
document.getElementById('project-facts-filter-sparse')?.checked;
|
||||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${
|
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${
|
||||||
hasFilter ? tp('projects.noMatchingFacts') : tp('projects.noFacts')
|
hasFilter ? tp('projects.noMatchingFacts') : tp('projects.noFacts')
|
||||||
}</td></tr>`;
|
}</td></tr>`;
|
||||||
refreshProjectHeaderStats();
|
refreshProjectHeaderStats();
|
||||||
@@ -797,10 +1095,16 @@ async function loadProjectFacts() {
|
|||||||
const pinBadge = f.pinned
|
const pinBadge = f.pinned
|
||||||
? `<span class="projects-list-item-badge" title="${escapeHtml(tp('projects.pinned'))}">${escapeHtml(tp('projects.pinned'))}</span>`
|
? `<span class="projects-list-item-badge" title="${escapeHtml(tp('projects.pinned'))}">${escapeHtml(tp('projects.pinned'))}</span>`
|
||||||
: '';
|
: '';
|
||||||
|
const lc = f.link_counts || {};
|
||||||
|
const linkBadge =
|
||||||
|
lc.outgoing || lc.incoming
|
||||||
|
? `<span class="projects-fact-link-badge" title="${escapeHtml(tp('projects.linkCountsTitle'))}">↑${lc.outgoing || 0} ↓${lc.incoming || 0}</span>`
|
||||||
|
: '<span class="projects-fact-link-badge projects-fact-link-badge--empty">—</span>';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${pinBadge}${vulnLink}</td>
|
<td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${pinBadge}${vulnLink}</td>
|
||||||
<td class="cell-fact-category">${formatCategoryBadge(f.category)}</td>
|
<td class="cell-fact-category">${formatCategoryBadge(f.category)}</td>
|
||||||
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
|
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
|
||||||
|
<td class="cell-fact-links">${linkBadge}</td>
|
||||||
<td>${formatFactBodyBadge(f)}</td>
|
<td>${formatFactBodyBadge(f)}</td>
|
||||||
<td>${formatConfidenceBadge(f.confidence)}</td>
|
<td>${formatConfidenceBadge(f.confidence)}</td>
|
||||||
<td>${formatProjectTime(f.updated_at, f.created_at)}</td>
|
<td>${formatProjectTime(f.updated_at, f.created_at)}</td>
|
||||||
@@ -849,6 +1153,7 @@ async function loadProjectConversations() {
|
|||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
<div class="projects-table-actions">
|
<div class="projects-table-actions">
|
||||||
<button type="button" class="projects-action-btn projects-action-btn--view" data-conv-id="${idEsc}" onclick="openProjectConversation(this.dataset.convId)">${escapeHtml(tp('projects.open'))}</button>
|
<button type="button" class="projects-action-btn projects-action-btn--view" data-conv-id="${idEsc}" onclick="openProjectConversation(this.dataset.convId)">${escapeHtml(tp('projects.open'))}</button>
|
||||||
|
<button type="button" class="projects-action-btn" data-conv-id="${idEsc}" onclick="promoteConversationAttackChain(this.dataset.convId)" title="${escapeHtml(tp('projects.promoteAttackChainTitle'))}">${escapeHtml(tp('projects.promoteAttackChain'))}</button>
|
||||||
<button type="button" class="projects-action-btn projects-action-btn--mute" data-conv-id="${idEsc}" onclick="unbindConversationFromProject(this.dataset.convId)" title="${escapeHtml(tp('projects.unbindProjectTitle'))}">${escapeHtml(tp('projects.unbind'))}</button>
|
<button type="button" class="projects-action-btn projects-action-btn--mute" data-conv-id="${idEsc}" onclick="unbindConversationFromProject(this.dataset.convId)" title="${escapeHtml(tp('projects.unbindProjectTitle'))}">${escapeHtml(tp('projects.unbind'))}</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -869,6 +1174,32 @@ function openProjectConversation(conversationId) {
|
|||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function promoteConversationAttackChain(conversationId) {
|
||||||
|
if (!currentProjectId || !conversationId) return;
|
||||||
|
if (!confirm(tp('projects.confirmPromoteAttackChain'))) return;
|
||||||
|
const res = await apiFetch(
|
||||||
|
`/api/projects/${currentProjectId}/promote-attack-chain/${encodeURIComponent(conversationId)}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
return alert(err.error || tp('projects.promoteAttackChainFailed'));
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification(
|
||||||
|
tpFmt(
|
||||||
|
'projects.promoteAttackChainSuccess',
|
||||||
|
`已沉淀 ${data.facts_created || 0} 新 / ${data.facts_updated || 0} 更新 / ${data.edges_created || 0} 边`,
|
||||||
|
data,
|
||||||
|
),
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
loadProjectFacts();
|
||||||
|
if (currentProjectTab === 'graph') loadProjectFactGraph();
|
||||||
|
}
|
||||||
|
|
||||||
async function unbindConversationFromProject(conversationId) {
|
async function unbindConversationFromProject(conversationId) {
|
||||||
if (!conversationId || !confirm(tp('projects.confirmUnbindConversation'))) return;
|
if (!conversationId || !confirm(tp('projects.confirmUnbindConversation'))) return;
|
||||||
const res = await apiFetch(`/api/conversations/${encodeURIComponent(conversationId)}/project`, {
|
const res = await apiFetch(`/api/conversations/${encodeURIComponent(conversationId)}/project`, {
|
||||||
@@ -1509,6 +1840,10 @@ function resetFactModalForm() {
|
|||||||
if (pinEl) pinEl.checked = false;
|
if (pinEl) pinEl.checked = false;
|
||||||
const rel = document.getElementById('fact-modal-related-vuln');
|
const rel = document.getElementById('fact-modal-related-vuln');
|
||||||
if (rel) rel.value = '';
|
if (rel) rel.value = '';
|
||||||
|
const linksEl = document.getElementById('fact-modal-links');
|
||||||
|
if (linksEl) linksEl.value = '';
|
||||||
|
const incomingWrap = document.getElementById('fact-modal-incoming-links-wrap');
|
||||||
|
if (incomingWrap) incomingWrap.hidden = true;
|
||||||
updateFactFormHints();
|
updateFactFormHints();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1540,6 +1875,8 @@ function fillFactModalForm(f) {
|
|||||||
}
|
}
|
||||||
const rel = document.getElementById('fact-modal-related-vuln');
|
const rel = document.getElementById('fact-modal-related-vuln');
|
||||||
if (rel) rel.value = f.related_vulnerability_id || '';
|
if (rel) rel.value = f.related_vulnerability_id || '';
|
||||||
|
const linksEl = document.getElementById('fact-modal-links');
|
||||||
|
if (linksEl) linksEl.value = formatIncomingLinksForModal(f.incoming_links);
|
||||||
const pinEl = document.getElementById('fact-modal-pinned');
|
const pinEl = document.getElementById('fact-modal-pinned');
|
||||||
if (pinEl) pinEl.checked = !!f.pinned;
|
if (pinEl) pinEl.checked = !!f.pinned;
|
||||||
updateFactFormHints();
|
updateFactFormHints();
|
||||||
@@ -1556,7 +1893,7 @@ async function showEditFactModal(factKey) {
|
|||||||
resetFactModalForm();
|
resetFactModalForm();
|
||||||
openProjectsOverlay('fact-modal', { focus: false });
|
openProjectsOverlay('fact-modal', { focus: false });
|
||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`,
|
`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}&include_links=true`,
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
closeFactModal();
|
closeFactModal();
|
||||||
@@ -1594,6 +1931,7 @@ async function saveFactModal() {
|
|||||||
confidence: document.getElementById('fact-modal-confidence').value,
|
confidence: document.getElementById('fact-modal-confidence').value,
|
||||||
pinned: !!document.getElementById('fact-modal-pinned')?.checked,
|
pinned: !!document.getElementById('fact-modal-pinned')?.checked,
|
||||||
related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '',
|
related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '',
|
||||||
|
links_text: document.getElementById('fact-modal-links')?.value || '',
|
||||||
};
|
};
|
||||||
const editId = window._factModalEditId;
|
const editId = window._factModalEditId;
|
||||||
const res = editId
|
const res = editId
|
||||||
@@ -1613,12 +1951,14 @@ async function saveFactModal() {
|
|||||||
}
|
}
|
||||||
closeFactModal();
|
closeFactModal();
|
||||||
loadProjectFacts();
|
loadProjectFacts();
|
||||||
|
if (currentProjectTab === 'graph') loadProjectFactGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteProjectFact(id) {
|
async function deleteProjectFact(id) {
|
||||||
if (!confirm(tp('projects.confirmDeleteFact'))) return;
|
if (!confirm(tp('projects.confirmDeleteFact'))) return;
|
||||||
await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' });
|
await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' });
|
||||||
loadProjectFacts();
|
loadProjectFacts();
|
||||||
|
if (currentProjectTab === 'graph') loadProjectFactGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseProjectDate(t) {
|
function parseProjectDate(t) {
|
||||||
@@ -1974,5 +2314,15 @@ window.viewFactsForVulnerability = viewFactsForVulnerability;
|
|||||||
window.openProjectConversation = openProjectConversation;
|
window.openProjectConversation = openProjectConversation;
|
||||||
window.unbindConversationFromProject = unbindConversationFromProject;
|
window.unbindConversationFromProject = unbindConversationFromProject;
|
||||||
window.loadProjectConversations = loadProjectConversations;
|
window.loadProjectConversations = loadProjectConversations;
|
||||||
|
window.loadProjectFactGraph = loadProjectFactGraph;
|
||||||
|
window.filterProjectFactGraph = filterProjectFactGraph;
|
||||||
|
window.centerProjectFactGraph = centerProjectFactGraph;
|
||||||
|
window.closeProjectFactGraphSidebar = closeProjectFactGraphSidebar;
|
||||||
|
window.openSelectedGraphFactDetail = openSelectedGraphFactDetail;
|
||||||
|
window.editSelectedGraphFact = editSelectedGraphFact;
|
||||||
|
window.promoteConversationAttackChain = promoteConversationAttackChain;
|
||||||
|
window.deleteProjectFactEdge = deleteProjectFactEdge;
|
||||||
|
window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
|
||||||
|
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
|
||||||
window.rebuildProjectNameMap = rebuildProjectNameMap;
|
window.rebuildProjectNameMap = rebuildProjectNameMap;
|
||||||
window.projectNameById = projectNameById;
|
window.projectNameById = projectNameById;
|
||||||
|
|||||||
+100
-1
@@ -1498,6 +1498,13 @@
|
|||||||
</aside>
|
</aside>
|
||||||
<main class="projects-detail" id="projects-detail-main">
|
<main class="projects-detail" id="projects-detail-main">
|
||||||
<div class="projects-detail-placeholder" id="projects-detail-placeholder">
|
<div class="projects-detail-placeholder" id="projects-detail-placeholder">
|
||||||
|
<div class="projects-placeholder-icon" aria-hidden="true">
|
||||||
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="3" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M3 9h18M8 4V9M16 4V9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M8 14h8M8 17h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<h3 data-i18n="projects.selectOrCreateTitle">选择或创建项目</h3>
|
<h3 data-i18n="projects.selectOrCreateTitle">选择或创建项目</h3>
|
||||||
<p data-i18n="projects.selectOrCreateHint">项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。</p>
|
<p data-i18n="projects.selectOrCreateHint">项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。</p>
|
||||||
<button class="btn-primary" type="button" onclick="showNewProjectModal()" data-i18n="projects.createFirstProject">创建第一个项目</button>
|
<button class="btn-primary" type="button" onclick="showNewProjectModal()" data-i18n="projects.createFirstProject">创建第一个项目</button>
|
||||||
@@ -1527,6 +1534,7 @@
|
|||||||
</header>
|
</header>
|
||||||
<nav class="projects-tabs" role="tablist">
|
<nav class="projects-tabs" role="tablist">
|
||||||
<button type="button" id="project-tab-facts" class="projects-tab is-active" role="tab" onclick="switchProjectTab('facts')" data-i18n="projects.tabFacts">事实黑板</button>
|
<button type="button" id="project-tab-facts" class="projects-tab is-active" role="tab" onclick="switchProjectTab('facts')" data-i18n="projects.tabFacts">事实黑板</button>
|
||||||
|
<button type="button" id="project-tab-graph" class="projects-tab" role="tab" onclick="switchProjectTab('graph')" data-i18n="projects.tabGraph">攻击路径</button>
|
||||||
<button type="button" id="project-tab-conversations" class="projects-tab" role="tab" onclick="switchProjectTab('conversations')" data-i18n="projects.tabConversations">关联对话</button>
|
<button type="button" id="project-tab-conversations" class="projects-tab" role="tab" onclick="switchProjectTab('conversations')" data-i18n="projects.tabConversations">关联对话</button>
|
||||||
<button type="button" id="project-tab-vulns" class="projects-tab" role="tab" onclick="switchProjectTab('vulns')" data-i18n="projects.tabVulns">关联漏洞</button>
|
<button type="button" id="project-tab-vulns" class="projects-tab" role="tab" onclick="switchProjectTab('vulns')" data-i18n="projects.tabVulns">关联漏洞</button>
|
||||||
<button type="button" id="project-tab-settings" class="projects-tab" role="tab" onclick="switchProjectTab('settings')" data-i18n="projects.tabSettings">设置</button>
|
<button type="button" id="project-tab-settings" class="projects-tab" role="tab" onclick="switchProjectTab('settings')" data-i18n="projects.tabSettings">设置</button>
|
||||||
@@ -1587,11 +1595,96 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="projects-table-wrap">
|
<div class="projects-table-wrap">
|
||||||
<table class="data-table data-table--projects">
|
<table class="data-table data-table--projects">
|
||||||
<thead><tr><th>Key</th><th data-i18n="projects.category">分类</th><th data-i18n="projects.summary">摘要</th><th>Body</th><th data-i18n="projects.confidence">置信度</th><th data-i18n="projects.updated">更新</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead>
|
<thead><tr><th>Key</th><th data-i18n="projects.category">分类</th><th data-i18n="projects.summary">摘要</th><th data-i18n="projects.linksColumn">关系</th><th>Body</th><th data-i18n="projects.confidence">置信度</th><th data-i18n="projects.updated">更新</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead>
|
||||||
<tbody id="project-facts-tbody"></tbody>
|
<tbody id="project-facts-tbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="project-panel-graph" class="projects-panel projects-panel--graph" role="tabpanel" hidden>
|
||||||
|
<div class="projects-fact-toolbar projects-graph-toolbar">
|
||||||
|
<p class="projects-fact-toolbar-hint" role="note">
|
||||||
|
<svg class="projects-fact-toolbar-hint-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 10v6M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span data-i18n="projects.graphToolbarHint">攻击路径图箭头与事实存储方向一致(source → target);节点按 target→infra→finding→exploit 分层排布。虚线边为待确认。</span>
|
||||||
|
</p>
|
||||||
|
<div class="projects-fact-toolbar-filters projects-graph-toolbar-row">
|
||||||
|
<label class="projects-fact-filter-field">
|
||||||
|
<span class="projects-fact-filter-label" data-i18n="projects.graphView">视图</span>
|
||||||
|
<select id="project-graph-view" onchange="loadProjectFactGraph()">
|
||||||
|
<option value="path" data-i18n="projects.graphViewPath">攻击路径</option>
|
||||||
|
<option value="full" data-i18n="projects.graphViewFull">完整关系</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="projects-fact-filter-field projects-fact-filter-field--search projects-graph-search-field">
|
||||||
|
<span class="sr-only" data-i18n="projects.graphSearchSr">搜索节点</span>
|
||||||
|
<svg class="projects-fact-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M20 20L16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<input type="search" id="project-graph-search" placeholder="搜索节点…" oninput="filterProjectFactGraph()" autocomplete="off" data-i18n="projects.graphSearchPlaceholder" data-i18n-attr="placeholder">
|
||||||
|
</label>
|
||||||
|
<div class="projects-graph-actions" role="group" aria-label="Graph actions">
|
||||||
|
<button type="button" class="projects-graph-action-btn" onclick="loadProjectFactGraph()" title="刷新" data-i18n="projects.graphRefresh" data-i18n-attr="title">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M21 3v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span data-i18n="projects.graphRefresh">刷新</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="projects-graph-action-btn" onclick="centerProjectFactGraph()" title="居中" data-i18n="projects.graphCenter" data-i18n-attr="title">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
<span data-i18n="projects.graphCenter">居中</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="projects-graph-action-btn projects-graph-action-btn--connect" id="project-graph-connect-btn" onclick="toggleProjectFactGraphConnectMode()" data-i18n="projects.graphConnect">连边</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="project-fact-graph-layout">
|
||||||
|
<div id="project-fact-graph-container" class="project-fact-graph-container"></div>
|
||||||
|
<aside id="project-fact-graph-sidebar" class="project-fact-graph-sidebar" hidden>
|
||||||
|
<div class="project-fact-graph-sidebar-header">
|
||||||
|
<div class="project-fact-graph-sidebar-title-wrap">
|
||||||
|
<span id="project-fact-graph-node-category" class="project-fact-graph-node-category"></span>
|
||||||
|
<h4 id="project-fact-graph-node-title">—</h4>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="project-fact-graph-sidebar-close" onclick="closeProjectFactGraphSidebar()" aria-label="关闭" data-i18n="common.close" data-i18n-attr="aria-label">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="project-fact-graph-node-meta" class="project-fact-graph-node-meta"></p>
|
||||||
|
<div id="project-fact-graph-edges-wrap" class="project-fact-graph-edges-wrap" hidden>
|
||||||
|
<h5 class="project-fact-graph-edges-title" data-i18n="projects.graphEdgesTitle">关系边</h5>
|
||||||
|
<p class="project-fact-graph-edges-hint" data-i18n="projects.graphEdgesHint">箭头方向与数据库/编辑弹窗一致(source → target);点击连线可定位。</p>
|
||||||
|
<div id="project-fact-graph-edges-list" class="project-fact-graph-edges-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="project-fact-graph-sidebar-actions">
|
||||||
|
<button type="button" class="btn-primary btn-small" id="project-fact-graph-detail-btn" onclick="openSelectedGraphFactDetail()" data-i18n="projects.details">详情</button>
|
||||||
|
<button type="button" class="btn-secondary btn-small" id="project-fact-graph-edit-btn" onclick="editSelectedGraphFact()" data-i18n="common.edit">编辑</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
<div class="project-fact-graph-footer">
|
||||||
|
<div id="project-fact-graph-stats" class="project-fact-graph-stats"></div>
|
||||||
|
<div class="projects-graph-legend" role="group" aria-label="Graph legend">
|
||||||
|
<div class="projects-graph-legend-group">
|
||||||
|
<span class="projects-graph-legend-heading" data-i18n="projects.graphLegendNodes">节点</span>
|
||||||
|
<span class="projects-graph-legend-item projects-graph-legend-item--node"><i style="--legend-color:#4F46E5;--legend-bg:#F5F3FF"></i><span data-i18n="projects.graphLegendNodeTarget">TARGET · 目标</span></span>
|
||||||
|
<span class="projects-graph-legend-item projects-graph-legend-item--node"><i style="--legend-color:#64748B;--legend-bg:#F8FAFC"></i><span data-i18n="projects.graphLegendNodeInfra">INFRA · 基础设施</span></span>
|
||||||
|
<span class="projects-graph-legend-item projects-graph-legend-item--node"><i style="--legend-color:#E11D48;--legend-bg:#FFF1F2"></i><span data-i18n="projects.graphLegendNodeFinding">FINDING · 发现</span></span>
|
||||||
|
<span class="projects-graph-legend-item projects-graph-legend-item--node"><i style="--legend-color:#9333EA;--legend-bg:#F5F3FF"></i><span data-i18n="projects.graphLegendNodeVuln">VULN · 漏洞</span></span>
|
||||||
|
<span class="projects-graph-legend-item projects-graph-legend-item--node"><i style="--legend-color:#B45309;--legend-bg:#FFFBEB"></i><span data-i18n="projects.graphLegendNodeExploit">EXPLOIT · 利用</span></span>
|
||||||
|
<span class="projects-graph-legend-item projects-graph-legend-item--node projects-graph-legend-item--node-dashed"><i style="--legend-color:#CBD5E1;--legend-bg:#F1F5F9"></i><span data-i18n="projects.graphLegendNodeMissing">MISSING · 缺失</span></span>
|
||||||
|
</div>
|
||||||
|
<span class="projects-graph-legend-divider" aria-hidden="true"></span>
|
||||||
|
<div class="projects-graph-legend-group">
|
||||||
|
<span class="projects-graph-legend-heading" data-i18n="projects.graphLegendEdges">连线</span>
|
||||||
|
<span class="projects-graph-legend-item projects-graph-legend-item--edge"><i style="--legend-color:#4F46E5"></i><span data-i18n="projects.graphLegendDiscovered">discovered_on</span></span>
|
||||||
|
<span class="projects-graph-legend-item projects-graph-legend-item--edge"><i style="--legend-color:#64748B"></i><span data-i18n="projects.graphLegendLeads">leads_to</span></span>
|
||||||
|
<span class="projects-graph-legend-item projects-graph-legend-item--edge"><i style="--legend-color:#DC2626"></i><span data-i18n="projects.graphLegendExploits">exploits</span></span>
|
||||||
|
<span class="projects-graph-legend-item projects-graph-legend-item--edge projects-graph-legend-item--dashed"><i style="--legend-color:#94A3B8"></i><span data-i18n="projects.graphLegendTentative">待确认(虚线)</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden>
|
<div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden>
|
||||||
<div class="projects-panel-toolbar projects-panel-toolbar--hint">
|
<div class="projects-panel-toolbar projects-panel-toolbar--hint">
|
||||||
<p class="projects-fact-toolbar-hint" role="note">
|
<p class="projects-fact-toolbar-hint" role="note">
|
||||||
@@ -4339,6 +4432,11 @@
|
|||||||
<label for="fact-modal-related-vuln" data-i18n="projects.relatedVulnIdLabel">关联漏洞 ID</label>
|
<label for="fact-modal-related-vuln" data-i18n="projects.relatedVulnIdLabel">关联漏洞 ID</label>
|
||||||
<input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选" data-i18n="projects.optional" data-i18n-attr="placeholder">
|
<input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选" data-i18n="projects.optional" data-i18n-attr="placeholder">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="fact-modal-links" data-i18n="projects.factLinksLabel">关系边(from → 本事实)</label>
|
||||||
|
<textarea id="fact-modal-links" class="form-input" rows="4" placeholder="discovered_on: target/primary_domain exploits: exploit/upload-rce" data-i18n="projects.factLinksPlaceholder" data-i18n-attr="placeholder"></textarea>
|
||||||
|
<p class="projects-field-hint" data-i18n="projects.factLinksHint">每行一条:type: source_fact_key(来源 → 当前事实)。常用 type:discovered_on、depends_on、leads_to、enables、exploits。保存时替换全部关系边。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="projects-modal-footer">
|
<div class="projects-modal-footer">
|
||||||
<button class="btn-secondary" type="button" onclick="closeFactModal()" data-i18n="common.cancel">取消</button>
|
<button class="btn-secondary" type="button" onclick="closeFactModal()" data-i18n="common.cancel">取消</button>
|
||||||
@@ -4396,6 +4494,7 @@
|
|||||||
<script src="/static/js/terminal.js"></script>
|
<script src="/static/js/terminal.js"></script>
|
||||||
<script src="/static/js/knowledge.js"></script>
|
<script src="/static/js/knowledge.js"></script>
|
||||||
<script src="/static/js/skills.js"></script>
|
<script src="/static/js/skills.js"></script>
|
||||||
|
<script src="/static/js/fact-graph.js"></script>
|
||||||
<script src="/static/js/projects.js"></script>
|
<script src="/static/js/projects.js"></script>
|
||||||
<script src="/static/js/vulnerability.js?v=12"></script>
|
<script src="/static/js/vulnerability.js?v=12"></script>
|
||||||
<script src="/static/js/webshell.js"></script>
|
<script src="/static/js/webshell.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user