mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-20 22:41:23 +02:00
fix: mask stored credentials and validate agent saves
This commit is contained in:
1 parent
4a5b177947
commit
76da98944c
18 files changed
+780
-22
No files matched your search
@@ -24,6 +24,10 @@
|
||||
| 同 UUID 旧连接断开误判离线 | WS defer 无条件删当前连接及标记 dead | 仅当前登记连接可以删除并退役会话,结束心跳 goroutine |
|
||||
| 23 WebShell 目录路径及假条目 | normalizeWebshellPath 删除根斜杠;权限兜底正则接受 ls 错误行 | 保留 Unix 绝对路径;树/面包屑/父级保持绝对路径;只接受权限格式目录项 |
|
||||
| 6 查看通知未清计数 | 查看回调只跳转 | 查看普通事件后标记已读并刷新;待审批通知仍保留待处理状态 |
|
||||
| 13 配置接口明文返回 API Key | GetConfig 序列化真实配置 | 返回掩码;保存时按原配置路径还原;模型测试在服务端解析指定通道凭据 |
|
||||
| 19 WebShell 口令明文回显 | 列表和创建/更新响应返回 Password | 响应掩码;保存时保留/替换/清空;执行按有权限的 connection_id 取服务端凭据 |
|
||||
| Markdown 智能体字段与重复 ID | 保存缺少关键字段和 ID 唯一性检查 | 创建/更新统一校验 name、ID、指令;ID 不允许文件间重复;创建使用排他文件写入 |
|
||||
| 辅助文件编辑描述丢失 | 辅助文件保存接口不更新 SKILL.md 元数据 | 辅助文件禁用描述编辑;描述未保存时阻止切换文件,提示先保存 SKILL.md |
|
||||
|
||||
## 不适用
|
||||
|
||||
@@ -40,3 +44,5 @@
|
||||
- 前端 HITL 断言问题在原始提交 82b0af1 的隔离导出目录也可复现,属于基线问题。
|
||||
- `go test ./...` 全仓通过。
|
||||
- WebShell 路径、错误输出、目录树测试与通知查看/待审批区分测试通过。
|
||||
- 配置凭据处理保留既有字段与空字符串清除语义;掩码标记只用于已保存的同路径凭据,新通道不能直接保存掩码。通道探活新增可选 `channel_id`,模型列表/审查测试支持可选凭据范围,避免同 URL 多密钥选择错误。
|
||||
- 新增 GET 配置不泄漏/不变更内存、掩码保留/替换/清空、歧义拒绝、WebShell 回显及执行凭据、Markdown 创建/更新唯一性、技能描述切换测试。
|
||||
@@ -0,0 +1,68 @@
|
||||
# Bug 表逐项状态
|
||||
|
||||
使用 Excel 行号作为定位键,包含未编号条目。状态只表示已有证据,不把未复现当作不存在。
|
||||
|
||||
| Excel 行 | 序号 | 问题摘要 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 2 | 1 | 对话页面显示错乱了,按钮和文字位置全乱了, | 待核对 |
|
||||
| 3 | 2 | 登录时没填密码也能提交,但提示信息和样式可以在优化下 | 待核对 |
|
||||
| 4 | 3 | 登录密码是明文传输的,容易被截获有安全风险,建议参考TDP的加密方案处理 | 待核对 |
|
||||
| 5 | 4 | 登录页用户名字段未做必填校验,提交时 username 字段值为空字符串,应增加非空验证 | 已修复并有针对性验证 |
|
||||
| 6 | 5 | 修改密码,只有红框提示,没有具体错误原因 | 待核对 |
|
||||
| 7 | 6 | 通知,点击查看后跳转到页面,通知的计数还在,只有点了已读在不在。 | 已修复并有针对性验证 |
|
||||
| 8 | 7 | 新增资产,自动识别出来的不是中文呢,好像是时区的问题。 | 待核对 |
|
||||
| 9 | 8 | 接上一个,改成中国后,省份还是拼音 | 待核对 |
|
||||
| 10 | 9 | 资产导出的 语言问题,也是英文 | 待核对 |
|
||||
| 11 | 10 | 项目管理,添加事实提示词优化,表单里是没有summary | 待核对 |
|
||||
| 12 | 11 | 样式问题 | 待核对 |
|
||||
| 13 | 12 | 样式问题 | 待核对 |
|
||||
| 14 | 13 | 明文回显 API Key | 已修复并有针对性验证 |
|
||||
| 15 | 14 | 点击查看事实后,弹窗无事实,然后就跳转到事实页面了,觉得留在本页面合适 | 待核对 |
|
||||
| 16 | 15 | 不同的项目保存漏洞时能有去重逻辑吗,待确认。隔离好像也没啥问题 | 待核对 |
|
||||
| 17 | 16 | 拦截规则已经停用了,但是还是被拦截了。可能刚停用还没生效,然后被拦截,(是因为停用之后 未保存只 | 待核对 |
|
||||
| 18 | 17 | 样式问题 | 待核对 |
|
||||
| 19 | 18 | 用户并发配额耗尽时任务静默排队,界面无提示 | 当前仓库未实现,不扩展能力 |
|
||||
| 20 | — | 量任务已经执行结束,任务管理页也将队列标记为“已完成 100%”,但该任务创建的容器仍长期保持“ | 当前仓库未实现,不扩展能力 |
|
||||
| 21 | 19 | webshell 连接口令明文 | 已修复并有针对性验证 |
|
||||
| 22 | 20 | 不知道这个是啥,应该是样式问题 | 待核对 |
|
||||
| 23 | 21 | 创建监听器 必填字段不清楚,交互上不知道是哪个必填 | 待核对 |
|
||||
| 24 | 22 | 已经取消的任务,结束之后标签会变成成功,好像没有取消成功 | C2 终态已修复,其他任务路径待核对 |
|
||||
| 25 | 23 | 【WebShell 文件管理】目录树用相对路径导致点进去必报错,且报错文本被解析成假文件条目步骤 | 已修复并有针对性验证 |
|
||||
| 26 | 24 | 工作流 id 由客户端随便填、零校验 | 待核对 |
|
||||
| 27 | 25 | 样式问题 | 待核对 |
|
||||
| 28 | 26 | 导入相同的工作流id的包,还能点确认,并且按钮显示导入完成。拦截的交互可以优化 | 待核对 |
|
||||
| 29 | 27 | Agent 节点 16 分 43 秒 / 118 次工具调用,全程零进度、零超时 | 待核对 |
|
||||
| 30 | 28 | 工具节点在会话容器内执行 nmap 默认参数必失败 | 当前仓库未实现,不扩展能力 |
|
||||
| 31 | 29 | 参数模板未解析被原样下发,流程「成功」但结论与事实相反 | 待核对 |
|
||||
| 32 | 30 | 角色名零校验:含 /、.. 的名字能创建,但之后删不掉 | 已修复并有针对性验证 |
|
||||
| 33 | 31 | 角色名可以加个 长度限制,否则展示有问题 | 已修复并有针对性验证 |
|
||||
| 34 | 32 | 批量任务里只要指令不调用任何工具(例如「请只回复:OK」),子任务就必然失败并回「缺少执行证据」 | 已修复并有针对性验证 |
|
||||
| 35 | — | Markdown 智能体:同文件名静默覆盖、关键字段不校验、name 含路径字符被静默改写 | 已修复并有针对性验证 |
|
||||
| 36 | — | 任务结果不校验上报者归属 —— 任意植入体可伪造别的会话、甚至别的监听器的任务结果。 | 待核对 |
|
||||
| 37 | — | 100s 状态不变、无 error、无 completed_at,会话早已离线。 | 待核对 |
|
||||
| 38 | — | 在「工作流」里删掉一个正被角色引用的工作流 → 删除成功且没有任何提示;之后任何人用这个角色发起 | 已修复并有针对性验证 |
|
||||
| 39 | — | 同一 implant uuid 重复上线互踢,旧连接断开把会话判死 | 已修复并有针对性验证 |
|
||||
| 40 | — | 无权限用户仍能看到“新建角色”“添加成员”等管理操作按钮;页面同时提示“权限不足”。应在前端按权 | 待核对 |
|
||||
| 41 | — | 点击“并发数”当前值进行编辑。 | 待核对 |
|
||||
| 42 | — | 设置页清空“项目名称”后点击保存,页面提示“已保存”,但名称被静默恢复成原值。 | 待核对 |
|
||||
| 43 | — | 样式问题 | 待核对 |
|
||||
| 44 | — | 输入 目标地址 时,前端正确解析 IP/端口/协议,但提交提示“域名格式无效”,无法创建。 | 待核对 |
|
||||
| 45 | — | 无工具节点的工作流成功完成后,被误判为缺少执行证据 | 待核对 |
|
||||
| 46 | — | Agent ID 可重复- 两个不同文件可使用同一个 ID。 | 已修复并有针对性验证 |
|
||||
| 47 | — | nmap 参数缺少前置校验 | 待核对 |
|
||||
| 48 | — | 对单个资产发起“只读连通性与服务指纹核验”后,Agent 已通过 nmap 和 HTTP 指纹工 | 待核对 |
|
||||
| 49 | — | C2 会话终端执行没有换行结尾的命令时,系统内部用于获取当前目录的 pwd 输出会与用户命令输出 | 待核对 |
|
||||
| 50 | — | 监听器编辑后点击保存没有反应 | 待核对 |
|
||||
| 51 | — | 创建 Malleable Profile 时 UA、响应头和 Jitter 配置被静默丢弃 | 已修复并有针对性验证 |
|
||||
| 52 | — | C2 载荷生成未校验回连地址,非法地址仍能生成 Oneliner | 待核对 |
|
||||
| 53 | — | 知识库索引构建失败原因不怎么太清晰 | 待核对 |
|
||||
| 54 | — | Agent 未挂载知识检索工具 | 待核对 |
|
||||
| 55 | — | - 选择其专用测试会话,打开「终端」。 | 待核对 |
|
||||
| 56 | — | AI看代码给提的问题 | 已修复并有针对性验证 |
|
||||
| 57 | — | 审计日志搜索无结果后,点击“清空”不会清除关键词或恢复列表,无反应 | 待核对 |
|
||||
| 58 | — | 任务失败,也显示已完成 | 待核对 |
|
||||
| 59 | — | 批量任务在“用户并发配额已满”时进入等待调度后,即使后来停止容器、腾出配额,队列仍停在“执行中 | 当前仓库未实现,不扩展能力 |
|
||||
| 60 | — | 资产扫描任务在后续工具调用的 HITL 审批阶段已被系统中断,审批卡片明确显示“任务已中断、审批 | 待核对 |
|
||||
| 61 | — | 问题:事实正文里的 discovered_on: target/xxx 只会当普通文本保存,不会 | 待核对 |
|
||||
| 62 | — | 无效工作流仍显示“启用”: 即使工作流校验不通过、无法试运行,列表和详情仍显示为“启用”,容易让 | 待核对 |
|
||||
| 63 | — | 编辑辅助文件时,描述修改未保存 | 已修复并有针对性验证 |
|
||||
+60
-13
@@ -372,7 +372,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, GetConfigResponse{
|
||||
response, err := maskedConfigResponse(GetConfigResponse{
|
||||
AI: h.config.AI,
|
||||
OpenAI: h.config.OpenAI,
|
||||
Vision: h.config.Vision,
|
||||
@@ -389,6 +389,11 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
Robots: h.config.Robots,
|
||||
MultiAgent: multiPub,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "配置序列化失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetToolsResponse 获取工具列表响应(分页)
|
||||
@@ -796,6 +801,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err := restoreConfigRequestSecrets(&req, h.config); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新OpenAI配置
|
||||
if req.AI != nil {
|
||||
@@ -1252,10 +1261,12 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
|
||||
// TestOpenAIRequest 测试OpenAI连接请求
|
||||
type TestOpenAIRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
CredentialScope string `json:"credential_scope,omitempty"`
|
||||
ChannelID string `json:"channel_id,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// TestOpenAI 测试OpenAI API连接是否可用
|
||||
@@ -1266,6 +1277,12 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
resolvedKey, resolveErr := h.resolveProbeSecret(req.APIKey, req.ChannelID, req.BaseURL, req.CredentialScope)
|
||||
if resolveErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
|
||||
return
|
||||
}
|
||||
req.APIKey = resolvedKey
|
||||
if strings.TrimSpace(req.APIKey) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空"})
|
||||
return
|
||||
@@ -1383,9 +1400,11 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
||||
|
||||
// TestTypeSafeRequest 测试 TypeSafe / Jev 连接。
|
||||
type TestTypeSafeRequest struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
CredentialScope string `json:"credential_scope,omitempty"`
|
||||
ChannelID string `json:"channel_id,omitempty"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// TestTypeSafe 用一条最小 Noul 验证 TypeSafe System One 是否可用。
|
||||
@@ -1395,6 +1414,12 @@ func (h *ConfigHandler) TestTypeSafe(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
resolvedKey, resolveErr := h.resolveProbeSecret(req.APIKey, req.ChannelID, req.BaseURL, req.CredentialScope)
|
||||
if resolveErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
|
||||
return
|
||||
}
|
||||
req.APIKey = resolvedKey
|
||||
if strings.TrimSpace(req.APIKey) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "TypeSafe API Key 不能为空"})
|
||||
return
|
||||
@@ -1438,9 +1463,11 @@ func (h *ConfigHandler) TestTypeSafe(c *gin.Context) {
|
||||
|
||||
// ListModelsRequest 获取模型列表请求(OpenAI 兼容 GET /models)。
|
||||
type ListModelsRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
CredentialScope string `json:"credential_scope,omitempty"`
|
||||
ChannelID string `json:"channel_id,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
// ListModels 代理调用上游 GET /models,返回可用模型 id 列表。
|
||||
@@ -1451,6 +1478,12 @@ func (h *ConfigHandler) ListModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
resolvedKey, resolveErr := h.resolveProbeSecret(req.APIKey, req.ChannelID, req.BaseURL, req.CredentialScope)
|
||||
if resolveErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
|
||||
return
|
||||
}
|
||||
req.APIKey = resolvedKey
|
||||
provider := strings.TrimSpace(req.Provider)
|
||||
if provider == "" {
|
||||
provider = "openai"
|
||||
@@ -1512,8 +1545,9 @@ func (h *ConfigHandler) ListModels(c *gin.Context) {
|
||||
|
||||
// TestVisionRequest 测试 Vision 模型连接;vision.api_key/base_url 留空时可传 openai 段作回退。
|
||||
type TestVisionRequest struct {
|
||||
Vision config.VisionConfig `json:"vision"`
|
||||
OpenAI config.OpenAIConfig `json:"openai,omitempty"`
|
||||
ChannelID string `json:"channel_id,omitempty"`
|
||||
Vision config.VisionConfig `json:"vision"`
|
||||
OpenAI config.OpenAIConfig `json:"openai,omitempty"`
|
||||
}
|
||||
|
||||
// TestVision 测试视觉模型 API 连接(最小 chat completion)。
|
||||
@@ -1523,6 +1557,19 @@ func (h *ConfigHandler) TestVision(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
mainKey, mainErr := h.resolveProbeSecret(req.OpenAI.APIKey, req.ChannelID, req.OpenAI.BaseURL, "openai")
|
||||
if mainErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": mainErr.Error()})
|
||||
return
|
||||
}
|
||||
req.OpenAI.APIKey = mainKey
|
||||
h.mu.RLock()
|
||||
restoreErr := restoreConfigRequestSecrets(&req, h.config)
|
||||
h.mu.RUnlock()
|
||||
if restoreErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": restoreErr.Error()})
|
||||
return
|
||||
}
|
||||
oa := req.Vision.OpenAICfgEffective(req.OpenAI)
|
||||
if strings.TrimSpace(oa.APIKey) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空(可填写 vision.api_key 或 openai.api_key)"})
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
const maskedSecret = "********"
|
||||
|
||||
func secretConfigKey(key string) bool {
|
||||
switch strings.ToLower(key) {
|
||||
case "api_key", "apikey", "password", "secret", "client_secret", "app_secret", "token", "bot_token", "app_token", "verify_token", "encoding_aes_key", "authorization", "x-api-key":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func configSecretSnapshot(cfg *config.Config) GetConfigResponse {
|
||||
return GetConfigResponse{AI: cfg.AI, OpenAI: cfg.OpenAI, Vision: cfg.Vision, FOFA: cfg.FOFA,
|
||||
ZoomEye: cfg.ZoomEye, Quake: cfg.Quake, Shodan: cfg.Shodan, MCP: cfg.MCP,
|
||||
Hitl: cfg.Hitl, Knowledge: cfg.Knowledge, Robots: cfg.Robots}
|
||||
}
|
||||
|
||||
func jsonSecretTree(value interface{}) (interface{}, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tree interface{}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
err = decoder.Decode(&tree)
|
||||
return tree, err
|
||||
}
|
||||
|
||||
// transformSecretTree masks a detached response or restores a submitted marker
|
||||
// from the same configuration path. It never changes the live configuration.
|
||||
func transformSecretTree(tree, current interface{}, restoring bool) error {
|
||||
switch node := tree.(type) {
|
||||
case map[string]interface{}:
|
||||
previous, _ := current.(map[string]interface{})
|
||||
for key, value := range node {
|
||||
text, isString := value.(string)
|
||||
if secretConfigKey(key) && isString {
|
||||
if !restoring && text != "" {
|
||||
node[key] = maskedSecret
|
||||
}
|
||||
if restoring && text == maskedSecret {
|
||||
stored, ok := previous[key].(string)
|
||||
if !ok || stored == "" {
|
||||
return fmt.Errorf("字段 %s 没有已保存凭据,请填写新的值", key)
|
||||
}
|
||||
node[key] = stored
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := transformSecretTree(value, previous[key], restoring); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
previous, _ := current.([]interface{})
|
||||
for i, value := range node {
|
||||
var stored interface{}
|
||||
if i < len(previous) {
|
||||
stored = previous[i]
|
||||
}
|
||||
// Named entries can be reordered without moving secrets between them.
|
||||
if entry, ok := value.(map[string]interface{}); ok {
|
||||
for _, identity := range []string{"id", "name"} {
|
||||
if id, ok := entry[identity].(string); ok && id != "" {
|
||||
stored = nil
|
||||
for _, candidate := range previous {
|
||||
if old, ok := candidate.(map[string]interface{}); ok && old[identity] == id {
|
||||
stored = old
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := transformSecretTree(value, stored, restoring); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maskedConfigResponse(response GetConfigResponse) (interface{}, error) {
|
||||
tree, err := jsonSecretTree(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = transformSecretTree(tree, nil, false)
|
||||
return tree, err
|
||||
}
|
||||
|
||||
func restoreConfigRequestSecrets(request interface{}, cfg *config.Config) error {
|
||||
update, isUpdate := request.(*UpdateConfigRequest)
|
||||
mainKeyMasked := isUpdate && update.OpenAI != nil && update.OpenAI.APIKey == maskedSecret
|
||||
tree, err := jsonSecretTree(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous, err := jsonSecretTree(configSecretSnapshot(cfg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := transformSecretTree(tree, previous, true); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(tree)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(raw, request); err != nil {
|
||||
return err
|
||||
}
|
||||
if mainKeyMasked && update.AI != nil {
|
||||
id := config.NormalizeAIChannelID(update.AI.DefaultChannel)
|
||||
if channel, ok := update.AI.Channels[id]; ok {
|
||||
update.OpenAI.APIKey = channel.APIKey
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) resolveProbeSecret(key, channelID, baseURL, scope string) (string, error) {
|
||||
if key != maskedSecret {
|
||||
return key, nil
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
if channelID != "" {
|
||||
if ch, ok := h.config.AI.Channels[channelID]; ok && ch.APIKey != "" {
|
||||
return ch.APIKey, nil
|
||||
}
|
||||
return "", fmt.Errorf("通道没有已保存凭据,请填写 API Key")
|
||||
}
|
||||
var scoped string
|
||||
switch scope {
|
||||
case "vision":
|
||||
scoped = h.config.Vision.APIKey
|
||||
case "hitlAudit":
|
||||
scoped = h.config.Hitl.AuditModel.APIKey
|
||||
case "knowledgeEmbedding":
|
||||
scoped = h.config.Knowledge.Embedding.APIKey
|
||||
case "openai":
|
||||
scoped = h.config.OpenAI.APIKey
|
||||
case "":
|
||||
default:
|
||||
return "", fmt.Errorf("未知凭据范围")
|
||||
}
|
||||
if scope != "" {
|
||||
if scoped == "" {
|
||||
scoped = h.config.OpenAI.APIKey
|
||||
}
|
||||
if scoped != "" {
|
||||
return scoped, nil
|
||||
}
|
||||
return "", fmt.Errorf("没有已保存的 API Key")
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
add := func(url, secret string) {
|
||||
if strings.TrimRight(url, "/") == strings.TrimRight(baseURL, "/") && secret != "" {
|
||||
keys[secret] = true
|
||||
}
|
||||
}
|
||||
add(h.config.OpenAI.BaseURL, h.config.OpenAI.APIKey)
|
||||
for _, ch := range h.config.AI.Channels {
|
||||
add(ch.BaseURL, ch.APIKey)
|
||||
}
|
||||
add(h.config.Hitl.AuditModel.BaseURL, h.config.Hitl.AuditModel.APIKey)
|
||||
add(h.config.Vision.BaseURL, h.config.Vision.APIKey)
|
||||
add(h.config.Knowledge.Embedding.BaseURL, h.config.Knowledge.Embedding.APIKey)
|
||||
add(h.config.Knowledge.Retrieval.Rerank.BaseURL, h.config.Knowledge.Retrieval.Rerank.APIKey)
|
||||
if len(keys) == 1 {
|
||||
for secret := range keys {
|
||||
return secret, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("无法唯一确定已保存凭据,请选择通道或填写 API Key")
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestConfigResponseMasksSecretsAndSavePreservesThem(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
OpenAI: config.OpenAIConfig{APIKey: "test-only-main", BaseURL: "http://127.0.0.1", Model: "test"},
|
||||
AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
|
||||
"first": {Name: "first", APIKey: "test-only-first", BaseURL: "http://127.0.0.1"},
|
||||
"second": {Name: "second", APIKey: "test-only-second", BaseURL: "http://127.0.0.1"},
|
||||
}},
|
||||
Robots: config.RobotsConfig{Wecom: config.RobotWecomConfig{Token: "test-only-token", Secret: "test-only-secret", EncodingAESKey: "test-only-aes"}},
|
||||
}
|
||||
h := &ConfigHandler{config: cfg, logger: zap.NewNop()}
|
||||
r := gin.New()
|
||||
r.GET("/config", h.GetConfig)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/config", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "test-only-") {
|
||||
t.Fatal("response contains a saved secret")
|
||||
}
|
||||
if cfg.OpenAI.APIKey != "test-only-main" || cfg.AI.Channels["first"].APIKey != "test-only-first" {
|
||||
t.Fatal("GET mutated live secrets")
|
||||
}
|
||||
var req UpdateConfigRequest
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restoreConfigRequestSecrets(&req, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.OpenAI.APIKey != cfg.OpenAI.APIKey || req.AI.Channels["second"].APIKey != cfg.AI.Channels["second"].APIKey || req.Robots.Wecom.Secret != cfg.Robots.Wecom.Secret {
|
||||
t.Fatal("unchanged save lost secret")
|
||||
}
|
||||
req.OpenAI.APIKey = "test-only-replacement"
|
||||
if err := restoreConfigRequestSecrets(&req, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.OpenAI.APIKey != "test-only-replacement" {
|
||||
t.Fatal("explicit replacement was ignored")
|
||||
}
|
||||
req.OpenAI.APIKey = ""
|
||||
if err := restoreConfigRequestSecrets(&req, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.OpenAI.APIKey != "" {
|
||||
t.Fatal("explicit clearing was ignored")
|
||||
}
|
||||
// Changing the default channel must not copy the old default's secret into it.
|
||||
req.OpenAI.APIKey = maskedSecret
|
||||
req.AI.DefaultChannel = "second"
|
||||
if err := restoreConfigRequestSecrets(&req, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.OpenAI.APIKey != "test-only-second" {
|
||||
t.Fatal("default channel change selected the old main key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelListUsesSavedChannelSecretWithoutEcho(t *testing.T) {
|
||||
var requests int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
if r.Header.Get("Authorization") != "Bearer test-only-upstream" {
|
||||
t.Error("masked key reached upstream")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"test-model"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
h := &ConfigHandler{config: &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{"saved": {APIKey: "test-only-upstream", BaseURL: srv.URL}}}}}
|
||||
r := gin.New()
|
||||
r.POST("/models", h.ListModels)
|
||||
w := httptest.NewRecorder()
|
||||
raw, _ := json.Marshal(ListModelsRequest{BaseURL: srv.URL, APIKey: maskedSecret, ChannelID: "saved"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/models", bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK || requests != 1 || strings.Contains(w.Body.String(), "test-only-upstream") {
|
||||
t.Fatalf("model request: %d requests=%d", w.Code, requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskedProbeUsesSelectedChannelAndRejectsAmbiguousKey(t *testing.T) {
|
||||
h := &ConfigHandler{config: &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
|
||||
"one": {APIKey: "test-one", BaseURL: "http://127.0.0.1"},
|
||||
"two": {APIKey: "test-two", BaseURL: "http://127.0.0.1"},
|
||||
}}}}
|
||||
if key, err := h.resolveProbeSecret(maskedSecret, "two", "http://127.0.0.1", ""); err != nil || key != "test-two" {
|
||||
t.Fatalf("selected resolution failed: %v", err)
|
||||
}
|
||||
if _, err := h.resolveProbeSecret(maskedSecret, "", "http://127.0.0.1", ""); err == nil {
|
||||
t.Fatal("ambiguous key was selected")
|
||||
}
|
||||
if _, err := h.resolveProbeSecret(maskedSecret, "missing", "http://127.0.0.1", ""); err == nil {
|
||||
t.Fatal("missing channel accepted")
|
||||
}
|
||||
if key, err := h.resolveProbeSecret("test-new", "missing", "http://127.0.0.1", ""); err != nil || key != "test-new" {
|
||||
t.Fatal("new credential rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigMaskCannotBeSavedAsNewChannelCredential(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
req := UpdateConfigRequest{AI: &config.AIConfig{Channels: map[string]config.AIChannelConfig{"new": {APIKey: maskedSecret}}}}
|
||||
if restoreConfigRequestSecrets(&req, cfg) == nil {
|
||||
t.Fatal("mask accepted for new channel")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/agents"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var markdownAgentIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
|
||||
|
||||
func (h *MarkdownAgentsHandler) validateMarkdownWrite(filename string, content []byte) error {
|
||||
front, _, err := agents.SplitFrontMatter(string(content))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var fm agents.FrontMatter
|
||||
if err := yaml.Unmarshal([]byte(front), &fm); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(fm.Name) == "" {
|
||||
return fmt.Errorf("智能体 name 不能为空")
|
||||
}
|
||||
if fm.ID != "" && !markdownAgentIDPattern.MatchString(fm.ID) {
|
||||
return fmt.Errorf("Agent ID 格式无效")
|
||||
}
|
||||
sub, err := agents.ParseMarkdownSubAgent(filename, string(content))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !markdownAgentIDPattern.MatchString(sub.ID) {
|
||||
return fmt.Errorf("Agent ID 必须为 1–64 个字母、数字、连字符或下划线,且以字母或数字开头")
|
||||
}
|
||||
if err := validateRoleName(sub.Name); err != nil {
|
||||
return fmt.Errorf("智能体名称无效: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(sub.Instruction) == "" {
|
||||
return fmt.Errorf("智能体指令不能为空")
|
||||
}
|
||||
if sub.MaxIterations < 0 {
|
||||
return fmt.Errorf("最大迭代数不能为负数")
|
||||
}
|
||||
files, err := agents.LoadMarkdownAgentFiles(h.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, file := range files {
|
||||
if filepath.Base(file.Filename) != filepath.Base(filename) && file.Config.ID == sub.ID {
|
||||
return fmt.Errorf("Agent ID 已被文件 %s 使用", file.Filename)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestMarkdownAgentValidationAndIDUniqueness(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
h := NewMarkdownAgentsHandler(dir)
|
||||
r := gin.New()
|
||||
r.POST("/agents", h.CreateMarkdownAgent)
|
||||
r.PUT("/agents/:filename", h.UpdateMarkdownAgent)
|
||||
send := func(method, path string, body markdownAgentBody) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
good := markdownAgentBody{Filename: "first.md", ID: "unique", Name: "valid", Instruction: "Reply OK"}
|
||||
if w := send(http.MethodPost, "/agents", good); w.Code != http.StatusOK {
|
||||
t.Fatalf("create: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
original, _ := os.ReadFile(filepath.Join(dir, good.Filename))
|
||||
duplicateFile := good
|
||||
duplicateFile.Instruction = "overwrite"
|
||||
if w := send(http.MethodPost, "/agents", duplicateFile); w.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate file: %d", w.Code)
|
||||
}
|
||||
duplicateID := good
|
||||
duplicateID.Filename = "second.md"
|
||||
if w := send(http.MethodPost, "/agents", duplicateID); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("duplicate ID: %d", w.Code)
|
||||
}
|
||||
after, _ := os.ReadFile(filepath.Join(dir, good.Filename))
|
||||
if !bytes.Equal(original, after) {
|
||||
t.Fatal("rejected create overwrote existing file")
|
||||
}
|
||||
if w := send(http.MethodPut, "/agents/first.md", good); w.Code != http.StatusOK {
|
||||
t.Fatalf("self update: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := send(http.MethodPut, "/agents/missing.md", good); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("created missing update: %d", w.Code)
|
||||
}
|
||||
for _, body := range []markdownAgentBody{
|
||||
{Filename: "bad.md", ID: "bad", Name: "bad/name", Instruction: "text"},
|
||||
{Filename: "bad.md", ID: "bad/id", Name: "valid", Instruction: "text"},
|
||||
{Filename: "bad.md", ID: "bad", Name: "valid"},
|
||||
{Filename: "bad.md", ID: "bad", Name: "valid", Raw: "---\nname: valid\nid: raw/id\n---\ntext"},
|
||||
{Filename: "bad.md", ID: "bad", Name: "valid", Raw: "---\nid: raw\n---\ntext"},
|
||||
} {
|
||||
if w := send(http.MethodPost, "/agents", body); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid %#v: %d %s", body, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
other := good
|
||||
other.Filename, other.ID = "second.md", "second"
|
||||
if w := send(http.MethodPost, "/agents", other); w.Code != http.StatusOK {
|
||||
t.Fatalf("other create: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
other.ID = "unique"
|
||||
if w := send(http.MethodPut, "/agents/second.md", other); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("duplicate update: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"cyberstrike-ai/internal/agents"
|
||||
"cyberstrike-ai/internal/audit"
|
||||
@@ -19,8 +20,9 @@ var markdownAgentFilenameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*\.m
|
||||
|
||||
// MarkdownAgentsHandler 管理 agents 目录下子代理 Markdown(增删改查)。
|
||||
type MarkdownAgentsHandler struct {
|
||||
dir string
|
||||
audit *audit.Service
|
||||
dir string
|
||||
audit *audit.Service
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewMarkdownAgentsHandler dir 须为已解析的绝对路径。
|
||||
@@ -152,6 +154,8 @@ type markdownAgentBody struct {
|
||||
|
||||
// CreateMarkdownAgent POST /api/multi-agent/markdown-agents
|
||||
func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) {
|
||||
h.writeMu.Lock()
|
||||
defer h.writeMu.Unlock()
|
||||
if h.dir == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未配置 agents 目录"})
|
||||
return
|
||||
@@ -215,6 +219,10 @@ func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.validateMarkdownWrite(filepath.Base(path), out); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if want := agents.WantsMarkdownOrchestrator(filepath.Base(path), body.Kind, string(out)); want {
|
||||
other, oerr := existingOtherOrchestrator(h.dir, filepath.Base(path))
|
||||
if oerr != nil {
|
||||
@@ -230,10 +238,22 @@ func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(path, out, 0644); err != nil {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "文件已存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, writeErr := f.Write(out)
|
||||
closeErr := f.Close()
|
||||
if writeErr != nil || closeErr != nil {
|
||||
_ = os.Remove(path)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "写入智能体文件失败"})
|
||||
return
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "agent", "markdown_create", "创建 Markdown 子代理", "markdown_agent", filepath.Base(path), nil)
|
||||
}
|
||||
@@ -242,12 +262,18 @@ func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) {
|
||||
|
||||
// UpdateMarkdownAgent PUT /api/multi-agent/markdown-agents/:filename
|
||||
func (h *MarkdownAgentsHandler) UpdateMarkdownAgent(c *gin.Context) {
|
||||
h.writeMu.Lock()
|
||||
defer h.writeMu.Unlock()
|
||||
filename := c.Param("filename")
|
||||
path, err := h.safeJoin(filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
var body markdownAgentBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -285,6 +311,10 @@ func (h *MarkdownAgentsHandler) UpdateMarkdownAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.validateMarkdownWrite(filename, out); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if want := agents.WantsMarkdownOrchestrator(filename, body.Kind, string(out)); want {
|
||||
other, oerr := existingOtherOrchestrator(h.dir, filename)
|
||||
if oerr != nil {
|
||||
|
||||
@@ -391,6 +391,11 @@ func (h *WebShellHandler) ListConnections(c *gin.Context) {
|
||||
if list == nil {
|
||||
list = []database.WebShellConnection{}
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].Password != "" {
|
||||
list[i].Password = maskedSecret
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
@@ -457,7 +462,7 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
|
||||
"host": host, "type": shellType,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, conn)
|
||||
c.JSON(http.StatusOK, publicWebshellConnection(conn))
|
||||
}
|
||||
|
||||
// UpdateConnection 更新 WebShell 连接(PUT /api/webshell/connections/:id)
|
||||
@@ -498,11 +503,20 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
|
||||
if shellType == "" {
|
||||
shellType = "php"
|
||||
}
|
||||
password := strings.TrimSpace(req.Password)
|
||||
if password == maskedSecret {
|
||||
stored, ok := h.authorizedWebshellConnection(c, id, "")
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该连接"})
|
||||
return
|
||||
}
|
||||
password = stored.Password
|
||||
}
|
||||
conn := &database.WebShellConnection{
|
||||
ID: id,
|
||||
ProjectID: projectID,
|
||||
URL: req.URL,
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
Password: password,
|
||||
Type: shellType,
|
||||
Method: method,
|
||||
CmdParam: strings.TrimSpace(req.CmdParam),
|
||||
@@ -520,9 +534,9 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
|
||||
}
|
||||
updated, _ := h.db.GetWebshellConnection(id)
|
||||
if updated != nil {
|
||||
c.JSON(http.StatusOK, updated)
|
||||
c.JSON(http.StatusOK, publicWebshellConnection(updated))
|
||||
} else {
|
||||
c.JSON(http.StatusOK, conn)
|
||||
c.JSON(http.StatusOK, publicWebshellConnection(conn))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,6 +765,10 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
if req.Password == maskedSecret {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请使用已保存连接或填写新的连接口令"})
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(req.URL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package handler
|
||||
|
||||
import "cyberstrike-ai/internal/database"
|
||||
|
||||
func publicWebshellConnection(conn *database.WebShellConnection) *database.WebShellConnection {
|
||||
if conn == nil {
|
||||
return nil
|
||||
}
|
||||
public := *conn
|
||||
if public.Password != "" {
|
||||
public.Password = maskedSecret
|
||||
}
|
||||
return &public
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestWebshellMasksResponsesAndPreservesStoredPassword(t *testing.T) {
|
||||
db, user, allowed, _ := setupWebshellRBACTest(t)
|
||||
allowed.Password = "test-only-webshell-password"
|
||||
if err := db.UpdateWebshellConnection(allowed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := NewWebShellHandler(zap.NewNop(), db)
|
||||
w := performWebshellJSON(user, http.MethodGet, "/connections", nil, h.ListConnections)
|
||||
if w.Code != http.StatusOK || strings.Contains(w.Body.String(), allowed.Password) || !strings.Contains(w.Body.String(), maskedSecret) {
|
||||
t.Fatalf("unsafe list: %d", w.Code)
|
||||
}
|
||||
update := func(c *gin.Context) { c.Params = gin.Params{{Key: "id", Value: allowed.ID}}; h.UpdateConnection(c) }
|
||||
for _, password := range []string{maskedSecret, "test-only-new-password", ""} {
|
||||
w = performWebshellJSON(user, http.MethodPut, "/connections/"+allowed.ID, map[string]interface{}{"url": allowed.URL, "type": "php", "method": "post", "password": password, "remark": "updated"}, update)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "test-only-") {
|
||||
t.Fatal("update returned secret")
|
||||
}
|
||||
saved, err := db.GetWebshellConnection(allowed.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := password
|
||||
if password == maskedSecret {
|
||||
expected = allowed.Password
|
||||
}
|
||||
if saved.Password != expected {
|
||||
t.Fatal("password was not preserved/replaced/cleared as requested")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebshellExecUsesStoredPasswordForMaskedConnection(t *testing.T) {
|
||||
db, user, allowed, _ := setupWebshellRBACTest(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.Form.Get("pass") != "test-only-stored" {
|
||||
t.Error("executor did not use stored password")
|
||||
}
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
allowed.URL, allowed.Password = srv.URL, "test-only-stored"
|
||||
if err := db.UpdateWebshellConnection(allowed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := NewWebShellHandler(zap.NewNop(), db)
|
||||
w := performWebshellJSON(user, http.MethodPost, "/exec", map[string]interface{}{"url": allowed.URL, "connection_id": allowed.ID, "password": maskedSecret, "command": "printf OK"}, h.Exec)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("saved exec: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = performWebshellJSON(user, http.MethodPost, "/exec", map[string]interface{}{"url": allowed.URL, "password": maskedSecret, "command": "printf OK"}, h.Exec)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("ad hoc marker accepted: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -3495,6 +3495,7 @@
|
||||
"newFilePathInvalid": "Invalid path (no .. or absolute paths)",
|
||||
"noPackageFiles": "No files listed",
|
||||
"unsavedSwitch": "You have unsaved changes. Switch file anyway?",
|
||||
"saveDescriptionFirst": "Save the description in SKILL.md before switching to another file",
|
||||
"contentLabel": "Content",
|
||||
"contentPlaceholder": "Edit the selected file…",
|
||||
"contentPlaceholderAdd": "SKILL.md body only (front matter is generated)…",
|
||||
|
||||
@@ -3508,6 +3508,7 @@
|
||||
"newFilePathInvalid": "Недопустимый путь (без .. и абсолютных путей)",
|
||||
"noPackageFiles": "Файлы не указаны",
|
||||
"unsavedSwitch": "Есть несохранённые изменения. Всё равно переключить файл?",
|
||||
"saveDescriptionFirst": "Сохраните описание в SKILL.md перед переходом к другому файлу",
|
||||
"contentLabel": "Содержимое",
|
||||
"contentPlaceholder": "Измените выбранный файл…",
|
||||
"contentPlaceholderAdd": "Только тело SKILL.md (front matter создаётся автоматически)…",
|
||||
|
||||
@@ -3483,6 +3483,7 @@
|
||||
"newFilePathInvalid": "路径无效(禁止 .. 与绝对路径)",
|
||||
"noPackageFiles": "未列出文件",
|
||||
"unsavedSwitch": "当前文件有未保存修改,确定切换?",
|
||||
"saveDescriptionFirst": "请先在 SKILL.md 中保存描述,再切换到辅助文件",
|
||||
"contentLabel": "内容",
|
||||
"contentPlaceholder": "编辑当前选中的文件…",
|
||||
"contentPlaceholderAdd": "SKILL.md 正文(无需手写 YAML 头)…",
|
||||
|
||||
@@ -3199,6 +3199,7 @@ async function probeSelectedAIChannels() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider: ch.provider || 'openai_compatible',
|
||||
channel_id: id,
|
||||
base_url: ch.base_url || '',
|
||||
api_key: ch.api_key || '',
|
||||
model: ch.model || ''
|
||||
@@ -3527,6 +3528,12 @@ function populateModelSelect(scope, models, currentValue) {
|
||||
async function fetchModelList(scope) {
|
||||
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
const creds = resolveModelListCredentials(scope);
|
||||
creds.credential_scope = scope;
|
||||
if (scope === 'openai') creds.channel_id = selectedAIChannelId;
|
||||
const keyInputByScope = {vision: 'vision-api-key', hitlAudit: 'hitl-audit-model-api-key', knowledgeEmbedding: 'knowledge-embedding-api-key'};
|
||||
if (keyInputByScope[scope] && !document.getElementById(keyInputByScope[scope])?.value.trim()) {
|
||||
creds.channel_id = selectedAIChannelId;
|
||||
}
|
||||
const modelListUiIds = {
|
||||
openai: {
|
||||
btnId: 'fetch-openai-models-btn',
|
||||
@@ -3641,7 +3648,7 @@ async function testVisionConnection() {
|
||||
const response = await apiFetch('/api/config/test-vision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vision: vision, openai: openai })
|
||||
body: JSON.stringify({ vision: vision, openai: openai, channel_id: selectedAIChannelId })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
@@ -3763,6 +3770,8 @@ async function testHitlAuditModelConnection() {
|
||||
const payload = typeSafe
|
||||
? { base_url: baseUrl, api_key: apiKey, model: model }
|
||||
: cfg;
|
||||
payload.credential_scope = 'hitlAudit';
|
||||
if (!document.getElementById('hitl-audit-model-api-key')?.value.trim()) payload.channel_id = selectedAIChannelId;
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -3886,6 +3895,7 @@ async function testOpenAIConnection() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider: provider,
|
||||
channel_id: channelId,
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
model: model
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = fs.readFileSync('web/static/js/skills.js', 'utf8');
|
||||
const start = source.indexOf('async function selectSkillPackageFile(');
|
||||
const end = source.indexOf('// 编辑skill', start);
|
||||
function harness(dirty) {
|
||||
const inputs = {'skill-description': {disabled:false}, 'skill-content': {value:''}};
|
||||
let calls = 0;
|
||||
const ctx = {
|
||||
skillDescriptionDirty:dirty, skillFileDirty:false, skillActivePath:'SKILL.md',
|
||||
document:{getElementById:id => inputs[id] || null},
|
||||
_t:x => x, showNotification() {}, renderSkillPackageTree() {}, confirm:() => true,
|
||||
apiFetch:async () => {calls++; return {ok:true,json:async () => ({content:'text',skill:{content:'body'}})};},
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(source.slice(start,end),ctx);
|
||||
return {ctx,inputs,calls:() => calls};
|
||||
}
|
||||
test('auxiliary file editing disables description and returning to SKILL.md enables it', async () => {
|
||||
const {ctx,inputs} = harness(false);
|
||||
await ctx.selectSkillPackageFile('test','references/example.md',{});
|
||||
assert.equal(inputs['skill-description'].disabled,true);
|
||||
await ctx.selectSkillPackageFile('test','SKILL.md',{});
|
||||
assert.equal(inputs['skill-description'].disabled,false);
|
||||
});
|
||||
test('unsaved description cannot disappear when switching files', async () => {
|
||||
const h = harness(true);
|
||||
await h.ctx.selectSkillPackageFile('test','references/example.md',{});
|
||||
assert.equal(h.ctx.skillActivePath,'SKILL.md');
|
||||
assert.equal(h.calls(),0);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ let currentEditingSkillName = null;
|
||||
let skillModalAddMode = true;
|
||||
let skillActivePath = 'SKILL.md';
|
||||
let skillFileDirty = false;
|
||||
let skillDescriptionDirty = false;
|
||||
let skillPackageFiles = [];
|
||||
let skillModalControlsWired = false;
|
||||
let isSavingSkill = false; // 防止重复提交
|
||||
@@ -454,6 +455,8 @@ function wireSkillModalOnce() {
|
||||
skillModalControlsWired = true;
|
||||
const addTa = document.getElementById('skill-content-add');
|
||||
const edTa = document.getElementById('skill-content');
|
||||
const descriptionInput = document.getElementById('skill-description');
|
||||
if (descriptionInput) descriptionInput.addEventListener('input', () => { skillDescriptionDirty = true; });
|
||||
if (addTa) addTa.addEventListener('input', () => { if (skillModalAddMode) skillFileDirty = true; });
|
||||
if (edTa) edTa.addEventListener('input', () => { if (!skillModalAddMode) skillFileDirty = true; });
|
||||
const nb = document.getElementById('skill-new-file-btn');
|
||||
@@ -483,6 +486,8 @@ function showAddSkillModal() {
|
||||
if (!modal) return;
|
||||
|
||||
skillModalAddMode = true;
|
||||
skillDescriptionDirty = false;
|
||||
document.getElementById('skill-description').disabled = false;
|
||||
skillFileDirty = false;
|
||||
skillActivePath = 'SKILL.md';
|
||||
skillPackageFiles = [];
|
||||
@@ -544,12 +549,18 @@ async function selectSkillPackageFile(skillId, path, opts) {
|
||||
const freshContent = opts && Object.prototype.hasOwnProperty.call(opts, 'freshContent')
|
||||
? opts.freshContent
|
||||
: null;
|
||||
if (path !== 'SKILL.md' && skillDescriptionDirty) {
|
||||
showNotification(_t('skillModal.saveDescriptionFirst'), 'error');
|
||||
return;
|
||||
}
|
||||
if (!force && skillFileDirty) {
|
||||
if (!confirm(_t('skillModal.unsavedSwitch'))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
skillActivePath = path;
|
||||
const descriptionInput = document.getElementById('skill-description');
|
||||
if (descriptionInput) descriptionInput.disabled = path !== 'SKILL.md';
|
||||
const label = document.getElementById('skill-active-path');
|
||||
if (label) label.textContent = path;
|
||||
const hint = document.getElementById('skill-body-hint-edit');
|
||||
@@ -591,6 +602,8 @@ async function editSkill(skillId) {
|
||||
const modal = document.getElementById('skill-modal');
|
||||
if (!modal) return;
|
||||
skillModalAddMode = false;
|
||||
skillDescriptionDirty = false;
|
||||
document.getElementById('skill-description').disabled = false;
|
||||
skillFileDirty = false;
|
||||
skillActivePath = 'SKILL.md';
|
||||
const pkg = document.getElementById('skill-package-editor');
|
||||
@@ -739,6 +752,8 @@ function closeSkillModal() {
|
||||
currentEditingSkillName = null;
|
||||
skillModalAddMode = true;
|
||||
skillFileDirty = false;
|
||||
skillDescriptionDirty = false;
|
||||
document.getElementById('skill-description').disabled = false;
|
||||
skillPackageFiles = [];
|
||||
skillActivePath = 'SKILL.md';
|
||||
}
|
||||
@@ -833,6 +848,7 @@ async function saveSkill() {
|
||||
}
|
||||
|
||||
skillFileDirty = false;
|
||||
if (path === 'SKILL.md') skillDescriptionDirty = false;
|
||||
showNotification(_t('skills.saveSuccess'), 'success');
|
||||
const filesRes = await apiFetch(`/api/skills/${encodeURIComponent(currentEditingSkillName)}/files`);
|
||||
if (filesRes.ok) {
|
||||
|
||||
@@ -1244,7 +1244,8 @@ function probeWebshellConnection(conn) {
|
||||
encoding: webshellConnEncoding(conn),
|
||||
os: webshellConnOS(conn),
|
||||
connection_id: conn.id || '',
|
||||
command: buildWebshellProbeCommand(probeToken)
|
||||
command: buildWebshellProbeCommand(probeToken),
|
||||
connection_id: password === '********' ? (document.getElementById('webshell-edit-id')?.value || '') : ''
|
||||
})
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
|
||||
Reference in new issue
Block a user