mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-21 14:52:05 +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
+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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user