Compare commits

..

49 Commits

Author SHA1 Message Date
公明 51f1cfde2f Add files via upload 2026-06-22 23:12:53 +08:00
公明 b2c8913014 Add files via upload 2026-06-22 17:53:52 +08:00
公明 ae98288b62 Add files via upload 2026-06-22 15:53:31 +08:00
公明 9955e856a0 Add files via upload 2026-06-22 15:48:44 +08:00
公明 018544e5f9 Add files via upload 2026-06-22 15:43:39 +08:00
公明 c1c86e4632 Add files via upload 2026-06-22 13:47:53 +08:00
公明 08d77bc12b Add files via upload 2026-06-21 01:56:48 +08:00
公明 ce73a7b3e4 Add files via upload 2026-06-21 01:55:25 +08:00
公明 f78f424aab Add files via upload 2026-06-21 01:53:55 +08:00
公明 e19d8e39bd Add files via upload 2026-06-21 01:52:14 +08:00
公明 ecf594a25b Update config.yaml 2026-06-20 20:37:48 +08:00
公明 d5759f6d83 Add files via upload 2026-06-20 19:57:07 +08:00
公明 81b3f64b15 Add files via upload 2026-06-20 19:55:32 +08:00
公明 0e0f1352f0 Add files via upload 2026-06-20 19:52:33 +08:00
公明 ffba311afd Add files via upload 2026-06-20 19:47:47 +08:00
公明 d9ed36cfb1 Add files via upload 2026-06-20 19:45:29 +08:00
公明 b7f80b78ee Add files via upload 2026-06-20 19:39:39 +08:00
公明 8f8e5cfff5 Increase rune limits in config.yaml 2026-06-20 19:37:50 +08:00
公明 120f860640 Add files via upload 2026-06-20 19:36:35 +08:00
公明 90cd119a83 Add files via upload 2026-06-20 19:35:06 +08:00
公明 56d597e0c5 Add files via upload 2026-06-20 19:31:56 +08:00
公明 11ab5cde8f Add files via upload 2026-06-20 19:28:34 +08:00
公明 46a7d338a4 Add files via upload 2026-06-20 17:25:44 +08:00
公明 46f68cc1d4 Update config.yaml 2026-06-20 16:19:57 +08:00
公明 7003cdb2e3 Add files via upload 2026-06-20 15:34:58 +08:00
公明 4e5e6208bd Add files via upload 2026-06-20 15:29:36 +08:00
公明 6a7e78a846 Add files via upload 2026-06-20 15:28:10 +08:00
公明 88c6fbfb75 Add files via upload 2026-06-20 15:26:49 +08:00
公明 1cd6d0fa90 Add files via upload 2026-06-20 15:24:40 +08:00
公明 24390db100 Add files via upload 2026-06-19 01:41:32 +08:00
公明 c000fe5195 Add files via upload 2026-06-19 01:39:53 +08:00
公明 0b4a11d01a Add files via upload 2026-06-19 01:38:30 +08:00
公明 d433e44a7d Add files via upload 2026-06-19 01:36:52 +08:00
公明 7de51fe0ea Update config.yaml 2026-06-19 00:05:50 +08:00
公明 a354cf97e5 Add files via upload 2026-06-19 00:04:38 +08:00
公明 c180f07c7e Add files via upload 2026-06-19 00:02:53 +08:00
公明 15730d3ef4 Add files via upload 2026-06-19 00:01:20 +08:00
公明 b7fa18b6d4 Add files via upload 2026-06-18 23:44:04 +08:00
公明 8d622f63ff Update version to v1.6.40 in config.yaml 2026-06-18 23:24:14 +08:00
公明 20b05146fb Add files via upload 2026-06-18 23:23:48 +08:00
公明 d8768eae76 Add files via upload 2026-06-18 23:21:58 +08:00
公明 9232cee38d Add files via upload 2026-06-18 23:20:39 +08:00
公明 6c975e63d2 Add files via upload 2026-06-18 23:19:09 +08:00
公明 e175523b82 Add files via upload 2026-06-18 23:17:30 +08:00
公明 ae23427d9e Add files via upload 2026-06-18 21:53:20 +08:00
公明 93a2504ce3 Add files via upload 2026-06-18 21:52:36 +08:00
公明 09b0479fb3 Add files via upload 2026-06-18 21:50:44 +08:00
公明 2bdc9d4fe0 Add files via upload 2026-06-18 21:48:33 +08:00
公明 01b3d8056c Add files via upload 2026-06-18 21:09:00 +08:00
72 changed files with 9263 additions and 508 deletions
+6 -1
View File
@@ -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
- 📚 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
- 📂 **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
- 📋 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
@@ -551,6 +551,11 @@ multi_agent:
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor optional
# 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, ...
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`)
+6 -1
View File
@@ -111,7 +111,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
- 🔒 Web 登录保护、审计日志、SQLite 持久化
- 📚 知识库(RAG):向量嵌入与余弦相似度检索(与 Eino `retriever.Retriever` 语义一致),可选 **Eino Compose** 索引流水线及检索后处理(预算、重排等配置项)
- 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作
- 📂 **项目管理**按项目归类对话与漏洞;**共享事实**(项目黑板)在多会话沉淀目标/环境/认证等认知,自动注入 Agent 上下文,支持 MCP 工具读写(`upsert_project_fact``get_project_fact` 等)
- 📂 **项目管理**共享事实(黑板)会话沉淀认知,`upsert_project_fact` + `links` 串联攻击路径;聊天攻击链与项目事实图可视化
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
@@ -549,6 +549,11 @@ multi_agent:
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor 可选
# 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 等
project:
enabled: true # 启用项目黑板与事实 MCP 工具
fact_index_max_runes: 65000
fact_summary_max_runes: 24000
default_inject_deprecated: false
```
### 工具模版示例(`tools/nmap.yaml`
+9 -7
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.39"
version: "v1.6.42"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -58,7 +58,7 @@ openai:
api_key: sk-xxxxxxx # API 密钥(必填)
model: qwen3-max # 模型名称(必填)
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinkingextended thinking),mode: off 关闭
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effortClaude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
reasoning:
mode: on # auto | on | offoff 时不附加任何推理扩展字段
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
@@ -142,10 +142,10 @@ multi_agent:
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
run_retry_max_attempts: 0 # 429/5xx/网络抖动时整轮 Run 指数退避续跑;0=默认 10(与 deep_model_retry 互补,建议保持默认)
run_retry_max_attempts: 0 # 429/5xx/网络抖动时可退避重试次数(run loop + summarization 共用 isEinoTransientRunError);0=默认 10
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
deep_output_key: final_answer # P0Eino session 写入最终助手结论(框架内部;Deep/Supervisor 主/eino_single
deep_model_retry_max_retries: 3 # P0:单次 ChatModel API 失败时框架自动重试(超时/502 等);子代理模型不受此项影响
deep_model_retry_max_retries: 0 # 已废弃,请用 run_retry_max_attempts;保留字段仅为兼容旧配置
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
# Eino callbacks + OpenTelemetry:框架级 span(与 Zap 对齐);默认不向终端用户 UI 推 eino_trace_*(见 sse_trace_to_client
eino_callbacks:
@@ -308,7 +308,9 @@ roles_dir: roles # 角色配置文件目录(相对于配置文件所在目录
project:
enabled: true
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
fact_index_max_runes: 6500
fact_summary_max_runes: 2400
fact_index_max_runes: 65000
# 事实关系速览段预算(从索引总预算中预留)
fact_index_path_max_runes: 10000
fact_summary_max_runes: 24000
default_inject_deprecated: false
@@ -1,7 +1,7 @@
package agent
import (
"cyberstrike-ai/internal/project"
"cyberstrike-ai/internal/projectprompt"
)
// DefaultSingleAgentSystemPrompt 单代理(Eino ADK / MCP)内置系统提示;可通过 agent.system_prompt_path 覆盖为文件。
@@ -107,7 +107,7 @@ func DefaultSingleAgentSystemPrompt() string {
- 若最近一步得到 404/空结果/无效响应,不得直接结束;至少再进行一次“同目标不同策略”的验证(如变更路径、参数、请求方法、上下文来源)。
- 避免无效空转:同一工具+同类参数连续失败 3 次后,必须切换策略(改工具、改入口、改假设)并说明切换原因。
` + project.FactRecordingBlackboardSection(false) + `
` + projectprompt.FactRecordingBlackboardSection(false) + `
## 技能库(Skills)与知识库
+8
View File
@@ -829,6 +829,7 @@ func setupRoutes(
protected.PUT("/batch-tasks/:queueId/schedule-enabled", agentHandler.SetBatchQueueScheduleEnabled)
protected.DELETE("/batch-tasks/:queueId", agentHandler.DeleteBatchQueue)
protected.PUT("/batch-tasks/:queueId/tasks/:taskId", agentHandler.UpdateBatchTask)
protected.POST("/batch-tasks/:queueId/tasks/:taskId/run", agentHandler.RunSingleBatchTask)
protected.POST("/batch-tasks/:queueId/tasks", agentHandler.AddBatchTask)
protected.DELETE("/batch-tasks/:queueId/tasks/:taskId", agentHandler.DeleteBatchTask)
@@ -876,6 +877,7 @@ func setupRoutes(
protected.POST("/config/apply", configHandler.ApplyConfig)
protected.POST("/config/test-openai", configHandler.TestOpenAI)
protected.POST("/config/test-vision", configHandler.TestVision)
protected.POST("/config/list-models", configHandler.ListModels)
// 系统设置 - 终端(执行命令,提高运维效率)
protected.POST("/terminal/run", terminalHandler.RunCommand)
@@ -1067,6 +1069,11 @@ func setupRoutes(
protected.GET("/projects/:id", projectHandler.GetProject)
protected.PUT("/projects/:id", projectHandler.UpdateProject)
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.POST("/projects/:id/facts", projectHandler.CreateFact)
protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact)
@@ -1107,6 +1114,7 @@ func setupRoutes(
c2Routes.POST("/listeners/:id/start", c2Handler.StartListener)
c2Routes.POST("/listeners/:id/stop", c2Handler.StopListener)
c2Routes.GET("/sessions", c2Handler.ListSessions)
c2Routes.DELETE("/sessions", c2Handler.DeleteSessions)
c2Routes.GET("/sessions/:id", c2Handler.GetSession)
c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
+38 -9
View File
@@ -61,6 +61,7 @@ func registerC2ListenerTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webList
- stop: 停止监听器(需 listener_id
- delete: 删除监听器(需 listener_id
监听器类型: tcp_reverse, http_beacon, https_beacon, websocket
tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-GCM + ImplantToken)才登记会话;经典 bash/nc 反弹需在 config.allow_legacy_shell=true(公网不推荐)。
端口约束:create/update 的 bind_port 禁止与本平台 Web/API 所用端口相同。当前本服务该端口为 %d(配置项 server.port,随进程启动从配置文件加载)。若 bind_port 与此相同会导致本服务或监听器 bind 失败、Beacon/oneliner 误连到 Web 而非 C2。请为监听器另选空闲端口。`, webListenPort),
InputSchema: map[string]interface{}{
"type": "object",
@@ -74,7 +75,7 @@ func registerC2ListenerTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webList
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port", webListenPort), "minimum": 1, "maximum": 65535},
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
"remark": map[string]interface{}{"type": "string", "description": "备注"},
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用"},
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false"},
},
"required": []string{"action"},
},
@@ -222,20 +223,23 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
s.RegisterTool(mcp.Tool{
Name: builtin.ToolC2Session,
Description: `C2 会话管理。通过 action 参数选择操作:
- list: 列出会话(可按 listener_id/status/os/search 过滤)
- list: 列出会话(可按 listener_id/status/os/search/suspicious 过滤)
- get: 获取会话详情及最近任务历史(需 session_id
- set_sleep: 设置心跳间隔(需 session_id
- kill: 下发 exit 任务让 implant 退出(需 session_id
- delete: 删除会话记录(需 session_id`,
- delete: 删除单个会话记录(需 session_id
- delete_batch: 批量删除会话(需 session_ids 数组)`,
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "description": "操作: list/get/set_sleep/kill/delete", "enum": []string{"list", "get", "set_sleep", "kill", "delete"}},
"action": map[string]interface{}{"type": "string", "description": "操作: list/get/set_sleep/kill/delete/delete_batch", "enum": []string{"list", "get", "set_sleep", "kill", "delete", "delete_batch"}},
"session_id": map[string]interface{}{"type": "string", "description": "会话 IDget/set_sleep/kill/delete 需要)"},
"session_ids": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "会话 ID 列表(delete_batch"},
"listener_id": map[string]interface{}{"type": "string", "description": "按监听器过滤(list"},
"status": map[string]interface{}{"type": "string", "description": "按状态过滤: active/sleeping/dead/killedlist"},
"os": map[string]interface{}{"type": "string", "description": "按 OS 过滤: linux/windows/darwinlist"},
"search": map[string]interface{}{"type": "string", "description": "模糊搜索 hostname/username/IPlist"},
"suspicious": map[string]interface{}{"type": "boolean", "description": "仅疑似误报:离线且 tcp_* / unknown / PID 0list"},
"limit": map[string]interface{}{"type": "integer", "description": "返回数量上限(list"},
"sleep_seconds": map[string]interface{}{"type": "integer", "description": "心跳间隔秒数(set_sleep"},
"jitter_percent": map[string]interface{}{"type": "integer", "description": "抖动百分比 0-100set_sleep"},
@@ -257,6 +261,9 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
if limit := int(getFloat64(params, "limit")); limit > 0 {
filter.Limit = limit
}
if v, ok := params["suspicious"].(bool); ok && v {
filter.Suspicious = true
}
sessions, err := m.DB().ListC2Sessions(filter)
return makeC2Result(map[string]interface{}{"sessions": sessions, "count": len(sessions)}, err)
@@ -274,8 +281,16 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
case "set_sleep":
sleep := int(getFloat64(params, "sleep_seconds"))
jitter := int(getFloat64(params, "jitter_percent"))
err := m.DB().SetC2SessionSleep(id, sleep, jitter)
return makeC2Result(map[string]interface{}{"updated": err == nil, "sleep_seconds": sleep, "jitter_percent": jitter}, err)
task, err := m.SetSessionSleep(id, sleep, jitter)
out := map[string]interface{}{
"updated": err == nil,
"sleep_seconds": sleep,
"jitter_percent": jitter,
}
if task != nil {
out["task_id"] = task.ID
}
return makeC2Result(out, err)
case "kill":
task, err := m.EnqueueTask(c2.EnqueueTaskInput{
@@ -292,6 +307,17 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
err := m.DB().DeleteC2Session(id)
return makeC2Result(map[string]interface{}{"deleted": err == nil}, err)
case "delete_batch":
rawIDs, _ := params["session_ids"].([]interface{})
ids := make([]string, 0, len(rawIDs))
for _, v := range rawIDs {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
ids = append(ids, strings.TrimSpace(s))
}
}
n, err := m.DB().DeleteC2SessionsByIDs(ids)
return makeC2Result(map[string]interface{}{"deleted": n}, err)
default:
return makeC2Result(nil, fmt.Errorf("unknown action: %s", action))
}
@@ -491,11 +517,11 @@ func registerC2PayloadTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webListe
Name: builtin.ToolC2Payload,
Description: fmt.Sprintf(`C2 Payload 生成。通过 action 参数选择操作:
- oneliner: 生成单行 payload。kind 必须与监听器协议一致,否则会失败:
• tcp_reverse裸 TCP 反弹,可用 kind: bash, nc, nc_mkfifo, python, perl, powershellbash 指 /dev/tcp 类,不是 HTTP
• tcp_reverse默认仅支持 build 加密 Beacon;若监听器 config.allow_legacy_shell=true,才可用 kind: bash, nc, nc_mkfifo, python, perl, powershell。
• http_beacon / https_beacon / websocket:仅 HTTP(S) Beacon 轮询,oneliner 只能用 kind: curl_beacon(脚本内用 bash+curl,与「tcp 的 bash」不同)。curl_beacon 返回串末尾含「 &」用于把整个 bash -c 放后台;若用 exec/execute 同步执行,必须整段原样复制(含末尾 &)。若删掉 &,内部 while 死循环占满前台,调用会一直阻塞到超时/杀进程。
需要经典 bash 反弹 shell 时:先 c2_listener create type=tcp_reverse,再对该监听器用 kind=bash
公网部署 tcp_reverse 请用 build 生成加密 Beacon,勿开启 allow_legacy_shell
• 省略 kind 时,会按监听器类型自动选第一个兼容类型(HTTP 系默认为 curl_beacon)。
- build: 交叉编译 beacon 二进制。支持 http_beacon / https_beacon / websocket / tcp_reversetcp_reverse 植入端回连后先发魔数 CSB1,再走与 HTTP 相同的 AES-GCM JSON 语义;未发魔数的连接仍按经典交互 shell 处理)。
- build: 交叉编译 beacon 二进制。支持 http_beacon / https_beacon / websocket / tcp_reversetcp_reverse 植入端回连后先发魔数 CSB1,再经 AES-GCM 解密且校验 ImplantToken 后才登记会话)。
依赖的监听器 bind_port 须避开本服务 Web 端口 %d(配置 server.port,与 c2_listener 描述一致),否则 Beacon 无法正确回连。`, webListenPort),
InputSchema: map[string]interface{}{
"type": "object",
@@ -540,6 +566,9 @@ func registerC2PayloadTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webListe
}
return makeC2Result(nil, fmt.Errorf("监听器类型 %s 不支持 %s,兼容类型: %v", listener.Type, kind, names))
}
if err := c2.ValidateOnelinerForListener(listener, kind); err != nil {
return makeC2Result(nil, err)
}
input := c2.OnelinerInput{
Kind: kind,
Host: host,
+53
View File
@@ -89,6 +89,28 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
"type": "string",
"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"},
},
@@ -124,7 +146,26 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
if err != 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)
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 != "" {
msg += warn
}
@@ -164,6 +205,18 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
if 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
if warn := project.SparseBodyWarningIfNeeded(f.Category, f.FactKey, f.Body); warn != "" {
msg += warn
+203
View File
@@ -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()
}
+18 -9
View File
@@ -20,10 +20,9 @@ import (
)
// TCPReverseListener 监听 TCP 端口,等待目标机反弹连接。
// 经典模式:纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容
// 二进制 Beacon:连接后先发送魔数 CSB1,随后使用与 HTTP Beacon 相同的 AES-GCM JSON 语义(成帧见 tcp_beacon_server.go
// 每个新连接自动生成一个 implant_uuid(基于远端地址 + 启动时间 hash),登记为 c2_session
// 任务派发:使用同步 exec 模式 —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。
// 默认仅接受加密 TCP Beacon:连接后先发送魔数 CSB1,再经 AES-GCM 解密且校验 ImplantToken 后才登记会话
// 可选经典模式(config.allow_legacy_shell=true):纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容,无鉴权,仅建议内网实验
// 任务派发(经典模式):同步 exec —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。
type TCPReverseListener struct {
rec *database.C2Listener
cfg *ListenerConfig
@@ -122,12 +121,14 @@ func (l *TCPReverseListener) acceptLoop() {
}
}
// handleConn 一个连接=一个会话:先识别二进制 TCP Beacon(魔数 CSB1),否则走经典交互式 shell。
// handleConn 先识别加密 TCP Beacon(魔数 CSB1 + AES-GCM + Token);未通过则按配置拒绝或走经典 shell。
func (l *TCPReverseListener) handleConn(conn net.Conn) {
br := bufio.NewReader(conn)
_ = conn.SetReadDeadline(time.Now().Add(20 * time.Second))
prefix, err := br.Peek(4)
if err == nil && len(prefix) == 4 && string(prefix) == tcpBeaconMagic {
remote := conn.RemoteAddr().String()
_ = conn.SetReadDeadline(time.Now().Add(tcpBeaconPeekTimeout))
prefix, peekErr := br.Peek(4)
if peekErr == nil && len(prefix) == 4 && string(prefix) == tcpBeaconMagic {
if _, err := br.Discard(4); err != nil {
_ = conn.Close()
return
@@ -136,14 +137,22 @@ func (l *TCPReverseListener) handleConn(conn net.Conn) {
l.handleTCPBeaconSession(conn, br)
return
}
if !l.cfg.AllowLegacyShell {
l.logger.Debug("tcp_reverse 拒绝未加密连接", zap.String("remote", remote))
_ = conn.Close()
return
}
_ = conn.SetReadDeadline(time.Time{})
l.handleShellConn(conn, br)
}
// handleShellConn 经典裸 TCP 反弹 shell(与 nc/bash /dev/tcp 兼容)。
// handleShellConn 经典裸 TCP 反弹 shell(与 nc/bash /dev/tcp 兼容);需监听器显式开启 allow_legacy_shell
func (l *TCPReverseListener) handleShellConn(conn net.Conn, br *bufio.Reader) {
remote := conn.RemoteAddr().String()
host, _, _ := net.SplitHostPort(remote)
// 用 listener+remote_ip 生成稳定 implant_uuid,使同一来源的重连复用同一会话
uuidSeed := fmt.Sprintf("%s|%s", l.rec.ID, host)
hash := sha256.Sum256([]byte(uuidSeed))
+41 -1
View File
@@ -381,8 +381,10 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (*
Metadata: req.Metadata,
}
if existing != nil {
// 保留原 ID/FirstSeenAt/Note,避免被覆盖
// 保留原 ID/FirstSeenAt/Note 与操作员设置的 sleep/jitter,避免被 beacon 心跳上报覆盖
session.FirstSeenAt = existing.FirstSeenAt
session.SleepSeconds = existing.SleepSeconds
session.JitterPercent = existing.JitterPercent
if session.Note == "" {
session.Note = existing.Note
}
@@ -413,6 +415,44 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (*
return session, nil
}
// SetSessionSleep 更新会话期望的心跳间隔,并向植入体下发 sleep 任务以尽快生效。
func (m *Manager) SetSessionSleep(sessionID string, sleepSeconds, jitterPercent int) (*database.C2Task, error) {
if strings.TrimSpace(sessionID) == "" {
return nil, ErrInvalidInput
}
if sleepSeconds < 1 {
sleepSeconds = 1
}
if jitterPercent < 0 {
jitterPercent = 0
}
if jitterPercent > 100 {
jitterPercent = 100
}
if err := m.db.SetC2SessionSleep(sessionID, sleepSeconds, jitterPercent); err != nil {
return nil, err
}
task, err := m.EnqueueTask(EnqueueTaskInput{
SessionID: sessionID,
TaskType: TaskTypeSleep,
Payload: map[string]interface{}{
"seconds": sleepSeconds,
"jitter": jitterPercent,
},
Source: "manual",
})
if err != nil {
m.logger.Warn("sleep 任务入队失败", zap.Error(err), zap.String("session_id", sessionID))
}
m.publishEvent("info", "session", sessionID, "",
fmt.Sprintf("Sleep 已更新: %ds (抖动 %d%%)", sleepSeconds, jitterPercent),
map[string]interface{}{
"sleep_seconds": sleepSeconds,
"jitter_percent": jitterPercent,
})
return task, nil
}
// MarkSessionDead 心跳超时检测器调用:标记会话为 dead
func (m *Manager) MarkSessionDead(sessionID string) error {
if err := m.db.SetC2SessionStatus(sessionID, string(SessionDead)); err != nil {
+118
View File
@@ -0,0 +1,118 @@
package c2
import (
"path/filepath"
"testing"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
func TestIngestCheckIn_PreservesOperatorSleepOnHeartbeat(t *testing.T) {
tmp := t.TempDir()
db, err := database.NewDB(filepath.Join(tmp, "c2.sqlite"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
mgr := NewManager(db, zap.NewNop(), tmp)
ln, err := mgr.CreateListener(CreateListenerInput{
Name: "t",
Type: string(ListenerTypeHTTPBeacon),
BindHost: "127.0.0.1",
BindPort: 18080,
})
if err != nil {
t.Fatal(err)
}
first, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{
ImplantUUID: "implant-uuid-1",
Hostname: "host1",
Username: "user",
OS: "darwin",
Arch: "amd64",
SleepSeconds: 5,
JitterPercent: 0,
})
if err != nil {
t.Fatal(err)
}
if err := db.SetC2SessionSleep(first.ID, 30, 20); err != nil {
t.Fatal(err)
}
second, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{
ImplantUUID: "implant-uuid-1",
Hostname: "host1",
Username: "user",
OS: "darwin",
Arch: "amd64",
SleepSeconds: 5,
JitterPercent: 0,
})
if err != nil {
t.Fatal(err)
}
if second.SleepSeconds != 30 || second.JitterPercent != 20 {
t.Fatalf("expected sleep=30 jitter=20, got sleep=%d jitter=%d", second.SleepSeconds, second.JitterPercent)
}
stored, err := db.GetC2Session(first.ID)
if err != nil || stored == nil {
t.Fatal(err)
}
if stored.SleepSeconds != 30 || stored.JitterPercent != 20 {
t.Fatalf("db: expected sleep=30 jitter=20, got sleep=%d jitter=%d", stored.SleepSeconds, stored.JitterPercent)
}
}
func TestSetSessionSleep_UpdatesDBAndEnqueuesTask(t *testing.T) {
tmp := t.TempDir()
db, err := database.NewDB(filepath.Join(tmp, "c2.sqlite"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
mgr := NewManager(db, zap.NewNop(), tmp)
ln, err := mgr.CreateListener(CreateListenerInput{
Name: "t2",
Type: string(ListenerTypeHTTPBeacon),
BindHost: "127.0.0.1",
BindPort: 18081,
})
if err != nil {
t.Fatal(err)
}
sess, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{
ImplantUUID: "implant-uuid-2",
Hostname: "host2",
Username: "user",
OS: "linux",
Arch: "amd64",
SleepSeconds: 5,
})
if err != nil {
t.Fatal(err)
}
task, err := mgr.SetSessionSleep(sess.ID, 15, 10)
if err != nil {
t.Fatal(err)
}
if task == nil || task.TaskType != string(TaskTypeSleep) {
t.Fatalf("expected sleep task, got %#v", task)
}
stored, err := db.GetC2Session(sess.ID)
if err != nil || stored == nil {
t.Fatal(err)
}
if stored.SleepSeconds != 15 || stored.JitterPercent != 10 {
t.Fatalf("expected sleep=15 jitter=10, got sleep=%d jitter=%d", stored.SleepSeconds, stored.JitterPercent)
}
}
+20
View File
@@ -1,9 +1,12 @@
package c2
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"cyberstrike-ai/internal/database"
)
// OnelinerKind 单行 payload 的语言/形式
@@ -79,6 +82,23 @@ type OnelinerInput struct {
ImplantToken string // HTTP Beacon 鉴权 token
}
// ValidateOnelinerForListener 校验 oneliner 与监听器配置是否匹配(如 tcp_reverse 默认要求加密 Beacon)。
func ValidateOnelinerForListener(listener *database.C2Listener, kind OnelinerKind) error {
if listener == nil {
return fmt.Errorf("listener is nil")
}
if ListenerType(listener.Type) == ListenerTypeTCPReverse && tcpOnelinerKinds[kind] {
cfg := &ListenerConfig{}
if strings.TrimSpace(listener.ConfigJSON) != "" {
_ = json.Unmarshal([]byte(listener.ConfigJSON), cfg)
}
if !cfg.AllowLegacyShell {
return fmt.Errorf("监听器未开启 allow_legacy_shelltcp_reverse 默认仅接受 CSB1 加密 BeaconAES-GCM + Token);请用 build 生成 beacon,或显式开启 allow_legacy_shell(公网不推荐)")
}
}
return nil
}
// GenerateOneliner 生成单行 payload。
// 设计要点:
// - 不依赖目标机预装的可执行(除该 oneliner 关键的 bash/python/perl 等);
+3
View File
@@ -23,6 +23,9 @@ import (
// tcpBeaconMagic 二进制 Beacon 在反向 TCP 连接建立后首先发送的 4 字节,用于与经典 shell 反弹区分。
const tcpBeaconMagic = "CSB1"
// tcpBeaconPeekTimeout 等待 CSB1 魔数的探测窗口;合法 Beacon 连接后立即发送魔数。
const tcpBeaconPeekTimeout = 2 * time.Second
// tcpBeaconMaxFrame 单帧密文(base64 字符串)最大字节数,防止 OOM。
const tcpBeaconMaxFrame = 64 << 20
+2
View File
@@ -141,6 +141,8 @@ type ListenerConfig struct {
MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"`
// CallbackHost 植入端/Payload 使用的回连主机名(可选);与 bind_host 分离,便于 NAT/ECS 等场景
CallbackHost string `json:"callback_host,omitempty"`
// AllowLegacyShell 为 true 时 tcp_reverse 允许未加密的经典 bash/nc 反弹 shell 登记会话(默认 false,公网部署强烈不建议开启)
AllowLegacyShell bool `json:"allow_legacy_shell,omitempty"`
}
// ApplyDefaults 对未填字段填默认值;调用方负责持久化时序列化新值
+9
View File
@@ -45,6 +45,7 @@ type ProjectConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
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"`
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"`
DefaultInjectDeprecated bool `yaml:"default_inject_deprecated,omitempty" json:"default_inject_deprecated,omitempty"`
}
@@ -57,6 +58,14 @@ func (c ProjectConfig) FactIndexMaxRunesEffective() int {
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 数(索引一行,宜含验证要点)。
func (c ProjectConfig) FactSummaryMaxRunesEffective() int {
if c.FactSummaryMaxRunes <= 0 {
+36
View File
@@ -507,6 +507,42 @@ func (db *DB) CancelPendingBatchTasks(queueID string, completedAt time.Time) err
return nil
}
// PrepareBatchSingleTaskRun 准备单条执行:可选重置子任务,并更新队列索引与状态
func (db *DB) PrepareBatchSingleTaskRun(queueID, taskID string, taskIndex int, resetTask, resumeQueue bool) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开始事务失败: %w", err)
}
defer tx.Rollback()
if resetTask {
_, err = tx.Exec(
"UPDATE batch_tasks SET status = ?, conversation_id = NULL, started_at = NULL, completed_at = NULL, error = NULL, result = NULL WHERE queue_id = ? AND id = ?",
"pending", queueID, taskID,
)
if err != nil {
return fmt.Errorf("重置批量任务状态失败: %w", err)
}
}
if resumeQueue {
_, err = tx.Exec(
"UPDATE batch_task_queues SET status = ?, current_index = ?, completed_at = NULL, last_run_error = NULL WHERE id = ?",
"paused", taskIndex, queueID,
)
} else {
_, err = tx.Exec(
"UPDATE batch_task_queues SET current_index = ?, last_run_error = NULL WHERE id = ?",
taskIndex, queueID,
)
}
if err != nil {
return fmt.Errorf("更新批量任务队列状态失败: %w", err)
}
return tx.Commit()
}
// DeleteBatchTask 删除批量任务
func (db *DB) DeleteBatchTask(queueID, taskID string) error {
_, err := db.Exec(
+47
View File
@@ -17,6 +17,9 @@ var ErrNoValidC2EventIDs = errors.New("no valid event ids")
// ErrNoValidC2TaskIDs 批量删除任务时未提供任何合法 ID
var ErrNoValidC2TaskIDs = errors.New("no valid task ids")
// ErrNoValidC2SessionIDs 批量删除会话时未提供任何合法 ID
var ErrNoValidC2SessionIDs = errors.New("no valid session ids")
// validC2TextIDForDelete 校验 C2 文本主键(e_/t_/s_/… 等)用于批量删除入参
func validC2TextIDForDelete(id string) bool {
if len(id) < 2 || len(id) > 80 {
@@ -473,6 +476,7 @@ type ListC2SessionsFilter struct {
Status string // active|sleeping|dead|killed;空表示全部
OS string
Search string // 模糊匹配 hostname/username/internal_ip
Suspicious bool // 疑似误报:离线且 hostname 为 tcp_* / 用户名为 unknown / PID 为 0
Limit int // 0 表示无限制
}
@@ -497,6 +501,11 @@ func (db *DB) ListC2Sessions(filter ListC2SessionsFilter) ([]*C2Session, error)
kw := "%" + filter.Search + "%"
args = append(args, kw, kw, kw)
}
if filter.Suspicious {
conditions = append(conditions, `status = 'dead' AND (
hostname LIKE 'tcp_%' OR LOWER(COALESCE(username,'')) = 'unknown' OR COALESCE(pid, 0) = 0
)`)
}
query := `
SELECT id, listener_id, implant_uuid, COALESCE(hostname,''), COALESCE(username,''),
COALESCE(os,''), COALESCE(arch,''), COALESCE(pid, 0), COALESCE(process_name,''),
@@ -554,6 +563,44 @@ func (db *DB) DeleteC2Session(id string) error {
return nil
}
// DeleteC2SessionsByIDs 按主键批量删除会话
func (db *DB) DeleteC2SessionsByIDs(ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
const maxBatch = 500
if len(ids) > maxBatch {
ids = ids[:maxBatch]
}
clean := make([]string, 0, len(ids))
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if !validC2TextIDForDelete(id) {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
clean = append(clean, id)
}
if len(clean) == 0 {
return 0, ErrNoValidC2SessionIDs
}
placeholders := strings.Repeat("?,", len(clean)-1) + "?"
args := make([]interface{}, len(clean))
for i := range clean {
args[i] = clean[i]
}
query := `DELETE FROM c2_sessions WHERE id IN (` + placeholders + `)`
res, err := db.Exec(query, args...)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// ----------------------------------------------------------------------------
// CRUDC2 任务
// ----------------------------------------------------------------------------
+20 -5
View File
@@ -382,26 +382,40 @@ func (db *DB) CountConversations(search string) (int, error) {
return count, nil
}
func conversationOrderClause(sortBy, tableAlias string) string {
col := "updated_at"
if strings.TrimSpace(strings.ToLower(sortBy)) == "created_at" {
col = "created_at"
}
prefix := tableAlias
if prefix != "" {
prefix += "."
}
return "ORDER BY " + prefix + col + " DESC"
}
// ListConversations 列出所有对话
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
func (db *DB) ListConversations(limit, offset int, search, sortBy string) ([]*Conversation, error) {
var rows *sql.Rows
var err error
if search != "" {
// 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积
searchPattern := "%" + search + "%"
orderClause := conversationOrderClause(sortBy, "c")
rows, err = db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id
FROM conversations c
WHERE c.title LIKE ?
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)
ORDER BY c.updated_at DESC
`+orderClause+`
LIMIT ? OFFSET ?`,
searchPattern, searchPattern, limit, offset,
)
} else {
orderClause := conversationOrderClause(sortBy, "")
rows, err = db.Query(
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations ORDER BY updated_at DESC LIMIT ? OFFSET ?",
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations "+orderClause+" LIMIT ? OFFSET ?",
limit, offset,
)
}
@@ -467,11 +481,12 @@ func (db *DB) CountUngroupedConversations() (int, error) {
}
// ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。
func (db *DB) ListUngroupedConversations(limit, offset int) ([]*Conversation, error) {
func (db *DB) ListUngroupedConversations(limit, offset int, sortBy string) ([]*Conversation, error) {
orderClause := conversationOrderClause(sortBy, "c")
rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
ungroupedConversationsSQL+`
ORDER BY c.updated_at DESC
`+orderClause+`
LIMIT ? OFFSET ?`,
limit, offset,
)
+23
View File
@@ -353,6 +353,22 @@ func (db *DB) initTables() error {
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 := `
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_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_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_vulnerabilities_project_id ON vulnerabilities(project_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)
}
if _, err := db.Exec(createProjectFactEdgesTable); err != nil {
return fmt.Errorf("创建project_fact_edges表失败: %w", err)
}
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
}
+11 -4
View File
@@ -389,7 +389,7 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
return f, nil
}
// DeprecateProjectFact 将事实标记为 deprecated。
// DeprecateProjectFact 将事实标记为 deprecated(关联边同步 deprecated
func (db *DB) DeprecateProjectFact(projectID, factKey string) error {
res, err := db.Exec(
`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 {
return fmt.Errorf("事实不存在")
}
return nil
return db.DeprecateProjectFactEdgesForKey(projectID, factKey)
}
// RestoreProjectFact 将已废弃事实恢复为 tentative 或 confirmed(重新参与黑板索引)。
@@ -430,9 +430,16 @@ func (db *DB) RestoreProjectFact(projectID, factKey, confidence string) error {
return err
}
// DeleteProjectFact 删除事实。
// DeleteProjectFact 删除事实(级联删除相关边)
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
}
+410
View File
@@ -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()
}
+70 -4
View File
@@ -640,7 +640,7 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry(
var emptyResponseAttempts int
for {
resultMA, errMA = multiagent.RunEinoSingleChatModelAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger,
conversationID, h.conversationProjectID(conversationID), curMsg, curHist, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID),
)
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
@@ -689,7 +689,7 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
var emptyResponseAttempts int
for {
resultMA, errMA = multiagent.RunDeepAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger,
conversationID, h.conversationProjectID(conversationID), curMsg, curHist, roleTools, progressCallback,
h.agentsMarkdownDir, orchestration, nil, h.projectBlackboardBlock(conversationID),
)
@@ -1309,7 +1309,10 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
// 保存过程详情到数据库(排除 response/doneresponse 正文已在 messages 表)
// response_start/response_delta 已聚合为 planning,不落逐条。
// [Eino] agent 心跳 progress 仅用于实时进度标题,不落库以免时间线刷屏。
skipEinoAgentHeartbeat := eventType == "progress" && strings.HasPrefix(strings.TrimSpace(message), "[Eino] ")
if assistantMessageID != "" &&
!skipEinoAgentHeartbeat &&
eventType != "response" &&
eventType != "done" &&
eventType != "response_start" &&
@@ -1678,6 +1681,7 @@ func (h *AgentHandler) ListBatchQueues(c *gin.Context) {
// StartBatchQueue 开始执行批量任务队列
func (h *AgentHandler) StartBatchQueue(c *gin.Context) {
queueID := c.Param("queueId")
h.batchTaskManager.ClearSingleRunTask(queueID)
ok, err := h.startBatchQueueExecution(queueID, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -1709,6 +1713,7 @@ func (h *AgentHandler) RerunBatchQueue(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置队列失败"})
return
}
h.batchTaskManager.ClearSingleRunTask(queueID)
ok, err := h.startBatchQueueExecution(queueID, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -1908,6 +1913,53 @@ func (h *AgentHandler) AddBatchTask(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "任务已添加", "task": task, "queue": queue})
}
// RunSingleBatchTask 单条执行指定子任务(可覆盖已成功项),完成后暂停队列
func (h *AgentHandler) RunSingleBatchTask(c *gin.Context) {
queueID := c.Param("queueId")
taskID := c.Param("taskId")
if err := h.batchTaskManager.PrepareSingleTaskRun(queueID, taskID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.batchTaskManager.SetSingleRunTask(queueID, taskID)
// 暂停态单条执行:旧批量协程可能仍占用执行槽,先回收以便重新启动
if queue, ok := h.batchTaskManager.GetBatchQueue(queueID); ok && queue.Status == BatchQueueStatusPaused {
h.forceUnmarkBatchQueueRunning(queueID)
}
autoStarted := true
autoStartMsg := "已开始单条执行"
ok, startErr := h.startBatchQueueExecution(queueID, false)
if startErr != nil {
h.batchTaskManager.ClearSingleRunTask(queueID)
autoStarted = false
autoStartMsg = "任务已准备就绪,但自动启动失败: " + startErr.Error()
} else if !ok {
h.batchTaskManager.ClearSingleRunTask(queueID)
autoStarted = false
autoStartMsg = "任务已准备就绪,但队列不存在"
}
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
return
}
if h.audit != nil {
h.audit.RecordOK(c, "task", "run_single_batch_task", "单条执行批量子任务", "batch_task", taskID, map[string]interface{}{
"batch_queue_id": queueID,
"auto_started": autoStarted,
})
}
c.JSON(http.StatusOK, gin.H{
"message": autoStartMsg,
"queue": queue,
"autoStarted": autoStarted,
})
}
// DeleteBatchTask 删除批量任务
func (h *AgentHandler) DeleteBatchTask(c *gin.Context) {
queueID := c.Param("queueId")
@@ -1949,6 +2001,10 @@ func (h *AgentHandler) unmarkBatchQueueRunning(queueID string) {
delete(h.batchRunning, queueID)
}
func (h *AgentHandler) forceUnmarkBatchQueueRunning(queueID string) {
h.unmarkBatchQueueRunning(queueID)
}
func (h *AgentHandler) nextBatchQueueRunAt(cronExpr string, from time.Time) (*time.Time, error) {
expr := strings.TrimSpace(cronExpr)
if expr == "" {
@@ -2096,6 +2152,10 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", "创建对话失败: "+err.Error())
h.batchTaskManager.MoveToNextTask(queueID)
if h.batchTaskManager.TakeSingleRunTaskIfMatch(queueID, task.ID) {
h.batchTaskManager.UpdateQueueStatus(queueID, "paused")
break
}
continue
}
conversationID = conv.ID
@@ -2233,12 +2293,12 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
var runErr error
switch {
case useBatchMulti:
resultMA, runErr = multiagent.RunDeepAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch, nil, h.projectBlackboardBlock(conversationID))
resultMA, runErr = multiagent.RunDeepAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch, nil, h.projectBlackboardBlock(conversationID))
default:
if h.config == nil {
runErr = fmt.Errorf("服务器配置未加载")
} else {
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID))
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID))
}
}
@@ -2352,6 +2412,12 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
// 移动到下一个任务
h.batchTaskManager.MoveToNextTask(queueID)
if h.batchTaskManager.TakeSingleRunTaskIfMatch(queueID, task.ID) {
h.batchTaskManager.UpdateQueueStatus(queueID, "paused")
h.logger.Info("单条执行完成,队列已暂停", zap.String("queueId", queueID), zap.String("taskId", task.ID))
break
}
// 检查是否被取消或暂停
queue, _ = h.batchTaskManager.GetBatchQueue(queueID)
if queue.Status == "cancelled" || queue.Status == "paused" {
+161 -8
View File
@@ -77,11 +77,12 @@ type BatchTaskQueue struct {
// BatchTaskManager 批量任务管理器
type BatchTaskManager struct {
db *database.DB
logger *zap.Logger
queues map[string]*BatchTaskQueue
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
mu sync.RWMutex
db *database.DB
logger *zap.Logger
queues map[string]*BatchTaskQueue
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
singleRunTasks map[string]string // queueID -> taskID,单条执行完成后暂停队列
mu sync.RWMutex
}
// NewBatchTaskManager 创建批量任务管理器
@@ -90,9 +91,10 @@ func NewBatchTaskManager(logger *zap.Logger) *BatchTaskManager {
logger = zap.NewNop()
}
return &BatchTaskManager{
logger: logger,
queues: make(map[string]*BatchTaskQueue),
taskCancels: make(map[string]context.CancelFunc),
logger: logger,
queues: make(map[string]*BatchTaskQueue),
taskCancels: make(map[string]context.CancelFunc),
singleRunTasks: make(map[string]string),
}
}
@@ -864,6 +866,138 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
return task, nil
}
// PrepareSingleTaskRun 准备单条执行:重置目标任务(若已有结果)并定位队列索引
func (m *BatchTaskManager) PrepareSingleTaskRun(queueID, taskID string) error {
var cancelFunc context.CancelFunc
var siblingRunningIDs []string
m.mu.Lock()
queue, exists := m.queues[queueID]
if !exists {
m.mu.Unlock()
return fmt.Errorf("队列不存在")
}
var task *BatchTask
taskIndex := -1
for i, t := range queue.Tasks {
if t.ID == taskID {
taskIndex = i
task = t
break
}
}
if task == nil {
m.mu.Unlock()
return fmt.Errorf("任务不存在")
}
if !queueAllowsSingleTaskRunLocked(queue, task) {
m.mu.Unlock()
return fmt.Errorf("队列正在执行或未就绪,无法单条执行")
}
// 暂停态:中止在途子任务并收口仍标记 running 的其它子任务,以便单条执行非冲突项
if queue.Status == BatchQueueStatusPaused {
if c, ok := m.taskCancels[queueID]; ok {
cancelFunc = c
delete(m.taskCancels, queueID)
}
for _, t := range queue.Tasks {
if t != nil && t.ID != taskID && t.Status == BatchTaskStatusRunning {
siblingRunningIDs = append(siblingRunningIDs, t.ID)
}
}
}
needsReset := task.Status != BatchTaskStatusPending
resumeQueue := queue.Status == BatchQueueStatusCompleted || queue.Status == BatchQueueStatusCancelled
m.mu.Unlock()
if cancelFunc != nil {
cancelFunc()
}
const staleRunMsg = "为单条执行其它任务,已中止"
for _, sid := range siblingRunningIDs {
m.UpdateTaskStatus(queueID, sid, BatchTaskStatusCancelled, "", staleRunMsg)
}
m.mu.Lock()
defer m.mu.Unlock()
queue, exists = m.queues[queueID]
if !exists {
return fmt.Errorf("队列不存在")
}
task = nil
taskIndex = -1
for i, t := range queue.Tasks {
if t.ID == taskID {
taskIndex = i
task = t
break
}
}
if task == nil {
return fmt.Errorf("任务不存在")
}
if m.db != nil {
if err := m.db.PrepareBatchSingleTaskRun(queueID, taskID, taskIndex, needsReset, resumeQueue); err != nil {
return fmt.Errorf("准备单条执行失败: %w", err)
}
}
if needsReset {
task.Status = BatchTaskStatusPending
task.ConversationID = ""
task.StartedAt = nil
task.CompletedAt = nil
task.Error = ""
task.Result = ""
}
queue.CurrentIndex = taskIndex
queue.LastRunError = ""
if resumeQueue {
queue.Status = BatchQueueStatusPaused
queue.CompletedAt = nil
}
return nil
}
// SetSingleRunTask 标记队列仅执行指定子任务,完成后自动暂停
func (m *BatchTaskManager) SetSingleRunTask(queueID, taskID string) {
m.mu.Lock()
defer m.mu.Unlock()
if m.singleRunTasks == nil {
m.singleRunTasks = make(map[string]string)
}
m.singleRunTasks[queueID] = taskID
}
// ClearSingleRunTask 清除单条执行标记
func (m *BatchTaskManager) ClearSingleRunTask(queueID string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.singleRunTasks, queueID)
}
// TakeSingleRunTaskIfMatch 若刚完成的子任务为单条执行目标,则清除标记并返回 true
func (m *BatchTaskManager) TakeSingleRunTaskIfMatch(queueID, taskID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.singleRunTasks == nil {
return false
}
if m.singleRunTasks[queueID] != taskID {
return false
}
delete(m.singleRunTasks, queueID)
return true
}
// DeleteTask 删除任务(队列空闲时可删;执行中任务不可删)
func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
m.mu.Lock()
@@ -936,6 +1070,25 @@ func queueAllowsTaskListMutationLocked(queue *BatchTaskQueue) bool {
}
}
// queueAllowsSingleTaskRunLocked 是否允许对指定子任务发起单条执行(必须在持有 BatchTaskManager.mu 下调用)
func queueAllowsSingleTaskRunLocked(queue *BatchTaskQueue, task *BatchTask) bool {
if queue == nil || task == nil {
return false
}
if task.Status == BatchTaskStatusRunning {
return false
}
if queue.Status == BatchQueueStatusRunning {
return false
}
switch queue.Status {
case BatchQueueStatusPending, BatchQueueStatusPaused, BatchQueueStatusCompleted, BatchQueueStatusCancelled:
return true
default:
return false
}
}
// GetNextTask 获取下一个待执行的任务
func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
m.mu.Lock()
+58 -3
View File
@@ -1,6 +1,7 @@
package handler
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -277,6 +278,9 @@ func (h *C2Handler) ListSessions(c *gin.Context) {
filter.Limit = n
}
}
if c.Query("suspicious") == "1" || strings.EqualFold(c.Query("suspicious"), "true") {
filter.Suspicious = true
}
sessions, err := h.mgr().DB().ListC2Sessions(filter)
if err != nil {
@@ -324,7 +328,37 @@ func (h *C2Handler) DeleteSession(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// SetSessionSleep 设置会话的 sleep/jitter
// DeleteSessions 批量删除会话(请求体 JSON: {"ids":["s_xxx",...]}
func (h *C2Handler) DeleteSessions(c *gin.Context) {
var req struct {
IDs []string `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()})
return
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids is required"})
return
}
n, err := h.mgr().DB().DeleteC2SessionsByIDs(req.IDs)
if err != nil {
if errors.Is(err, database.ErrNoValidC2SessionIDs) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if h.audit != nil {
h.audit.RecordOK(c, "c2", "session_delete", "批量删除 C2 会话", "c2_session", "", map[string]interface{}{
"count": n, "ids": req.IDs,
})
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
// SetSessionSleep 设置会话的 sleep/jitter,并下发 sleep 任务到植入体
func (h *C2Handler) SetSessionSleep(c *gin.Context) {
id := c.Param("id")
var req struct {
@@ -335,12 +369,33 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.SleepSeconds < 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_seconds must be >= 1"})
return
}
if req.JitterPercent < 0 || req.JitterPercent > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "jitter_percent must be 0-100"})
return
}
if err := h.mgr().DB().SetC2SessionSleep(id, req.SleepSeconds, req.JitterPercent); err != nil {
task, err := h.mgr().SetSessionSleep(id, req.SleepSeconds, req.JitterPercent)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"updated": true})
out := gin.H{
"updated": true,
"sleep_seconds": req.SleepSeconds,
"jitter_percent": req.JitterPercent,
}
if task != nil {
out["task_id"] = task.ID
}
c.JSON(http.StatusOK, out)
}
// ============================================================================
+74
View File
@@ -1068,6 +1068,80 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
})
}
// ListModelsRequest 获取模型列表请求(OpenAI 兼容 GET /models)。
type ListModelsRequest struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
}
// ListModels 代理调用上游 GET /models,返回可用模型 id 列表。
func (h *ConfigHandler) ListModels(c *gin.Context) {
var req ListModelsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
return
}
provider := strings.TrimSpace(req.Provider)
if provider == "" {
provider = "openai"
}
if strings.EqualFold(provider, "claude") {
c.JSON(http.StatusOK, gin.H{
"success": false,
"supported": false,
"error": "Claude (Anthropic Messages API) 不支持自动获取模型列表,请手动填写",
})
return
}
if strings.TrimSpace(req.APIKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空"})
return
}
baseURL := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
tmpCfg := &config.OpenAIConfig{
Provider: provider,
BaseURL: baseURL,
APIKey: strings.TrimSpace(req.APIKey),
}
client := openai.NewClient(tmpCfg, nil, h.logger)
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
models, err := client.ListModels(ctx)
if err != nil {
if apiErr, ok := err.(*openai.APIError); ok {
c.JSON(http.StatusOK, gin.H{
"success": false,
"supported": true,
"error": fmt.Sprintf("API 返回错误 (HTTP %d): %s", apiErr.StatusCode, apiErr.Body),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": false,
"supported": true,
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"supported": true,
"models": models,
"count": len(models),
})
}
// TestVisionRequest 测试 Vision 模型连接;vision.api_key/base_url 留空时可传 openai 段作回退。
type TestVisionRequest struct {
Vision config.VisionConfig `json:"vision"`
+3 -2
View File
@@ -105,17 +105,18 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
excludeGrouped := strings.TrimSpace(search) == "" &&
(c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1")
sortBy := strings.TrimSpace(c.Query("sort_by"))
var conversations []*database.Conversation
var total int
var err error
if excludeGrouped {
conversations, err = h.db.ListUngroupedConversations(limit, offset)
conversations, err = h.db.ListUngroupedConversations(limit, offset, sortBy)
if err == nil {
total, err = h.db.CountUngroupedConversations()
}
} else {
conversations, err = h.db.ListConversations(limit, offset, search)
conversations, err = h.db.ListConversations(limit, offset, search, sortBy)
if err == nil {
total, err = h.db.CountConversations(search)
}
+2
View File
@@ -224,6 +224,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
h.config,
&h.config.MultiAgent,
h.agent,
h.db,
h.logger,
conversationID,
h.conversationProjectID(conversationID),
@@ -455,6 +456,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
h.config,
&h.config.MultiAgent,
h.agent,
h.db,
h.logger,
prep.ConversationID,
h.conversationProjectID(prep.ConversationID),
+2
View File
@@ -234,6 +234,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
h.config,
&h.config.MultiAgent,
h.agent,
h.db,
h.logger,
conversationID,
h.conversationProjectID(conversationID),
@@ -467,6 +468,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
h.config,
&h.config.MultiAgent,
h.agent,
h.db,
h.logger,
prep.ConversationID,
h.conversationProjectID(prep.ConversationID),
+137 -1
View File
@@ -2464,17 +2464,108 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"parameters": []map[string]interface{}{
{"name": "id", "in": "path", "required": true, "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{}{
"tags": []string{"项目管理"}, "summary": "创建/更新事实", "operationId": "upsertProjectFactREST",
"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",
"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": "成功"}},
},
},
"/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{}{
"get": map[string]interface{}{
"tags": []string{"漏洞管理"},
@@ -5030,6 +5121,51 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"/api/config/list-models": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"配置管理"},
"summary": "获取模型列表",
"description": "代理调用 OpenAI 兼容 GET /models,返回可用模型 id 列表。Claude 不支持。",
"operationId": "listModels",
"requestBody": map[string]interface{}{
"required": true,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"required": []string{"api_key"},
"properties": map[string]interface{}{
"provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai/claude", "example": "openai"},
"base_url": map[string]interface{}{"type": "string", "description": "API基地址(可选)"},
"api_key": map[string]interface{}{"type": "string", "description": "API密钥"},
},
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "获取结果",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"success": map[string]interface{}{"type": "boolean"},
"supported": map[string]interface{}{"type": "boolean"},
"error": map[string]interface{}{"type": "string"},
"models": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"count": map[string]interface{}{"type": "integer"},
},
},
},
},
},
"400": map[string]interface{}{"description": "参数错误"},
"401": map[string]interface{}{"description": "未授权"},
},
},
},
// ==================== 终端 ====================
"/api/terminal/run": map[string]interface{}{
+255 -21
View File
@@ -1,10 +1,12 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"strings"
"cyberstrike-ai/internal/attackchain"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/project"
@@ -223,26 +225,102 @@ func (h *ProjectHandler) DeleteProject(c *gin.Context) {
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 {
FactKey string `json:"fact_key" binding:"required"`
Category string `json:"category"`
Summary string `json:"summary" binding:"required"`
Body string `json:"body"`
Confidence string `json:"confidence"`
Pinned bool `json:"pinned"`
RelatedVulnerabilityID string `json:"related_vulnerability_id"`
FactKey string `json:"fact_key" binding:"required"`
Category string `json:"category"`
Summary string `json:"summary" binding:"required"`
Body string `json:"body"`
Confidence string `json:"confidence"`
Pinned bool `json:"pinned"`
RelatedVulnerabilityID string `json:"related_vulnerability_id"`
Links []factLinkRequest `json:"links"`
LinksText *string `json:"links_text"`
}
// updateFactRequest 部分更新事实;指针字段省略=不修改,body 传 "" 可清空(仍走 merge 逻辑见 Upsert)。
type updateFactRequest struct {
FactKey *string `json:"fact_key"`
Category *string `json:"category"`
Summary *string `json:"summary"`
Body *string `json:"body"`
Confidence *string `json:"confidence"`
Pinned *bool `json:"pinned"`
RelatedVulnerabilityID *string `json:"related_vulnerability_id"`
ClearBody bool `json:"clear_body"`
FactKey *string `json:"fact_key"`
Category *string `json:"category"`
Summary *string `json:"summary"`
Body *string `json:"body"`
Confidence *string `json:"confidence"`
Pinned *bool `json:"pinned"`
RelatedVulnerabilityID *string `json:"related_vulnerability_id"`
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 查询参数可获取单条详情)
@@ -254,7 +332,8 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
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
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
@@ -285,7 +364,52 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) {
}
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
@@ -295,8 +419,9 @@ func (h *ProjectHandler) CreateFact(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
projectID := c.Param("id")
f := &database.ProjectFact{
ProjectID: c.Param("id"),
ProjectID: projectID,
FactKey: req.FactKey,
Category: req.Category,
Summary: req.Summary,
@@ -310,16 +435,24 @@ func (h *ProjectHandler) CreateFact(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
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
func (h *ProjectHandler) UpdateFact(c *gin.Context) {
projectID := c.Param("id")
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": "事实不存在"})
return
}
oldFactKey := existing.FactKey
var req updateFactRequest
if err := c.ShouldBindJSON(&req); err != nil {
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()})
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
@@ -408,3 +563,82 @@ func (h *ProjectHandler) RestoreFact(c *gin.Context) {
}
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)
}
+1 -1
View File
@@ -447,7 +447,7 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
}
func (h *RobotHandler) cmdList() string {
convs, err := h.db.ListConversations(50, 0, "")
convs, err := h.db.ListConversations(50, 0, "", "")
if err != nil {
return "获取对话列表失败: " + err.Error()
}
+9 -6
View File
@@ -630,13 +630,16 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
}
}
}
// 仅在代理切换时更新进度标题;同一代理的每个 ADK 事件不再重复刷 progress。
if einoLastAgent != ev.AgentName {
progress("progress", fmt.Sprintf("[Eino] %s", ev.AgentName), map[string]interface{}{
"conversationId": conversationID,
"einoAgent": ev.AgentName,
"einoRole": einoRoleTag(ev.AgentName),
"orchestration": orchMode,
})
}
einoLastAgent = ev.AgentName
progress("progress", fmt.Sprintf("[Eino] %s", ev.AgentName), map[string]interface{}{
"conversationId": conversationID,
"einoAgent": ev.AgentName,
"einoRole": einoRoleTag(ev.AgentName),
"orchestration": orchMode,
})
}
if ev.Output == nil || ev.Output.MessageOutput == nil {
continue
@@ -34,6 +34,15 @@ func einoExecuteTimeoutUserHint() string {
return "已超时终止 · Timed out"
}
// einoExecuteRecvErrIsToolTimeout 判断 Recv 错误是否由 agent.tool_timeout_minutes 触发。
// WithTimeout 到期后 local 侧常报 canceled / exit -1,但 execCtx.Err() 仍为 DeadlineExceeded。
func einoExecuteRecvErrIsToolTimeout(rerr error, tctx context.Context) bool {
if tctx != nil && errors.Is(tctx.Err(), context.DeadlineExceeded) {
return true
}
return errors.Is(rerr, context.DeadlineExceeded)
}
// einoStreamingShellWrap 包装 Eino filesystem 使用的 StreamingShellcloudwego eino-ext local.Local)。
// 官方 execute 工具默认走 ExecuteStreaming 且不设 RunInBackendGround;末尾带 & 时子进程仍与管道相连,
// streamStdout 按行读取会在无换行输出时长时间阻塞(与 MCP 工具 exec 的独立实现不同)。
@@ -83,6 +92,16 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
if execCancel != nil {
execCancel()
}
if einoExecuteRecvErrIsToolTimeout(err, execCtx) {
hint := "\n\n" + einoExecuteTimeoutUserHint() + "\n"
if w.recordMonitor != nil {
w.recordMonitor(tid, userCmd, hint, false, context.DeadlineExceeded)
}
if w.invokeNotify != nil && tid != "" {
w.invokeNotify.Fire(tid, "execute", agentTag, false, hint, context.DeadlineExceeded)
}
return schema.StreamReaderFromArray([]*filesystem.ExecuteResponse{{Output: hint}}), nil
}
if w.recordMonitor != nil {
w.recordMonitor(tid, userCmd, "", false, err)
}
@@ -91,7 +110,7 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
}
return nil, err
}
if sr == nil || w.invokeNotify == nil || tid == "" {
if sr == nil || w.invokeNotify == nil {
if execCancel != nil {
execCancel()
}
@@ -120,6 +139,11 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
if rerr != nil {
success = false
invokeErr = rerr
// 单次 execute 超时须与 MCP 工具一致:写入工具结果尾标、继续迭代,不得向 ADK 流注入硬错误。
if einoExecuteRecvErrIsToolTimeout(rerr, tctx) {
invokeErr = context.DeadlineExceeded
break
}
_ = outW.Send(nil, rerr)
break
}
@@ -0,0 +1,138 @@
package multiagent
import (
"context"
"errors"
"io"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/einomcp"
"github.com/cloudwego/eino/adk/filesystem"
"github.com/cloudwego/eino/schema"
)
type mockStreamingShell struct {
immediateErr error
recvErr error
output string
}
func (m *mockStreamingShell) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
if m.immediateErr != nil {
return nil, m.immediateErr
}
outR, outW := schema.Pipe[*filesystem.ExecuteResponse](4)
go func() {
defer outW.Close()
if strings.TrimSpace(m.output) != "" {
_ = outW.Send(&filesystem.ExecuteResponse{Output: m.output}, nil)
}
if m.recvErr != nil {
_ = outW.Send(nil, m.recvErr)
}
}()
return outR, nil
}
func TestEinoExecuteRecvErrIsToolTimeout(t *testing.T) {
tctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
time.Sleep(2 * time.Millisecond)
<-tctx.Done()
if !einoExecuteRecvErrIsToolTimeout(context.Canceled, tctx) {
t.Fatal("expected canceled recv with deadline exec ctx to count as tool timeout")
}
if !einoExecuteRecvErrIsToolTimeout(context.DeadlineExceeded, nil) {
t.Fatal("expected DeadlineExceeded recv without tctx")
}
if einoExecuteRecvErrIsToolTimeout(errors.New("exit status 1"), context.Background()) {
t.Fatal("unexpected timeout for generic error")
}
}
func TestEinoStreamingShellWrap_ToolTimeoutImmediateErrIsSoft(t *testing.T) {
inner := &mockStreamingShell{immediateErr: context.DeadlineExceeded}
wrap := &einoStreamingShellWrap{
inner: inner,
toolTimeoutMinutes: 60,
}
sr, err := wrap.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: "true"})
if err != nil {
t.Fatalf("immediate tool timeout must return soft stream, got err: %v", err)
}
defer sr.Close()
var got strings.Builder
for {
resp, rerr := sr.Recv()
if errors.Is(rerr, io.EOF) {
break
}
if rerr != nil {
t.Fatalf("outer stream must not hard-fail, got: %v", rerr)
}
if resp != nil && resp.Output != "" {
got.WriteString(resp.Output)
}
}
if !strings.Contains(got.String(), einoExecuteTimeoutUserHint()) {
t.Fatalf("expected timeout hint, got: %q", got.String())
}
}
func TestEinoStreamingShellWrap_ToolTimeoutRecvErrIsSoft(t *testing.T) {
inner := &mockStreamingShell{recvErr: context.DeadlineExceeded}
notify := einomcp.NewToolInvokeNotifyHolder()
wrap := &einoStreamingShellWrap{
inner: inner,
invokeNotify: notify,
toolTimeoutMinutes: 60,
}
// 生产路径由 Eino compose 注入 toolCallID;单测通过已过期 execCtx 识别 tool_timeout 软错误。
tctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
time.Sleep(2 * time.Millisecond)
<-tctx.Done()
sr, err := wrap.ExecuteStreaming(tctx, &filesystem.ExecuteRequest{Command: "sleep 999"})
if err != nil {
t.Fatalf("ExecuteStreaming: %v", err)
}
defer sr.Close()
var got strings.Builder
for {
resp, rerr := sr.Recv()
if errors.Is(rerr, io.EOF) {
break
}
if rerr != nil {
t.Fatalf("outer stream must not hard-fail on tool timeout, got: %v", rerr)
}
if resp != nil && resp.Output != "" {
got.WriteString(resp.Output)
}
}
if !strings.Contains(got.String(), einoExecuteTimeoutUserHint()) {
t.Fatalf("expected timeout hint in stream, got: %q", got.String())
}
}
func TestEinoStreamingShellWrap_NonTimeoutRecvErrStillHard(t *testing.T) {
inner := &mockStreamingShell{recvErr: errors.New("broken pipe")}
wrap := &einoStreamingShellWrap{inner: inner}
sr, err := wrap.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: "true"})
if err != nil {
t.Fatalf("ExecuteStreaming: %v", err)
}
defer sr.Close()
_, rerr := sr.Recv()
if rerr == nil || errors.Is(rerr, io.EOF) {
t.Fatal("expected hard stream error for non-timeout failure")
}
}
+5 -2
View File
@@ -7,6 +7,7 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
@@ -29,7 +30,9 @@ type PlanExecuteRootArgs struct {
MwCfg *config.MultiAgentEinoMiddlewareConfig
// ConversationID is used for transcript/isolation paths in middleware.
ConversationID string
Logger *zap.Logger
DB *database.DB
ProjectID string
Logger *zap.Logger
// ModelName is used for model input token estimation logs.
ModelName string
// ExecPreMiddlewares 是由 prependEinoMiddlewares 构建的前置中间件(patchtoolcalls, reduction, toolsearch, plantask),
@@ -93,7 +96,7 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
}
// 4. summarization(最后,与 Deep/Supervisor 一致)
if a.AppCfg != nil {
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.Logger)
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.DB, a.ProjectID, a.Logger)
if sumErr != nil {
return nil, fmt.Errorf("plan_execute executor summarization: %w", sumErr)
}
+3 -1
View File
@@ -11,6 +11,7 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/project"
@@ -32,6 +33,7 @@ func RunEinoSingleChatModelAgent(
appCfg *config.Config,
ma *config.MultiAgentConfig,
ag *agent.Agent,
db *database.DB,
logger *zap.Logger,
conversationID string,
projectID string,
@@ -121,7 +123,7 @@ func RunEinoSingleChatModelAgent(
return nil, fmt.Errorf("eino single 模型: %w", err)
}
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
if err != nil {
return nil, fmt.Errorf("eino single summarization: %w", err)
}
+56 -1
View File
@@ -9,7 +9,9 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
copenai "cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/project"
"github.com/bytedance/sonic"
"github.com/cloudwego/eino/adk"
@@ -40,6 +42,8 @@ func newEinoSummarizationMiddleware(
appCfg *config.Config,
mwCfg *config.MultiAgentEinoMiddlewareConfig,
conversationID string,
db *database.DB,
projectID string,
logger *zap.Logger,
) (adk.ChatModelAgentMiddleware, error) {
if summaryModel == nil || appCfg == nil {
@@ -143,7 +147,14 @@ func newEinoSummarizationMiddleware(
},
},
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
return summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
if ferr != nil {
return nil, ferr
}
if appCfg != nil {
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
}
return out, nil
},
Callback: func(ctx context.Context, before, after adk.ChatModelAgentState) error {
if transcriptPath != "" && len(before.Messages) > 0 {
@@ -176,6 +187,50 @@ func newEinoSummarizationMiddleware(
return mw, nil
}
// refreshFactIndexInMessages 在 summarization 压缩后,用 DB 最新索引替换 system 中已有的项目黑板索引段。
func refreshFactIndexInMessages(msgs []adk.Message, db *database.DB, projectID string, cfg config.ProjectConfig, logger *zap.Logger) []adk.Message {
if db == nil || !cfg.Enabled {
return msgs
}
projectID = strings.TrimSpace(projectID)
if projectID == "" {
return msgs
}
freshIndex, err := project.BuildFactIndexBlock(db, projectID, cfg)
if err != nil {
if logger != nil {
logger.Warn("summarization: 刷新项目黑板索引失败", zap.String("projectId", projectID), zap.Error(err))
}
return msgs
}
freshIndex = strings.TrimSpace(freshIndex)
if freshIndex == "" {
return msgs
}
changed := false
out := make([]adk.Message, len(msgs))
for i, msg := range msgs {
if msg == nil || msg.Role != schema.System {
out[i] = msg
continue
}
newContent, ok := project.ReplaceFactIndexSection(msg.Content, freshIndex)
if !ok {
out[i] = msg
continue
}
cloned := *msg
cloned.Content = newContent
out[i] = &cloned
changed = true
}
if changed && logger != nil {
logger.Info("summarization: 已刷新项目黑板索引", zap.String("projectId", projectID))
}
return out
}
// summarizeFinalizeWithRecentAssistantToolTrail 在摘要消息后保留最近 assistant/tool 轨迹,避免压缩后执行链断裂。
//
// 关键不变量:tool_call ↔ tool_result 的 pair 必须整体保留或整体丢弃。
+56 -1
View File
@@ -7,9 +7,14 @@ import (
"strings"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/project"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// fixedTokenCounter 让 tool 消息按 tokensPerToolMessage 计,其它消息按 1 计。
@@ -389,9 +394,11 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
"你是CyberStrikeAI,是一个专业的网络安全渗透测试专家。",
"高强度扫描要求:全力出击",
"",
project.FactIndexSectionStartMarker,
"## 项目黑板索引(project: 123, id: abc",
"(暂无事实)",
"需要写入请使用 upsert_project_fact。",
project.FactIndexSectionEndMarker,
"",
"# Skills System",
"**How to Use Skills**",
@@ -419,7 +426,7 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
func TestFormatSummarizationTranscript_OmitsBloatedSystem(t *testing.T) {
t.Parallel()
msgs := []adk.Message{
schema.SystemMessage("以下是当前会话绑定的工具名称索引\n- nmap\n\n你是CyberStrikeAI\n## 项目黑板索引(project: p1, id: x\n(暂无事实)\n# Skills System\nboiler"),
schema.SystemMessage("以下是当前会话绑定的工具名称索引\n- nmap\n\n你是CyberStrikeAI\n" + project.FactIndexSectionStartMarker + "\n## 项目黑板索引(project: p1, id: x\n(暂无事实)\n" + project.FactIndexSectionEndMarker + "\n# Skills System\nboiler"),
schema.UserMessage("hello"),
schema.AssistantMessage("reply", nil),
}
@@ -434,3 +441,51 @@ func TestFormatSummarizationTranscript_OmitsBloatedSystem(t *testing.T) {
t.Fatalf("dynamic blackboard missing: %q", out)
}
}
func TestRefreshFactIndexInMessages(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "summarize-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: "summarize-proj"})
if err != nil {
t.Fatal(err)
}
cfg := config.ProjectConfig{Enabled: true}
oldIndex, err := project.BuildFactIndexBlock(db, proj.ID, cfg)
if err != nil {
t.Fatal(err)
}
_, err = db.UpsertProjectFact(&database.ProjectFact{
ProjectID: proj.ID,
FactKey: "target/host",
Category: "target",
Summary: "fresh host fact",
})
if err != nil {
t.Fatal(err)
}
msgs := []adk.Message{
schema.SystemMessage("instruction\n\n" + oldIndex),
schema.UserMessage("hi"),
}
out := refreshFactIndexInMessages(msgs, db, proj.ID, cfg, nil)
sys := out[0].Content
if strings.Contains(sys, "(暂无事实)") {
t.Fatalf("expected refreshed index, got: %q", sys)
}
if !strings.Contains(sys, "fresh host fact") {
t.Fatalf("expected new fact in index: %q", sys)
}
if !strings.Contains(sys, "instruction") {
t.Fatalf("non-index system content should be preserved: %q", sys)
}
}
@@ -6,6 +6,8 @@ import (
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"cyberstrike-ai/internal/project"
"github.com/bytedance/sonic"
)
@@ -19,7 +21,6 @@ const (
transcriptToolIndexStartMarker = "以下是当前会话绑定的工具名称索引"
transcriptPersonaStartMarker = "你是CyberStrikeAI"
transcriptSkillsSystemMarker = "# Skills System"
transcriptProjectBlackboardMarker = "## 项目黑板索引"
)
// formatSummarizationTranscript renders pre-compaction messages for transcript.txt.
@@ -88,11 +89,17 @@ func stripSkillsSystemBoilerplate(s string) string {
}
func extractProjectBlackboardSection(s string) string {
idx := strings.Index(s, transcriptProjectBlackboardMarker)
if idx < 0 {
start := strings.Index(s, project.FactIndexSectionStartMarker)
if start < 0 {
return ""
}
return strings.TrimSpace(s[idx:])
section := s[start:]
end := strings.Index(section, project.FactIndexSectionEndMarker)
if end < 0 {
return ""
}
section = section[:end+len(project.FactIndexSectionEndMarker)]
return strings.TrimSpace(section)
}
func appendTranscriptSection(sb *strings.Builder, role schema.RoleType, body string) {
+6 -2
View File
@@ -15,6 +15,7 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/agents"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/project"
@@ -56,6 +57,7 @@ func RunDeepAgent(
appCfg *config.Config,
ma *config.MultiAgentConfig,
ag *agent.Agent,
db *database.DB,
logger *zap.Logger,
conversationID string,
projectID string,
@@ -210,7 +212,7 @@ func RunDeepAgent(
subMax := resolveMaxIterations(appCfg, sub.MaxIterations)
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
if err != nil {
return nil, fmt.Errorf("子代理 %q summarization 中间件: %w", id, err)
}
@@ -281,7 +283,7 @@ func RunDeepAgent(
return nil, fmt.Errorf("多代理主模型: %w", err)
}
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
if err != nil {
return nil, fmt.Errorf("多代理主 summarization 中间件: %w", err)
}
@@ -441,6 +443,8 @@ func RunDeepAgent(
AppCfg: appCfg,
MwCfg: &ma.EinoMiddleware,
ConversationID: conversationID,
DB: db,
ProjectID: projectID,
Logger: logger,
ModelName: appCfg.OpenAI.Model,
ExecPreMiddlewares: mainOrchestratorPre,
+10 -4
View File
@@ -10,7 +10,7 @@ package openai
// Auth: Bearer → x-api-key
// Tools: OpenAI tools[] → Claude tools[] (input_schema)
//
// Extended thinking: 顶层 `thinking` 从 OpenAI 请求体透传;响应中 `thinking` block 映射为
// Extended thinking: 顶层 `thinking` / `output_config` 从 OpenAI 请求体透传;响应中 `thinking` block 映射为
// `reasoning_content`(可读前缀 + 内部 JSON 尾缀以保留 signature,供多轮工具续跑;UI 用 openai.DisplayReasoningContent 剥离)。
import (
@@ -40,8 +40,9 @@ type claudeRequest struct {
System string `json:"system,omitempty"`
Messages []claudeMessage `json:"messages"`
Tools []claudeTool `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
Thinking json.RawMessage `json:"thinking,omitempty"`
Stream bool `json:"stream,omitempty"`
Thinking json.RawMessage `json:"thinking,omitempty"`
OutputConfig json.RawMessage `json:"output_config,omitempty"`
}
type claudeMessage struct {
@@ -304,12 +305,17 @@ func convertOpenAIToClaude(payload interface{}) (*claudeRequest, error) {
}
}
// Extended thinking (Anthropic top-level); merged from Eino ExtraFields / admin extras.
// Extended thinking + effort (Anthropic top-level); merged from Eino ExtraFields / admin extras.
if th, ok := oai["thinking"]; ok && th != nil {
if raw, err := json.Marshal(th); err == nil && len(raw) > 0 && string(raw) != "null" {
req.Thinking = json.RawMessage(raw)
}
}
if oc, ok := oai["output_config"]; ok && oc != nil {
if raw, err := json.Marshal(oc); err == nil && len(raw) > 0 && string(raw) != "null" {
req.OutputConfig = json.RawMessage(raw)
}
}
return req, nil
}
@@ -73,6 +73,39 @@ func TestConvertOpenAIToClaude_AssistantReasoningReplay(t *testing.T) {
}
}
func TestConvertOpenAIToClaude_OutputConfigEffort(t *testing.T) {
payload := map[string]interface{}{
"model": "claude-opus-4-8",
"messages": []interface{}{
map[string]interface{}{"role": "user", "content": "hi"},
},
"thinking": map[string]interface{}{
"type": "adaptive",
"display": "summarized",
},
"output_config": map[string]interface{}{
"effort": "high",
},
}
req, err := convertOpenAIToClaude(payload)
if err != nil {
t.Fatal(err)
}
if len(req.Thinking) == 0 {
t.Fatal("expected thinking")
}
if len(req.OutputConfig) == 0 {
t.Fatal("expected output_config")
}
var oc map[string]interface{}
if err := json.Unmarshal(req.OutputConfig, &oc); err != nil {
t.Fatal(err)
}
if oc["effort"] != "high" {
t.Fatalf("effort=%v", oc["effort"])
}
}
func TestClaudeToOpenAIResponseJSON_Thinking(t *testing.T) {
claudeBody := []byte(`{
"id":"msg_1","type":"message","role":"assistant","model":"x","stop_reason":"end_turn",
+79
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"unicode/utf8"
@@ -535,3 +536,81 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
return full.String(), toolCalls, finishReason, nil
}
// ModelsListResponse 表示 OpenAI 兼容 GET /models 响应。
type ModelsListResponse struct {
Object string `json:"object"`
Data []struct {
ID string `json:"id"`
Object string `json:"object,omitempty"`
OwnedBy string `json:"owned_by,omitempty"`
} `json:"data"`
}
// ListModels 调用 GET {baseURL}/models 获取可用模型 id 列表(按字典序)。
func (c *Client) ListModels(ctx context.Context) ([]string, error) {
if c == nil {
return nil, fmt.Errorf("openai client is not initialized")
}
if c.config == nil {
return nil, fmt.Errorf("openai config is nil")
}
if strings.TrimSpace(c.config.APIKey) == "" {
return nil, fmt.Errorf("openai api key is empty")
}
if c.isClaude() {
return nil, fmt.Errorf("claude provider does not support models list API")
}
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
if err != nil {
return nil, fmt.Errorf("build openai models request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("call openai models api: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read openai models response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, &APIError{
StatusCode: resp.StatusCode,
Body: string(respBody),
}
}
var list ModelsListResponse
if err := json.Unmarshal(respBody, &list); err != nil {
return nil, fmt.Errorf("decode openai models response: %w", err)
}
seen := make(map[string]struct{}, len(list.Data))
models := make([]string, 0, len(list.Data))
for _, item := range list.Data {
id := strings.TrimSpace(item.ID)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
models = append(models, id)
}
sort.Strings(models)
if len(models) == 0 {
return nil, fmt.Errorf("models list is empty")
}
return models, nil
}
+35 -14
View File
@@ -2,7 +2,6 @@ package project
import (
"fmt"
"sort"
"strings"
"cyberstrike-ai/internal/config"
@@ -22,7 +21,13 @@ func AppendSystemPromptBlock(base, block string) string {
return base + "\n\n" + block
}
// BuildFactIndexBlock 为 Agent 系统提示生成项目黑板索引(仅 key + summary,不含 body)。
const (
factIndexFooterGetDetail = "需要完整内容(攻击链、POC、请求响应等)时必须调用 get_project_fact(fact_key),禁止凭摘要臆造细节。"
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)。"
)
// BuildFactIndexBlock 为 Agent 系统提示生成项目黑板索引(key + summary + 关系边 + 攻击路径,不含 body)。
func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectConfig) (string, error) {
if db == nil || !cfg.Enabled {
return "", nil
@@ -41,27 +46,38 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo
if err != nil {
return "", err
}
allEdges, _ := db.ListProjectFactEdgesByProject(projectID)
_, incomingByTarget := indexEdgeGroupMaps(allEdges)
if len(facts) == 0 {
return fmt.Sprintf("## 项目黑板索引(project: %s, id: %s\n(暂无事实)\n需要写入请使用 upsert_project_fact;需要详情请调用 get_project_fact(fact_key)。", proj.Name, proj.ID), 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 {
if facts[i].Pinned != facts[j].Pinned {
return facts[i].Pinned
}
return facts[i].UpdatedAt.After(facts[j].UpdatedAt)
})
sortFactsForIndex(facts)
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
b.WriteString(fmt.Sprintf("## 项目黑板索引(project: %s, id: %s\n", proj.Name, proj.ID))
used := len([]rune(b.String()))
omitted := 0
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))
if used+lineRunes > maxRunes {
if used+lineRunes > factsBudget {
omitted++
continue
}
@@ -72,7 +88,12 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo
if omitted > 0 {
b.WriteString(fmt.Sprintf("\n(另有 %d 条未列入索引,请使用 list_project_facts 或 search_project_facts 查询。)\n", omitted))
}
b.WriteString("需要完整内容(攻击链、POC、请求响应等)时必须调用 get_project_fact(fact_key),禁止凭摘要臆造细节。\n")
b.WriteString("写入事实时:summary 写「什么+在哪+如何验证」;body 写可复现全流程(发现/利用类 fact_key 建议 finding|chain|exploit|poc/ 前缀)。\n")
return b.String(), nil
if pathSection := BuildFactPathOverviewSection(allEdges, indexedKeys, pathMaxRunes); pathSection != "" {
b.WriteString("\n")
b.WriteString(pathSection)
}
b.WriteString(footer)
return wrapFactIndexBlock(b.String()), nil
}
+56
View File
@@ -0,0 +1,56 @@
package project
import "strings"
// FactIndexSectionHeading 黑板索引可读标题行前缀(块内保留,供 Agent 阅读)。
const FactIndexSectionHeading = "## 项目黑板索引"
// FactIndexSectionStartMarker / EndMarkerHTML 注释边界,供程序化替换;对模型无指令语义。
const (
FactIndexSectionStartMarker = "<!-- fact-index-start -->"
FactIndexSectionEndMarker = "<!-- fact-index-end -->"
)
// ReplaceFactIndexSection 用 freshIndex 替换 content 中已有的项目黑板索引段。
// freshIndex 须为 BuildFactIndexBlock 的完整输出。起止 HTML 注释缺失时返回 (_, false)。
func ReplaceFactIndexSection(content, freshIndex string) (string, bool) {
freshIndex = strings.TrimSpace(freshIndex)
if freshIndex == "" {
return content, false
}
start, ok := factIndexSectionStart(content)
if !ok {
return content, false
}
end, ok := factIndexSectionEnd(content, start)
if !ok || end <= start {
return content, false
}
return content[:start] + freshIndex + content[end:], true
}
// wrapFactIndexBlock 为 BuildFactIndexBlock 正文加上统一起止 HTML 注释边界。
func wrapFactIndexBlock(content string) string {
content = strings.TrimSpace(content)
return FactIndexSectionStartMarker + "\n" + content + "\n" + FactIndexSectionEndMarker + "\n"
}
func factIndexSectionStart(content string) (int, bool) {
idx := strings.Index(content, FactIndexSectionStartMarker)
if idx < 0 {
return 0, false
}
return idx, true
}
func factIndexSectionEnd(content string, start int) (int, bool) {
if start < 0 || start >= len(content) {
return 0, false
}
tail := content[start:]
idx := strings.LastIndex(tail, FactIndexSectionEndMarker)
if idx < 0 {
return 0, false
}
return start + idx + len(FactIndexSectionEndMarker), true
}
+154
View File
@@ -0,0 +1,154 @@
package project
import (
"path/filepath"
"strings"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
func sampleFactIndexWithFacts(projectLabel, summary string) string {
return wrapFactIndexBlock("## 项目黑板索引(project: " + projectLabel + ", id: x\n" +
"- [target/a] target — " + summary + " (tentative)\n" +
factIndexFooterGetDetail + "\n" +
factIndexFooterWriteHint)
}
func TestReplaceFactIndexSection(t *testing.T) {
t.Parallel()
oldIndex := sampleFactIndexWithFacts("p1", "old summary")
newIndex := sampleFactIndexWithFacts("p1", "new summary")
t.Run("replaces index before next section", func(t *testing.T) {
content := "你是助手\n\n" + oldIndex + "\n\n## 图片分析\n看截图"
out, ok := ReplaceFactIndexSection(content, newIndex)
if !ok {
t.Fatal("expected replacement")
}
if strings.Contains(out, "old summary") {
t.Fatalf("old index should be gone: %q", out)
}
if !strings.Contains(out, "new summary") || !strings.Contains(out, "## 图片分析") {
t.Fatalf("expected new index and preserved vision section: %q", out)
}
if strings.Count(out, FactIndexSectionStartMarker) != 1 || strings.Count(out, FactIndexSectionEndMarker) != 1 {
t.Fatalf("expected exactly one start/end marker pair: %q", out)
}
})
t.Run("replaces index at end", func(t *testing.T) {
content := "## 项目测试范围\nscope\n\n" + oldIndex
out, ok := ReplaceFactIndexSection(content, newIndex)
if !ok {
t.Fatal("expected replacement")
}
if !strings.Contains(out, "## 项目测试范围") || !strings.Contains(out, "new summary") {
t.Fatalf("scope preserved, index updated: %q", out)
}
})
t.Run("summary with false markdown header does not truncate early", func(t *testing.T) {
summaryWithFakeHeader := "see\n\n## fake header in summary"
old := sampleFactIndexWithFacts("p1", summaryWithFakeHeader)
newIdx := sampleFactIndexWithFacts("p1", "new summary")
content := old + "\n\n## 图片分析\nvision"
out, ok := ReplaceFactIndexSection(content, newIdx)
if !ok {
t.Fatal("expected replacement")
}
if strings.Contains(out, "fake header in summary") {
t.Fatalf("old index tail should be fully removed: %q", out)
}
})
t.Run("summary containing end marker text does not truncate early", func(t *testing.T) {
summary := "note " + FactIndexSectionEndMarker + " in summary"
old := sampleFactIndexWithFacts("p1", summary)
newIdx := sampleFactIndexWithFacts("p1", "clean")
content := old + "\n\n## 图片分析\nvision"
out, ok := ReplaceFactIndexSection(content, newIdx)
if !ok {
t.Fatal("expected replacement")
}
if strings.Contains(out, "in summary") {
t.Fatalf("old block should be fully removed: %q", out)
}
})
t.Run("missing html markers does not replace", func(t *testing.T) {
legacy := "## 项目黑板索引(project: p1, id: x\n- [a] note — old (tentative)\n"
newIdx := sampleFactIndexWithFacts("p1", "new")
out, ok := ReplaceFactIndexSection("prefix\n\n"+legacy, newIdx)
if ok {
t.Fatalf("expected no replacement without markers: %q", out)
}
})
t.Run("empty facts block", func(t *testing.T) {
oldEmpty := wrapFactIndexBlock("## 项目黑板索引(project: p1, id: x\n(暂无事实)\n" + factIndexFooterEmpty)
newEmpty := sampleFactIndexWithFacts("p1", "first fact")
out, ok := ReplaceFactIndexSection(oldEmpty, newEmpty)
if !ok {
t.Fatal("expected replacement")
}
if strings.Contains(out, "(暂无事实)") {
t.Fatalf("old empty block should be gone: %q", out)
}
})
t.Run("no marker", func(t *testing.T) {
_, ok := ReplaceFactIndexSection("no blackboard here", newIndex)
if ok {
t.Fatal("expected false when marker missing")
}
})
t.Run("empty fresh index", func(t *testing.T) {
_, ok := ReplaceFactIndexSection(oldIndex, " ")
if ok {
t.Fatal("expected false for empty fresh index")
}
})
}
func TestFactIndexSectionBounds_useHTMLMarkers(t *testing.T) {
t.Parallel()
body := sampleFactIndexWithFacts("p", "line with\n\n## not a real section") + "TAIL_SHOULD_DROP"
start, ok := factIndexSectionStart(body)
if !ok || !strings.HasPrefix(body[start:], FactIndexSectionStartMarker) {
t.Fatalf("start should be at html start marker, got %d", start)
}
end, ok := factIndexSectionEnd(body, start)
if !ok || body[end:] != "\nTAIL_SHOULD_DROP" {
t.Fatalf("end should be after end marker, got remainder %q", body[end:])
}
}
func TestBuildFactIndexBlock_includesHTMLMarkers(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: "marker-proj"})
if err != nil {
t.Fatal(err)
}
block, err := BuildFactIndexBlock(db, proj.ID, config.ProjectConfig{Enabled: true})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(strings.TrimSpace(block), FactIndexSectionStartMarker) {
t.Fatalf("block should start with start marker: %q", block)
}
if !strings.Contains(block, FactIndexSectionEndMarker) {
t.Fatalf("block should include end marker: %q", block)
}
}
+256
View File
@@ -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
}
+68
View File
@@ -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))
}
}
+407
View File
@@ -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()
}
+96
View File
@@ -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 写入解析后的 linksparsed 为 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 写入出边(图连线等低层 APIbody 同步请用 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
}
+296
View File
@@ -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")
}
}
+231
View File
@@ -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)
})
}
+161
View File
@@ -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)
}
}
+9 -86
View File
@@ -1,100 +1,23 @@
package project
import (
"strings"
import "cyberstrike-ai/internal/projectprompt"
"cyberstrike-ai/internal/mcp/builtin"
)
// 边渗透边记录:统一节奏文案(agents/*.md 须与 FactRecordingIncrementalRhythmMarkdown 保持一致)。
const (
factRhythmCore = "勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。"
factRhythmCoordinatorSuffix = "委派/子任务返回新认知或漏洞时,由协调者及时写入,勿假定子代理已记。"
factRhythmSubAgentSuffix = "若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。"
)
// FactRecordingIncrementalRhythmMarkdown 返回边渗透边记录节奏(Markdown,供 agents/*.md 与文档对齐)。
// FactRecordingIncrementalRhythmMarkdown 见 projectprompt。
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()
return projectprompt.FactRecordingIncrementalRhythmMarkdown(coordinator, subAgent)
}
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()
}
// FactRecordingBlackboardSection 项目黑板与漏洞记录的完整系统提示块(单/多 Agent 主代理共用)。
// coordinatorDelegate 为 true 时追加「协调者代子代理落库」说明(Deep / plan_execute / supervisor)。
// FactRecordingBlackboardSection 见 projectprompt。
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(FactRecordingGuidanceBlock())
b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。")
return b.String()
return projectprompt.FactRecordingBlackboardSection(coordinatorDelegate)
}
// FactRecordingSubAgentSection 子代理边渗透边记录(无工具时输出待落库条目)
// FactRecordingSubAgentSection 见 projectprompt
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 {
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(FactRecordingGuidanceBlock())
b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。")
return b.String()
return projectprompt.FactRecordingBlackboardSectionMarkdown(coordinatorDelegate)
}
+5 -10
View File
@@ -3,6 +3,8 @@ package project
import (
"fmt"
"strings"
"cyberstrike-ai/internal/projectprompt"
)
// 事实 category 常量(写入 upsert_project_fact 的 category 字段)。
@@ -90,7 +92,8 @@ const attackChainFactBodyTemplate = `## 结论(可验证,一句话)
## 关联
- related_vulnerability_id: <可选对应 record_vulnerability id>
- 依赖事实: <fact_key auth/session_cookie>
- linksupsert 参数: [{ "from": "<fact_key>", "type": "discovered_on|..." }]from 当前 fact
- 依赖事实body 可读镜像: <fact_key auth/session_cookie>
## 备注与不确定性
<待验证假设环境差异绕过尝试记录>`
@@ -109,15 +112,7 @@ const envFactBodyTemplate = `## 摘要
// FactRecordingGuidanceBlock 写入系统提示:要求事实沉淀攻击链上下文而非仅结论。
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 导致上下文丢失`
return projectprompt.FactRecordingGuidanceBlock()
}
// SparseBodyWarning 攻击链类事实 body 不足时的工具返回提示(不阻断保存)。
+5 -1
View File
@@ -2,10 +2,14 @@ package project
import "strings"
// VisionImageSectionMarker 图片分析 section 标题(与 AppendVisionImageAnalysisIfReady 注入一致)。
const VisionImageSectionMarker = "## 图片分析"
// VisionImageAnalysisSection 单/多代理共用的图片分析提示(analyze_image;上下文仅保留文字摘要)。
func VisionImageAnalysisSection() string {
var b strings.Builder
b.WriteString("## 图片分析\n\n")
b.WriteString(VisionImageSectionMarker)
b.WriteString("\n\n")
b.WriteString("- 遇到图片文件(截图、验证码、登录页、报告配图)时,若存在工具 analyze_image,请传入服务器上的文件路径进行分析。\n")
b.WriteString("- 不要对二进制图片使用 read_file 指望理解内容;用户消息中「📎 xxx.png: /path」即为可传给 analyze_image 的路径。\n")
b.WriteString("- 验证码类:若已从页面或接口保存为本地图片(如 captcha.png),用 analyze_imagequestion 写明「只输出验证码字符」;识别失败则刷新验证码后重新保存再识;复杂滑块/行为验证码勿指望单次识图成功。\n")
+132
View File
@@ -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() }
+46 -16
View File
@@ -84,8 +84,9 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
}
}
// applyClaudeExtendedThinking sets Anthropic Messages API `thinking` when absent from ExtraRequestFields.
// Uses adaptive + summarized display by default (per Anthropic guidance for Claude 4.x); Sonnet 3.7 uses enabled+budget.
// applyClaudeExtendedThinking sets Anthropic Messages API fields per official guidance:
// - Adaptive models (4.6+): thinking.type=adaptive; output_config.effort only when user sets effort (API default is high).
// - Sonnet 3.7: thinking.type=enabled + budget_tokens=10000 (doc example); effort is not mapped — use extra_request_fields for custom budget.
func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort, model string) {
if cfg == nil || mode == "off" {
return
@@ -93,31 +94,60 @@ func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort,
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
if _, exists := cfg.ExtraFields["thinking"]; exists {
return
}
m := strings.ToLower(strings.TrimSpace(model))
thinking := map[string]any{
"type": "adaptive",
"display": "summarized",
sonnet37 := isClaudeSonnet37(m)
if _, exists := cfg.ExtraFields["thinking"]; !exists {
cfg.ExtraFields["thinking"] = claudeThinkingForModel(m, sonnet37)
}
// Sonnet 3.7: manual extended thinking is the documented path.
if strings.Contains(m, "claude-3-7-sonnet") || strings.Contains(m, "3-7-sonnet") || strings.Contains(m, "sonnet-3.7") {
thinking = map[string]any{
applyClaudeOutputConfigEffort(cfg, effort, sonnet37)
}
// claudeSonnet37DefaultBudgetTokens matches Anthropic extended-thinking documentation examples (budget_tokens with max_tokens 16000).
const claudeSonnet37DefaultBudgetTokens = 10000
func isClaudeSonnet37(m string) bool {
return strings.Contains(m, "claude-3-7-sonnet") ||
strings.Contains(m, "3-7-sonnet") ||
strings.Contains(m, "sonnet-3.7")
}
func claudeThinkingForModel(m string, sonnet37 bool) map[string]any {
if sonnet37 {
return map[string]any{
"type": "enabled",
"budget_tokens": 10000,
"budget_tokens": claudeSonnet37DefaultBudgetTokens,
"display": "summarized",
}
}
// Opus 4.7+: manual enabled+budget rejected — keep adaptive only.
// Opus 4.7+: manual enabled+budget rejected — adaptive only.
if strings.Contains(m, "opus-4-7") || strings.Contains(m, "opus-4.7") {
thinking = map[string]any{
return map[string]any{
"type": "adaptive",
"display": "summarized",
}
}
_ = effort // reserved: map to Anthropic effort / output_config when API stabilizes in one place
cfg.ExtraFields["thinking"] = thinking
return map[string]any{
"type": "adaptive",
"display": "summarized",
}
}
// applyClaudeOutputConfigEffort sets top-level output_config.effort only when effort is explicitly configured.
// Omitted effort uses the API default (high); do not inject effort on mode:on alone.
func applyClaudeOutputConfigEffort(cfg *einoopenai.ChatModelConfig, effort string, sonnet37 bool) {
if cfg == nil || sonnet37 {
return
}
if _, exists := cfg.ExtraFields["output_config"]; exists {
return
}
e := effortStringForAPI(effort)
if e == "" {
return
}
cfg.ExtraFields["output_config"] = map[string]any{"effort": e}
}
func effectiveMode(sr *config.OpenAIReasoningConfig, client *ClientIntent, allowClient bool) string {
+77
View File
@@ -80,3 +80,80 @@ func TestApplyOpenAICompat_maxPassthrough(t *testing.T) {
t.Fatalf("max effort wire=%q, want max", got)
}
}
func TestApplyClaude_adaptiveOutputConfigEffort(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{
Provider: "claude",
Model: "claude-opus-4-8",
Reasoning: config.OpenAIReasoningConfig{
Mode: "on",
Effort: "xhigh",
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
if !ok || th["type"] != "adaptive" {
t.Fatalf("thinking=%#v", cfg.ExtraFields["thinking"])
}
oc, ok := cfg.ExtraFields["output_config"].(map[string]any)
if !ok {
t.Fatal("expected output_config")
}
if oc["effort"] != "xhigh" {
t.Fatalf("effort=%v", oc["effort"])
}
}
func TestApplyClaude_sonnet37OfficialBudget(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{
Provider: "claude",
Model: "claude-3-7-sonnet-latest",
Reasoning: config.OpenAIReasoningConfig{
Mode: "on",
Effort: "low", // 3.7 has no output_config.effort; effort is not mapped to budget_tokens
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
if !ok || th["type"] != "enabled" {
t.Fatalf("thinking=%#v", cfg.ExtraFields["thinking"])
}
if th["budget_tokens"] != claudeSonnet37DefaultBudgetTokens {
t.Fatalf("budget_tokens=%v, want official example %d", th["budget_tokens"], claudeSonnet37DefaultBudgetTokens)
}
if _, hasOC := cfg.ExtraFields["output_config"]; hasOC {
t.Fatal("sonnet 3.7 should not set output_config")
}
}
func TestApplyClaude_onWithoutEffortOmitsOutputConfig(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{
Provider: "claude",
Model: "claude-sonnet-4-6",
Reasoning: config.OpenAIReasoningConfig{
Mode: "on",
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
if _, hasOC := cfg.ExtraFields["output_config"]; hasOC {
t.Fatal("on without explicit effort should omit output_config (API default high)")
}
}
func TestApplyClaude_autoWithoutEffortSkipsOutputConfig(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{
Provider: "claude",
Model: "claude-sonnet-4-6",
Reasoning: config.OpenAIReasoningConfig{
Mode: "auto",
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
if _, hasOC := cfg.ExtraFields["output_config"]; hasOC {
t.Fatal("auto without effort should omit output_config")
}
}
+5 -5
View File
@@ -27,13 +27,13 @@ parameters:
type: "string"
description: "数据源(wayback,commoncrawl,otx,urlscan"
required: false
flag: "-providers"
flag: "--providers"
format: "flag"
- name: "include_subs"
type: "bool"
description: "包含子域名"
required: false
flag: "-subs"
flag: "--subs"
format: "flag"
default: true
- name: "additional_args"
@@ -42,9 +42,9 @@ parameters:
额外的Gau参数。用于传递未在参数列表中定义的Gau选项。
**示例值:**
- "-o output.txt": 输出到文件
- "-t": 线程数
- "-b": 黑名单扩展
- "--o output.txt": 输出到文件
- "--threads 4": 线程数
- "--blacklist ttf,woff,svg,png": 黑名单扩展
**注意事项:**
- 多个参数用空格分隔
+1178 -47
View File
File diff suppressed because it is too large Load Diff
+1051 -31
View File
File diff suppressed because it is too large Load Diff
+119 -2
View File
@@ -258,10 +258,73 @@
"vulnerabilityManagement": "Vulnerability management",
"addFactCta": "+ Add fact",
"tabFacts": "Fact board",
"tabGraph": "Attack path",
"tabConversations": "Bound conversations",
"tabVulns": "Related vulnerabilities",
"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.",
"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",
"searchFactsPlaceholder": "Search key, summary, body…",
"category": "Category",
@@ -436,6 +499,9 @@
"conversationGroups": "Conversation groups",
"addGroup": "New group",
"recentConversations": "Recent conversations",
"sortConversations": "Sort",
"sortByCreatedAt": "Created time",
"sortByUpdatedAt": "Updated time",
"batchManage": "Batch manage",
"paginationShow": "Show {{start}}-{{end}} of {{total}}",
"paginationRange": "{{start}}-{{end}}/{{total}}",
@@ -676,7 +742,12 @@
"viewConversation": "View conversation",
"viewVulnerabilities": "View vulnerabilities",
"viewVulnerabilitiesQueueTitle": "View vulnerabilities: open management filtered to this queue",
"retryTask": "Retry",
"runSingleTask": "Run task",
"confirmRunSingleTask": "Run this task only? The queue will pause when it finishes and will not continue other pending items.",
"runSingleTaskFailed": "Failed to run task",
"runSingleTaskUnavailable": "Unavailable while the queue or a task is running",
"runSingleTaskUnavailableSelf": "This task is running",
"runSingleTaskUnavailableQueue": "Queue is running; pause it before running another task individually",
"conversationIdLabel": "Conversation ID",
"statusPending": "Pending",
"statusPaused": "Paused",
@@ -1939,6 +2010,13 @@
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
"openaiApiKeyPlaceholder": "Enter OpenAI API Key",
"modelPlaceholder": "gpt-4",
"fetchModels": "Fetch list",
"modelsListFetching": "Fetching model list...",
"modelsListSelectPlaceholder": "Select a model",
"modelsListSuccess": "Loaded {count} models — use the dropdown on the right, or type in the input",
"modelsListFailed": "Failed to fetch model list",
"modelsListNeedApiKey": "Please enter API Key first",
"modelsListClaudeHint": "Claude does not support auto model list; enter the model name manually",
"maxTotalTokens": "Max Context Tokens",
"maxTotalTokensPlaceholder": "120000",
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
@@ -2684,6 +2762,11 @@
},
"c2": {
"clipboardCopied": "Copied to clipboard",
"common": {
"justNow": "Just now",
"minutesAgo": "{{n}}m ago",
"hoursAgo": "{{n}}h ago"
},
"fmt": {
"durationMs": "{{n}}ms",
"durationSec": "{{n}}s",
@@ -2741,6 +2824,8 @@
"bindHintExternal": "Use 0.0.0.0 to allow external access",
"callbackHost": "Callback host (optional)",
"callbackHostHint": "Public IP or hostname stored for payloads/beacons; separate from bind address. If empty, payload generation falls back to bind address / auto-detect.",
"allowLegacyShell": "Allow unencrypted classic reverse shell (lab only)",
"allowLegacyShellHint": "Off by default. When enabled, raw bash/nc TCP connections register sessions and are vulnerable to internet scanners; use encrypted Beacon builds for production.",
"malleableProfile": "Malleable Profile",
"malleableProfileHint": "Optional; HTTP/HTTPS Beacon response headers and traffic disguise. Stop and start the listener again for changes to take effect.",
"malleableProfileNone": "None",
@@ -2818,10 +2903,22 @@
"infoFirstSeen": "First seen",
"infoLastCheckin": "Last check-in",
"infoNote": "Note",
"infoNoteEmpty": "No notes",
"infoSectionIdentity": "Identity",
"infoSectionSystem": "System",
"infoSectionNetwork": "Network & beacon",
"infoSectionTimeline": "Timeline",
"infoSectionNote": "Notes",
"adminYes": "Yes",
"adminNo": "No",
"promptSleepSeconds": "Sleep interval (seconds)",
"promptJitterPercent": "Jitter percent (0100)",
"sleepModalHint": "Saves to the server and queues a sleep task. The implant applies it on the next task poll; later check-ins keep this config.",
"sleepModalTitle": "Beacon interval",
"sleepModalCurrent": "Current {{sec}}s · jitter {{jitter}}%",
"sleepModalPreview": "Estimated {{min}} {{max}} s",
"sleepModalPresets": "Presets",
"toastSleepInvalid": "Sleep interval must be at least 1 second",
"toastSleepUpdated": "Sleep settings updated",
"confirmExitSession": "Send exit command to this session?",
"confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)",
@@ -2839,7 +2936,25 @@
"termWaitFinish": "Please wait for the current command to finish",
"termCtrlC": "Remote interrupt is not supported in this version",
"termQueued": "[Command queued — will run after the current task completes]",
"clearTerminal": "Clear"
"clearTerminal": "Clear",
"batchDelete": "Delete selected",
"deleteFiltered": "Delete filtered",
"selectAll": "Select all",
"filterAllStatus": "All statuses",
"filterAllListeners": "All listeners",
"filterSearchPlaceholder": "Search hostname / user / IP",
"filterApply": "Filter",
"filterReset": "Reset",
"filterSuspicious": "Likely false positives",
"filterCount": "{{n}} total, {{selected}} selected",
"emptyFilter": "No sessions match the current filters",
"listEmpty": "No sessions",
"selectPromptTitle": "Select a session",
"selectPromptHint": "Click a session in the list on the left to view terminal, files, and tasks.",
"confirmBatchDelete": "Delete {{n}} selected session(s)? Related tasks and file records will be removed.",
"confirmDeleteFiltered": "Delete all {{n}} session(s) in the current filter results?",
"toastSelectFirst": "Select at least one session to delete",
"toastBatchDeleted": "Deleted {{n}} session(s)"
},
"tasks": {
"title": "Task Management",
@@ -2862,6 +2977,8 @@
"pending": "Pending",
"emptyAll": "No tasks yet",
"emptySession": "No tasks for this session",
"sessionTaskHistory": "Task history",
"sessionTaskCount": "{{n}} tasks",
"colTask": "Task",
"colSession": "Session",
"colType": "Type",
+119 -2
View File
@@ -246,10 +246,73 @@
"vulnerabilityManagement": "漏洞管理",
"addFactCta": "+ 添加事实",
"tabFacts": "事实黑板",
"tabGraph": "攻击路径",
"tabConversations": "关联对话",
"tabVulns": "关联漏洞",
"tabSettings": "设置",
"factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 bodyAgent 通过 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(来源 → 当前事实)。常用 typediscovered_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": "搜索事实",
"searchFactsPlaceholder": "搜索 key、摘要、body…",
"category": "分类",
@@ -424,6 +487,9 @@
"conversationGroups": "对话分组",
"addGroup": "新建分组",
"recentConversations": "最近对话",
"sortConversations": "排序",
"sortByCreatedAt": "创建时间",
"sortByUpdatedAt": "更新时间",
"batchManage": "批量管理",
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
"paginationRange": "{{start}}-{{end}}/{{total}}",
@@ -664,7 +730,12 @@
"viewConversation": "查看对话",
"viewVulnerabilities": "查看漏洞",
"viewVulnerabilitiesQueueTitle": "查看漏洞:打开漏洞管理并筛选本队列",
"retryTask": "重试",
"runSingleTask": "单条执行",
"confirmRunSingleTask": "确定执行该任务?仅运行这一条,完成后队列会自动暂停,不会继续执行其他待执行项。",
"runSingleTaskFailed": "单条执行失败",
"runSingleTaskUnavailable": "队列或任务执行中,暂无法单条执行",
"runSingleTaskUnavailableSelf": "该任务正在执行中",
"runSingleTaskUnavailableQueue": "队列批量执行中,请暂停后再单条执行其它任务",
"conversationIdLabel": "对话ID",
"statusPending": "待执行",
"statusPaused": "已暂停",
@@ -1927,6 +1998,13 @@
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
"openaiApiKeyPlaceholder": "输入OpenAI API Key",
"modelPlaceholder": "gpt-4",
"fetchModels": "获取列表",
"modelsListFetching": "正在获取模型列表...",
"modelsListSelectPlaceholder": "请选择模型",
"modelsListSuccess": "已加载 {count} 个模型,请用右侧下拉框选择,或继续在左侧输入",
"modelsListFailed": "获取模型列表失败",
"modelsListNeedApiKey": "请先填写 API Key",
"modelsListClaudeHint": "Claude 不支持自动获取模型列表,请手动填写",
"maxTotalTokens": "最大上下文 Token 数",
"maxTotalTokensPlaceholder": "120000",
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
@@ -2672,6 +2750,11 @@
},
"c2": {
"clipboardCopied": "已复制到剪贴板",
"common": {
"justNow": "刚刚",
"minutesAgo": "{{n}} 分钟前",
"hoursAgo": "{{n}} 小时前"
},
"fmt": {
"durationMs": "{{n}}ms",
"durationSec": "{{n}}秒",
@@ -2729,6 +2812,8 @@
"bindHintExternal": "使用 0.0.0.0 允许外部访问",
"callbackHost": "回连地址(可选)",
"callbackHostHint": "公网 IP 或域名,写入配置供 Payload/Beacon 使用;与「绑定地址」分离。不填则生成 Payload 时按绑定地址或自动探测。",
"allowLegacyShell": "允许未加密经典反弹 Shell(内网实验)",
"allowLegacyShellHint": "默认关闭。开启后 bash/nc 等裸 TCP 连接可登记会话,公网易被扫描器误连;生产环境请使用「生成 Beacon」加密上线。",
"malleableProfile": "Malleable Profile",
"malleableProfileHint": "可选;用于 HTTP/HTTPS Beacon 服务端响应头等流量伪装。修改后需停止并重新启动监听器才会生效。",
"malleableProfileNone": "不使用",
@@ -2806,10 +2891,22 @@
"infoFirstSeen": "首次上线",
"infoLastCheckin": "上次心跳",
"infoNote": "备注",
"infoNoteEmpty": "暂无备注",
"infoSectionIdentity": "身份信息",
"infoSectionSystem": "系统环境",
"infoSectionNetwork": "网络与信标",
"infoSectionTimeline": "时间线",
"infoSectionNote": "备注",
"adminYes": "是",
"adminNo": "否",
"promptSleepSeconds": "Sleep 间隔(秒)",
"promptJitterPercent": "抖动百分比(0100",
"sleepModalHint": "保存后将写入服务端并下发 sleep 任务;植入体在下次拉取任务后生效,同时后续心跳会同步该配置。",
"sleepModalTitle": "心跳配置",
"sleepModalCurrent": "当前 {{sec}} 秒 · 抖动 {{jitter}}%",
"sleepModalPreview": "预计间隔 {{min}} {{max}} 秒",
"sleepModalPresets": "快捷",
"toastSleepInvalid": "Sleep 间隔至少为 1 秒",
"toastSleepUpdated": "Sleep 设置已更新",
"confirmExitSession": "向该会话发送退出指令?",
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
@@ -2827,7 +2924,25 @@
"termWaitFinish": "请等待当前命令执行完成",
"termCtrlC": "当前版本暂不支持中断远程命令",
"termQueued": "[命令已加入队列,将在当前任务完成后执行]",
"clearTerminal": "清屏"
"clearTerminal": "清屏",
"batchDelete": "批量删除",
"deleteFiltered": "删除筛选结果",
"selectAll": "全选",
"filterAllStatus": "全部状态",
"filterAllListeners": "全部监听器",
"filterSearchPlaceholder": "搜索主机名 / 用户 / IP",
"filterApply": "筛选",
"filterReset": "重置",
"filterSuspicious": "疑似误报",
"filterCount": "共 {{n}} 条,已选 {{selected}}",
"emptyFilter": "没有符合筛选条件的会话",
"listEmpty": "暂无会话",
"selectPromptTitle": "选择会话",
"selectPromptHint": "在左侧列表中点击一个会话,查看终端、文件与任务详情。",
"confirmBatchDelete": "确定删除选中的 {{n}} 个会话?关联任务与文件记录将一并清除。",
"confirmDeleteFiltered": "确定删除当前筛选结果中的全部 {{n}} 个会话?",
"toastSelectFirst": "请先勾选要删除的会话",
"toastBatchDeleted": "已删除 {{n}} 个会话"
},
"tasks": {
"title": "任务管理",
@@ -2850,6 +2965,8 @@
"pending": "待处理",
"emptyAll": "暂无任务",
"emptySession": "该会话暂无任务",
"sessionTaskHistory": "任务历史",
"sessionTaskCount": "共 {{n}} 条",
"colTask": "任务",
"colSession": "会话",
"colType": "类型",
+743 -138
View File
File diff suppressed because it is too large Load Diff
+111 -9
View File
@@ -2252,10 +2252,22 @@ async function syncAssistantReasoningContentFromServer(backendMessageId, domAssi
window.normalizeReasoningContentForDisplay = normalizeReasoningContentForDisplay;
window.setMessageReasoningContent = setMessageReasoningContent;
window.getMessageReasoningContent = getMessageReasoningContent;
window.filterNoiseProcessDetails = filterNoiseProcessDetails;
window.mergeMessageReasoningContentIntoProcessDetails = mergeMessageReasoningContentIntoProcessDetails;
window.syncAssistantReasoningContentFromServer = syncAssistantReasoningContentFromServer;
/** 相邻且类型/正文/data 完全一致的过程详情只保留一条(与后端去重一致,避免时间线叠多条相同块) */
function isEinoAgentHeartbeatProgress(detail) {
if (!detail || detail.eventType !== 'progress') return false;
const msg = String(detail.message != null ? detail.message : '').trim();
return /^\[Eino\]\s+\S/.test(msg);
}
function filterNoiseProcessDetails(details) {
if (!Array.isArray(details)) return details;
return details.filter(function (d) { return !isEinoAgentHeartbeatProgress(d); });
}
function dedupeConsecutiveProcessDetailRows(details) {
if (!Array.isArray(details) || details.length < 2) {
return details;
@@ -2394,6 +2406,7 @@ function renderProcessDetails(messageId, processDetails) {
detailsContainer.dataset.loaded = '1';
}
processDetails = mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningFromMessage);
processDetails = filterNoiseProcessDetails(processDetails);
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
@@ -5763,6 +5776,95 @@ let conversationGroupMappingCache = {};
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size';
const CONVERSATIONS_SORT_KEY = 'cyberstrike.conversations_sort_by';
function getConversationSortBy() {
try {
const saved = localStorage.getItem(CONVERSATIONS_SORT_KEY);
if (saved === 'created_at' || saved === 'updated_at') return saved;
} catch (e) { /* ignore */ }
return 'updated_at';
}
let conversationSortBy = getConversationSortBy();
function getConversationSortTime(conv) {
const field = conversationSortBy === 'created_at' ? 'createdAt' : 'updatedAt';
const raw = conv && conv[field];
if (!raw) return new Date(0);
const date = new Date(raw);
return isNaN(date.getTime()) ? new Date(0) : date;
}
function updateConversationSortMenuUI() {
const menu = document.getElementById('conversation-sort-menu');
const btn = document.getElementById('conversation-sort-btn');
if (!menu) return;
menu.querySelectorAll('.conversation-sort-option').forEach((option) => {
const selected = option.dataset.sort === conversationSortBy;
option.classList.toggle('is-selected', selected);
option.setAttribute('aria-checked', selected ? 'true' : 'false');
});
if (btn) {
btn.setAttribute('aria-expanded', menu.hidden ? 'false' : 'true');
}
}
function closeConversationSortMenu() {
const menu = document.getElementById('conversation-sort-menu');
const btn = document.getElementById('conversation-sort-btn');
if (menu) menu.hidden = true;
if (btn) btn.setAttribute('aria-expanded', 'false');
}
function toggleConversationSortMenu(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
const menu = document.getElementById('conversation-sort-menu');
const btn = document.getElementById('conversation-sort-btn');
if (!menu || !btn) return;
const willOpen = menu.hidden;
closeConversationSortMenu();
if (willOpen) {
menu.hidden = false;
btn.setAttribute('aria-expanded', 'true');
updateConversationSortMenuUI();
}
}
function setConversationSortBy(sortBy) {
const next = sortBy === 'created_at' ? 'created_at' : 'updated_at';
if (next === conversationSortBy) {
closeConversationSortMenu();
return;
}
conversationSortBy = next;
try {
localStorage.setItem(CONVERSATIONS_SORT_KEY, next);
} catch (e) { /* ignore */ }
updateConversationSortMenuUI();
closeConversationSortMenu();
conversationsPagination.page = 1;
loadConversationsWithGroups(conversationsSearchQuery);
}
if (!window.__conversationSortMenuBound) {
window.__conversationSortMenuBound = true;
document.addEventListener('click', (event) => {
const dropdown = document.getElementById('conversation-sort-dropdown');
if (!dropdown || dropdown.contains(event.target)) return;
closeConversationSortMenu();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeConversationSortMenu();
});
}
window.toggleConversationSortMenu = toggleConversationSortMenu;
window.setConversationSortBy = setConversationSortBy;
window.closeConversationSortMenu = closeConversationSortMenu;
function getConversationsPageSize() {
try {
@@ -6025,6 +6127,9 @@ async function loadConversationsWithGroups(searchQuery = '') {
const pageSize = conversationsPagination.pageSize;
const offset = (conversationsPagination.page - 1) * pageSize;
const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
if (conversationSortBy === 'created_at') {
convParams.set('sort_by', 'created_at');
}
if (searchQuery && searchQuery.trim()) {
convParams.set('search', searchQuery.trim());
} else {
@@ -6114,11 +6219,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
});
// 按时间排序
const sortByTime = (a, b) => {
const timeA = a.updatedAt ? new Date(a.updatedAt) : new Date(0);
const timeB = b.updatedAt ? new Date(b.updatedAt) : new Date(0);
return timeB - timeA;
};
const sortByTime = (a, b) => getConversationSortTime(b) - getConversationSortTime(a);
pinnedConvs.sort(sortByTime);
normalConvs.sort(sortByTime);
@@ -6146,8 +6247,8 @@ async function loadConversationsWithGroups(searchQuery = '') {
};
normalConvs.forEach(conv => {
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
const dateObj = getConversationSortTime(conv);
const validDate = dateObj.getTime() === 0 ? new Date() : dateObj;
const groupKey = getConversationGroup(validDate, todayStart, sevenDaysCutoff, yesterdayStart);
groups[groupKey].push({
...conv,
@@ -6159,8 +6260,8 @@ async function loadConversationsWithGroups(searchQuery = '') {
if (pinnedConvs.length > 0) {
pinnedConvs.forEach(conv => {
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
const dateObj = getConversationSortTime(conv);
const validDate = dateObj.getTime() === 0 ? new Date() : dateObj;
fragment.appendChild(createConversationListItemWithMenu({
...conv,
_timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart),
@@ -8508,6 +8609,7 @@ function clearGroupSearch() {
// 初始化时加载分组
document.addEventListener('DOMContentLoaded', async () => {
updateConversationSortMenuUI();
await loadGroups();
await loadConversationsWithGroups();
+680
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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 一致:优先 categoryvuln: 合成节点例外;无 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
View File
@@ -64,6 +64,8 @@ Host: ...
## 关联
- related_vulnerability_id: <可选>
- 依赖事实: <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) {
currentProjectTab = tab;
['facts', 'conversations', 'vulns', 'settings'].forEach((t) => {
['facts', 'graph', 'conversations', 'vulns', 'settings'].forEach((t) => {
const btn = document.getElementById(`project-tab-${t}`);
const panel = document.getElementById(`project-panel-${t}`);
if (btn) btn.classList.toggle('is-active', t === tab);
if (panel) panel.hidden = t !== tab;
});
if (tab === 'facts') loadProjectFacts();
if (tab === 'graph') loadProjectFactGraph();
if (tab === 'conversations') loadProjectConversations();
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() {
const params = new URLSearchParams();
params.set('limit', '200');
params.set('include_link_counts', 'true');
const search = document.getElementById('project-facts-search')?.value?.trim();
const category = document.getElementById('project-facts-filter-category')?.value?.trim();
const confidence = document.getElementById('project-facts-filter-confidence')?.value?.trim();
@@ -768,11 +1066,11 @@ function debouncedLoadProjectFacts() {
async function loadProjectFacts() {
const tbody = document.getElementById('project-facts-tbody');
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 res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`);
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;
}
const facts = await res.json();
@@ -782,7 +1080,7 @@ async function loadProjectFacts() {
document.getElementById('project-facts-filter-category')?.value ||
document.getElementById('project-facts-filter-confidence')?.value ||
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')
}</td></tr>`;
refreshProjectHeaderStats();
@@ -797,10 +1095,16 @@ async function loadProjectFacts() {
const pinBadge = f.pinned
? `<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>
<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-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
<td class="cell-fact-links">${linkBadge}</td>
<td>${formatFactBodyBadge(f)}</td>
<td>${formatConfidenceBadge(f.confidence)}</td>
<td>${formatProjectTime(f.updated_at, f.created_at)}</td>
@@ -849,6 +1153,7 @@ async function loadProjectConversations() {
<td class="col-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" 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>
</div>
</td>
@@ -869,6 +1174,32 @@ function openProjectConversation(conversationId) {
}, 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) {
if (!conversationId || !confirm(tp('projects.confirmUnbindConversation'))) return;
const res = await apiFetch(`/api/conversations/${encodeURIComponent(conversationId)}/project`, {
@@ -1509,6 +1840,10 @@ function resetFactModalForm() {
if (pinEl) pinEl.checked = false;
const rel = document.getElementById('fact-modal-related-vuln');
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();
}
@@ -1540,6 +1875,8 @@ function fillFactModalForm(f) {
}
const rel = document.getElementById('fact-modal-related-vuln');
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');
if (pinEl) pinEl.checked = !!f.pinned;
updateFactFormHints();
@@ -1556,7 +1893,7 @@ async function showEditFactModal(factKey) {
resetFactModalForm();
openProjectsOverlay('fact-modal', { focus: false });
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) {
closeFactModal();
@@ -1594,6 +1931,7 @@ async function saveFactModal() {
confidence: document.getElementById('fact-modal-confidence').value,
pinned: !!document.getElementById('fact-modal-pinned')?.checked,
related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '',
links_text: document.getElementById('fact-modal-links')?.value || '',
};
const editId = window._factModalEditId;
const res = editId
@@ -1613,12 +1951,14 @@ async function saveFactModal() {
}
closeFactModal();
loadProjectFacts();
if (currentProjectTab === 'graph') loadProjectFactGraph();
}
async function deleteProjectFact(id) {
if (!confirm(tp('projects.confirmDeleteFact'))) return;
await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' });
loadProjectFacts();
if (currentProjectTab === 'graph') loadProjectFactGraph();
}
function parseProjectDate(t) {
@@ -1974,5 +2314,15 @@ window.viewFactsForVulnerability = viewFactsForVulnerability;
window.openProjectConversation = openProjectConversation;
window.unbindConversationFromProject = unbindConversationFromProject;
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.projectNameById = projectNameById;
+390 -1
View File
@@ -299,6 +299,7 @@ async function loadConfig(loadTools = true) {
}
fillVisionConfigFromCurrent(currentConfig.vision || {});
initModelListControls();
// 填充FOFA配置
const fofa = currentConfig.fofa || {};
@@ -1569,9 +1570,397 @@ function syncVisionFormEnabled() {
if (panel) {
panel.style.opacity = enabled ? '1' : '0.55';
panel.querySelectorAll('input, select, textarea, a').forEach(el => {
if (el.id === 'test-vision-btn') return;
if (el.id === 'test-vision-btn' || el.id === 'fetch-vision-models-btn' || el.id === 'vision-model-select') return;
el.disabled = !enabled;
});
syncModelListFetchButtons();
}
}
const modelPickSelectMap = {};
let modelPickSelectDocListener = false;
function modelPickT(key) {
return typeof window.t === 'function' ? window.t(key) : key;
}
function closeAllModelPickDropdowns() {
Object.keys(modelPickSelectMap).forEach(function (id) {
modelPickSelectMap[id].wrapper.classList.remove('open');
});
}
function syncModelPickDropdown(selectId) {
const reg = modelPickSelectMap[selectId];
if (!reg) return;
const { select, dropdown, trigger, wrapper, menuList, countBadge } = reg;
const placeholder = modelPickT('settingsBasic.modelsListSelectPlaceholder');
menuList.innerHTML = '';
let optionCount = 0;
Array.prototype.forEach.call(select.options, function (opt) {
if (!opt.value) return;
optionCount += 1;
const item = document.createElement('div');
item.className = 'model-pick-option';
item.setAttribute('role', 'option');
item.setAttribute('data-value', opt.value);
if (opt.value === select.value) {
item.classList.add('is-selected');
item.setAttribute('aria-selected', 'true');
}
const check = document.createElement('span');
check.className = 'model-pick-option-check';
check.setAttribute('aria-hidden', 'true');
check.textContent = '✓';
const label = document.createElement('span');
label.className = 'model-pick-option-label';
label.textContent = opt.textContent;
item.appendChild(check);
item.appendChild(label);
menuList.appendChild(item);
});
const selectedOpt = select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
const labelEl = trigger.querySelector('.model-pick-trigger-label');
if (labelEl) {
labelEl.textContent = (selectedOpt && selectedOpt.value) ? selectedOpt.textContent : placeholder;
}
if (countBadge) {
countBadge.textContent = String(optionCount);
countBadge.style.display = optionCount > 0 ? '' : 'none';
}
const header = wrapper.querySelector('.model-pick-menu-header');
if (header) {
header.textContent = optionCount > 0
? placeholder + ' · ' + optionCount
: placeholder;
}
trigger.disabled = !!select.disabled;
wrapper.classList.toggle('is-disabled', !!select.disabled);
wrapper.style.display = optionCount > 0 ? '' : 'none';
select.style.display = 'none';
}
function enhanceModelPickSelect(selectId) {
const select = document.getElementById(selectId);
if (!select) return;
if (select.dataset.modelPickEnhanced === '1') {
syncModelPickDropdown(selectId);
return;
}
select.dataset.modelPickEnhanced = '1';
select.classList.add('model-pick-native');
select.tabIndex = -1;
select.setAttribute('aria-hidden', 'true');
const wrapper = document.createElement('div');
wrapper.className = 'model-pick-dropdown';
wrapper.style.display = 'none';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'model-pick-trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
const labelSpan = document.createElement('span');
labelSpan.className = 'model-pick-trigger-label';
labelSpan.textContent = modelPickT('settingsBasic.modelsListSelectPlaceholder');
const meta = document.createElement('span');
meta.className = 'model-pick-trigger-meta';
const countBadge = document.createElement('span');
countBadge.className = 'model-pick-count';
countBadge.style.display = 'none';
const caret = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
caret.setAttribute('class', 'model-pick-caret');
caret.setAttribute('viewBox', '0 0 16 16');
caret.setAttribute('aria-hidden', 'true');
caret.innerHTML = '<path fill="currentColor" d="M4.47 6.47a.75.75 0 0 1 1.06 0L8 8.94l2.47-2.47a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 0 1 0-1.06z"/>';
meta.appendChild(countBadge);
meta.appendChild(caret);
trigger.appendChild(labelSpan);
trigger.appendChild(meta);
const menu = document.createElement('div');
menu.className = 'model-pick-menu';
const header = document.createElement('div');
header.className = 'model-pick-menu-header';
menu.appendChild(header);
const menuList = document.createElement('div');
menuList.className = 'model-pick-menu-list';
menuList.setAttribute('role', 'listbox');
menu.appendChild(menuList);
const parent = select.parentNode;
const fetchLink = parent.querySelector('.model-pick-fetch-link');
if (fetchLink) {
parent.insertBefore(wrapper, fetchLink);
} else {
parent.appendChild(wrapper);
}
wrapper.appendChild(trigger);
wrapper.appendChild(menu);
wrapper.appendChild(select);
modelPickSelectMap[selectId] = {
wrapper,
trigger,
menu,
menuList,
countBadge,
select
};
if (!modelPickSelectDocListener) {
document.addEventListener('click', closeAllModelPickDropdowns);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeAllModelPickDropdowns();
});
modelPickSelectDocListener = true;
}
trigger.addEventListener('click', function (e) {
e.stopPropagation();
if (select.disabled) return;
const open = wrapper.classList.contains('open');
closeAllModelPickDropdowns();
if (!open) wrapper.classList.add('open');
});
menuList.addEventListener('click', function (e) {
const opt = e.target.closest('.model-pick-option');
if (!opt) return;
const val = opt.getAttribute('data-value');
if (val === null || val === '') return;
if (select.value !== val) {
select.value = val;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
wrapper.classList.remove('open');
syncModelPickDropdown(selectId);
});
syncModelPickDropdown(selectId);
}
function initModelListControls() {
const providerEl = document.getElementById('openai-provider');
if (providerEl && !providerEl.dataset.modelListBound) {
providerEl.dataset.modelListBound = '1';
providerEl.addEventListener('change', syncModelListFetchButtons);
}
const visionProv = document.getElementById('vision-provider');
if (visionProv && !visionProv.dataset.modelListBound) {
visionProv.dataset.modelListBound = '1';
visionProv.addEventListener('change', syncModelListFetchButtons);
}
bindModelSelect('openai');
bindModelSelect('vision');
syncModelListFetchButtons();
}
function modelSelectIds(scope) {
if (scope === 'vision') {
return { selectId: 'vision-model-select', inputId: 'vision-model' };
}
return { selectId: 'openai-model-select', inputId: 'openai-model' };
}
function bindModelSelect(scope) {
const { selectId, inputId } = modelSelectIds(scope);
const select = document.getElementById(selectId);
if (!select || select.dataset.bound) return;
select.dataset.bound = '1';
enhanceModelPickSelect(selectId);
select.addEventListener('change', function () {
if (!select.value) return;
const input = document.getElementById(inputId);
if (input) input.value = select.value;
});
}
function resolveModelListCredentials(scope) {
if (scope === 'vision') {
const vp = (document.getElementById('vision-provider')?.value || '').trim();
const provider = vp || document.getElementById('openai-provider')?.value || 'openai';
const baseUrl = (document.getElementById('vision-base-url')?.value || '').trim()
|| (document.getElementById('openai-base-url')?.value || '').trim();
const apiKey = (document.getElementById('vision-api-key')?.value || '').trim()
|| (document.getElementById('openai-api-key')?.value || '').trim();
return { provider, base_url: baseUrl, api_key: apiKey };
}
return {
provider: document.getElementById('openai-provider')?.value || 'openai',
base_url: (document.getElementById('openai-base-url')?.value || '').trim(),
api_key: (document.getElementById('openai-api-key')?.value || '').trim()
};
}
function syncModelListFetchButtons() {
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
const openaiProv = document.getElementById('openai-provider')?.value || 'openai';
const openaiBtn = document.getElementById('fetch-openai-models-btn');
const openaiHint = document.getElementById('fetch-openai-models-hint');
const openaiSelect = document.getElementById('openai-model-select');
const isClaudeOpenai = openaiProv === 'claude';
if (openaiBtn) {
openaiBtn.style.display = isClaudeOpenai ? 'none' : '';
}
if (openaiSelect && isClaudeOpenai) {
openaiSelect.style.display = 'none';
const openaiWrap = modelPickSelectMap['openai-model-select'];
if (openaiWrap) openaiWrap.wrapper.style.display = 'none';
} else if (openaiSelect && !isClaudeOpenai) {
syncModelPickDropdown('openai-model-select');
}
if (openaiHint) {
if (isClaudeOpenai) {
openaiHint.textContent = tFn('settingsBasic.modelsListClaudeHint');
openaiHint.style.display = '';
} else {
openaiHint.textContent = '';
openaiHint.style.display = 'none';
}
}
const vp = (document.getElementById('vision-provider')?.value || '').trim();
const visionEffectiveProv = vp || openaiProv;
const visionBtn = document.getElementById('fetch-vision-models-btn');
const visionHint = document.getElementById('fetch-vision-models-hint');
const visionSelect = document.getElementById('vision-model-select');
const isClaudeVision = visionEffectiveProv === 'claude';
if (visionBtn) {
visionBtn.style.display = isClaudeVision ? 'none' : '';
}
if (visionSelect && isClaudeVision) {
visionSelect.style.display = 'none';
const visionWrap = modelPickSelectMap['vision-model-select'];
if (visionWrap) visionWrap.wrapper.style.display = 'none';
} else if (visionSelect && !isClaudeVision) {
syncModelPickDropdown('vision-model-select');
}
if (visionHint) {
if (isClaudeVision) {
visionHint.textContent = tFn('settingsBasic.modelsListClaudeHint');
visionHint.style.display = '';
} else {
visionHint.textContent = '';
visionHint.style.display = 'none';
}
}
}
function populateModelSelect(scope, models, currentValue) {
const { selectId, inputId } = modelSelectIds(scope);
const select = document.getElementById(selectId);
const input = document.getElementById(inputId);
if (!select) return;
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
select.innerHTML = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.disabled = true;
placeholder.textContent = tFn('settingsBasic.modelsListSelectPlaceholder');
select.appendChild(placeholder);
const seen = new Set();
const addOption = (id) => {
const val = (id || '').trim();
if (!val || seen.has(val)) return;
seen.add(val);
const opt = document.createElement('option');
opt.value = val;
opt.textContent = val;
select.appendChild(opt);
};
(models || []).forEach(addOption);
const cur = (currentValue || (input && input.value) || '').trim();
if (cur && seen.has(cur)) {
select.value = cur;
} else {
select.value = '';
}
enhanceModelPickSelect(selectId);
syncModelPickDropdown(selectId);
}
async function fetchModelList(scope) {
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
const creds = resolveModelListCredentials(scope);
const btnId = scope === 'vision' ? 'fetch-vision-models-btn' : 'fetch-openai-models-btn';
const resultId = scope === 'vision' ? 'fetch-vision-models-result' : 'fetch-openai-models-result';
const inputId = scope === 'vision' ? 'vision-model' : 'openai-model';
const btn = document.getElementById(btnId);
const resultEl = document.getElementById(resultId);
const inputEl = document.getElementById(inputId);
if (creds.provider === 'claude') {
if (resultEl) {
resultEl.textContent = tFn('settingsBasic.modelsListClaudeHint');
resultEl.style.color = 'var(--text-muted, #718096)';
}
return;
}
if (!creds.api_key) {
if (resultEl) {
resultEl.textContent = tFn('settingsBasic.modelsListNeedApiKey');
resultEl.style.color = 'var(--error-color, #e53e3e)';
}
return;
}
if (btn) {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.5';
}
if (resultEl) {
resultEl.textContent = tFn('settingsBasic.modelsListFetching');
resultEl.style.color = 'var(--text-muted, #718096)';
}
try {
const response = await apiFetch('/api/config/list-models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(creds)
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || '请求失败');
}
if (!result.success) {
if (resultEl) {
resultEl.textContent = (result.supported === false
? tFn('settingsBasic.modelsListClaudeHint')
: tFn('settingsBasic.modelsListFailed')) + ': ' + (result.error || '');
resultEl.style.color = 'var(--error-color, #e53e3e)';
}
return;
}
const currentValue = inputEl ? inputEl.value.trim() : '';
populateModelSelect(scope, result.models || [], currentValue);
if (resultEl) {
const count = result.count != null ? result.count : (result.models || []).length;
resultEl.textContent = tFn('settingsBasic.modelsListSuccess').replace('{count}', String(count));
resultEl.style.color = 'var(--success-color, #38a169)';
}
} catch (error) {
if (resultEl) {
resultEl.textContent = tFn('settingsBasic.modelsListFailed') + ': ' + error.message;
resultEl.style.color = 'var(--error-color, #e53e3e)';
}
} finally {
if (btn) {
btn.style.pointerEvents = '';
btn.style.opacity = '';
}
}
}
+30 -26
View File
@@ -83,6 +83,21 @@ function batchQueueAllowsSubtaskMutation(queue) {
return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled';
}
/** 是否允许对指定子任务发起单条执行(与后端 queueAllowsSingleTaskRunLocked 对齐) */
function batchQueueCanRunSingleTask(queue, task) {
if (!queue || !task) return false;
if (task.status === 'running') return false;
if (queue.status === 'running') return false;
return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled';
}
function batchQueueRunSingleTaskDisabledReason(queue, task) {
if (!queue || !task) return _t('tasks.runSingleTaskUnavailable');
if (task.status === 'running') return _t('tasks.runSingleTaskUnavailableSelf');
if (queue.status === 'running') return _t('tasks.runSingleTaskUnavailableQueue');
return _t('tasks.runSingleTaskUnavailable');
}
// HTML转义函数(如果未定义)
if (typeof escapeHtml === 'undefined') {
function escapeHtml(text) {
@@ -1497,6 +1512,8 @@ async function showBatchQueueDetail(queueId) {
${queue.tasks.map((task, index) => {
const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' };
const canEdit = allowSubtaskMutation && task.status !== 'running';
const canRunSingle = batchQueueCanRunSingleTask(queue, task);
const runSingleUnavailableTitle = escapeHtml(batchQueueRunSingleTaskDisabledReason(queue, task));
const taskMessageEscaped = escapeHtml(task.message).replace(/'/g, "&#39;").replace(/"/g, "&quot;").replace(/\n/g, "\\n");
return `
<div class="batch-task-item ${task.status === 'running' ? 'batch-task-item-active' : ''}" data-queue-id="${queue.id}" data-task-id="${task.id}" data-task-message="${taskMessageEscaped}">
@@ -1504,10 +1521,10 @@ async function showBatchQueueDetail(queueId) {
<span class="batch-task-index">#${index + 1}</span>
<span class="batch-task-status ${taskStatus.class}">${taskStatus.text}</span>
<span class="batch-task-message" title="${escapeHtml(task.message)}">${escapeHtml(task.message)}</span>
<button class="btn-secondary btn-small batch-task-run-btn" ${canRunSingle ? `onclick="runSingleBatchTask('${queue.id}', '${task.id}'); event.stopPropagation();"` : `disabled title="${runSingleUnavailableTitle}"`}>` + _t('tasks.runSingleTask') + `</button>
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.edit') + `</button>` : ''}
${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.delete') + `</button>` : ''}
${allowSubtaskMutation && task.status === 'failed' ? `<button class="btn-secondary btn-small" onclick="retryBatchTask('${queue.id}', '${task.id}'); event.stopPropagation();">` + _t('tasks.retryTask') + `</button>` : ''}
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
</div>
${task.startedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}</div>` : ''}
${task.completedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.completeLabel') + `: ${new Date(task.completedAt).toLocaleString()}</div>` : ''}
@@ -2270,38 +2287,25 @@ async function saveInlineAgentMode() {
}
}
// --- 重试失败任务 ---
async function retryBatchTask(queueId, taskId) {
// --- 单条执行 ---
async function runSingleBatchTask(queueId, taskId) {
if (!queueId || !taskId) return;
if (!confirm(_t('tasks.confirmRunSingleTask'))) return;
try {
// 获取任务消息
const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`);
if (!detailResp.ok) throw new Error(_t('tasks.getQueueDetailFailed'));
const detail = await detailResp.json();
const task = detail.queue.tasks.find(t => t.id === taskId);
if (!task) throw new Error(_t('tasks.taskNotFound') || 'Task not found');
const message = task.message;
// 先添加新任务(pending),再删除旧任务 — 避免先删后加失败导致任务丢失
const addResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks`, {
const response = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
if (!addResp.ok) {
const r = await addResp.json().catch(() => ({}));
throw new Error(r.error || _t('tasks.addTaskFailed'));
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || _t('tasks.runSingleTaskFailed'));
}
// 新任务添加成功后才删除旧任务
const delResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}`, { method: 'DELETE' });
if (!delResp.ok) {
// 删除失败不阻塞(新任务已添加,旧任务保留也不影响)
console.warn('删除旧任务失败,但新任务已添加');
if (result.autoStarted === false && result.message) {
alert(result.message);
}
showBatchQueueDetail(queueId);
refreshBatchQueues();
} catch (e) {
console.error('重试任务失败:', e);
console.error('单条执行失败:', e);
alert(e.message);
}
}
@@ -2437,7 +2441,7 @@ window.startInlineEditRole = startInlineEditRole;
window.saveInlineRole = saveInlineRole;
window.startInlineEditAgentMode = startInlineEditAgentMode;
window.saveInlineAgentMode = saveInlineAgentMode;
window.retryBatchTask = retryBatchTask;
window.runSingleBatchTask = runSingleBatchTask;
window.startInlineEditSchedule = startInlineEditSchedule;
window.toggleInlineScheduleCron = toggleInlineScheduleCron;
window.saveInlineSchedule = saveInlineSchedule;
+4
View File
@@ -1989,6 +1989,10 @@ function buildWebshellTimelineItemFromDetail(detail) {
// 渲染「执行过程及调用工具」折叠块(默认折叠,刷新后加载历史时保留并可展开)
function renderWebshellProcessDetailsBlock(processDetails, defaultCollapsed) {
if (!processDetails || processDetails.length === 0) return null;
if (typeof window.filterNoiseProcessDetails === 'function') {
processDetails = window.filterNoiseProcessDetails(processDetails);
}
if (!processDetails.length) return null;
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
}
+167 -14
View File
@@ -808,16 +808,49 @@
<div class="recent-conversations-section">
<div class="section-header">
<span class="section-title" data-i18n="chat.recentConversations">最近对话</span>
<button class="batch-manage-btn" onclick="showBatchManageModal()" data-i18n="chat.batchManage" data-i18n-attr="title" data-i18n-skip-text="true" title="批量管理">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="3" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="3" y1="18" x2="21" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="8" cy="6" r="1" fill="currentColor"/>
<circle cx="8" cy="12" r="1" fill="currentColor"/>
<circle cx="8" cy="18" r="1" fill="currentColor"/>
</svg>
</button>
<div class="section-header-actions">
<div class="conversation-sort-dropdown" id="conversation-sort-dropdown">
<button type="button" class="conversation-sort-btn" id="conversation-sort-btn" onclick="toggleConversationSortMenu(event)" aria-haspopup="menu" aria-expanded="false" aria-controls="conversation-sort-menu" data-i18n="chat.sortConversations" data-i18n-attr="title" data-i18n-skip-text="true" title="排序">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M3 6h18M7 12h10M10 18h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<div class="conversation-sort-menu" id="conversation-sort-menu" role="menu" hidden>
<button type="button" class="conversation-sort-option" role="menuitemradio" data-sort="created_at" onclick="setConversationSortBy('created_at')">
<span class="conversation-sort-option-icon" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="5" width="18" height="16" rx="2.5" stroke="currentColor" stroke-width="1.75"/>
<path d="M3 10h18" stroke="currentColor" stroke-width="1.75"/>
<path d="M8 3v3M16 3v3" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
<circle cx="12" cy="15" r="1.75" fill="currentColor"/>
</svg>
</span>
<span class="conversation-sort-option-label" data-i18n="chat.sortByCreatedAt">创建时间</span>
<span class="conversation-sort-option-check" aria-hidden="true"></span>
</button>
<button type="button" class="conversation-sort-option" role="menuitemradio" data-sort="updated_at" onclick="setConversationSortBy('updated_at')">
<span class="conversation-sort-option-icon" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="8" stroke="currentColor" stroke-width="1.75"/>
<path d="M12 8v4.5l3 2" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="conversation-sort-option-label" data-i18n="chat.sortByUpdatedAt">更新时间</span>
<span class="conversation-sort-option-check" aria-hidden="true"></span>
</button>
</div>
</div>
<button class="batch-manage-btn" onclick="showBatchManageModal()" data-i18n="chat.batchManage" data-i18n-attr="title" data-i18n-skip-text="true" title="批量管理">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="3" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="3" y1="18" x2="21" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="8" cy="6" r="1" fill="currentColor"/>
<circle cx="8" cy="12" r="1" fill="currentColor"/>
<circle cx="8" cy="18" r="1" fill="currentColor"/>
</svg>
</button>
</div>
</div>
<div id="conversations-list" class="conversations-list"></div>
</div>
@@ -1465,6 +1498,13 @@
</aside>
<main class="projects-detail" id="projects-detail-main">
<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>
<p data-i18n="projects.selectOrCreateHint">项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。</p>
<button class="btn-primary" type="button" onclick="showNewProjectModal()" data-i18n="projects.createFirstProject">创建第一个项目</button>
@@ -1494,6 +1534,7 @@
</header>
<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-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-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>
@@ -1554,11 +1595,96 @@
</div>
<div class="projects-table-wrap">
<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>
</table>
</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 class="projects-panel-toolbar projects-panel-toolbar--hint">
<p class="projects-fact-toolbar-hint" role="note">
@@ -2035,12 +2161,17 @@
<div class="page-header">
<h2 data-i18n="c2.sessions.title">会话管理</h2>
<div class="page-header-actions">
<button type="button" class="btn-danger" id="c2-sessions-batch-delete" disabled onclick="C2.deleteSelectedSessions()"><span data-i18n="c2.sessions.batchDelete">批量删除</span></button>
<button type="button" class="btn-secondary" id="c2-sessions-delete-filtered" disabled onclick="C2.deleteFilteredSessions()"><span data-i18n="c2.sessions.deleteFiltered">删除筛选结果</span></button>
<button class="btn-secondary" onclick="C2.loadSessions()"><span data-i18n="common.refresh">刷新</span></button>
</div>
</div>
<div class="page-content" style="padding:0;">
<div class="c2-session-layout">
<div id="c2-session-list" class="c2-session-sidebar"></div>
<div class="c2-session-sidebar-wrap">
<div id="c2-session-toolbar" class="c2-sessions-toolbar"></div>
<div id="c2-session-list" class="c2-session-sidebar"></div>
</div>
<div id="c2-session-main" class="c2-session-main"></div>
</div>
</div>
@@ -2408,7 +2539,15 @@
</div>
<div class="form-group">
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span style="color: red;">*</span></label>
<input type="text" id="openai-model" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
<div class="model-pick-row">
<input type="text" id="openai-model" class="model-pick-input" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
<select id="openai-model-select" class="model-pick-native" style="display: none;" title="" aria-hidden="true" tabindex="-1">
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
</select>
<a href="javascript:void(0)" id="fetch-openai-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('openai')" data-i18n="settingsBasic.fetchModels">获取列表</a>
</div>
<small id="fetch-openai-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small>
<span id="fetch-openai-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span>
</div>
<div class="form-group">
<label for="openai-max-total-tokens"><span data-i18n="settingsBasic.maxTotalTokens">最大上下文 Token 数</span></label>
@@ -2486,7 +2625,15 @@
</div>
<div class="form-group">
<label for="vision-model"><span data-i18n="settingsBasic.visionModel">视觉模型</span> <span style="color: red;">*</span></label>
<input type="text" id="vision-model" data-i18n="settingsBasic.visionModelPlaceholder" data-i18n-attr="placeholder" placeholder="qwen-vl-max" />
<div class="model-pick-row">
<input type="text" id="vision-model" class="model-pick-input" data-i18n="settingsBasic.visionModelPlaceholder" data-i18n-attr="placeholder" placeholder="qwen-vl-max" />
<select id="vision-model-select" class="model-pick-native" style="display: none;" aria-hidden="true" tabindex="-1">
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
</select>
<a href="javascript:void(0)" id="fetch-vision-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('vision')" data-i18n="settingsBasic.fetchModels">获取列表</a>
</div>
<small id="fetch-vision-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small>
<span id="fetch-vision-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span>
</div>
<details style="margin-top: 8px;">
<summary style="cursor: pointer; font-size: 0.875rem; color: var(--accent-color, #3182ce);" data-i18n="settingsBasic.visionAdvanced">高级:预处理与限制</summary>
@@ -4285,6 +4432,11 @@
<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">
</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&#10;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(来源 → 当前事实)。常用 typediscovered_on、depends_on、leads_to、enables、exploits。保存时替换全部关系边。</p>
</div>
</div>
<div class="projects-modal-footer">
<button class="btn-secondary" type="button" onclick="closeFactModal()" data-i18n="common.cancel">取消</button>
@@ -4342,6 +4494,7 @@
<script src="/static/js/terminal.js"></script>
<script src="/static/js/knowledge.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/vulnerability.js?v=12"></script>
<script src="/static/js/webshell.js"></script>