From 90d09e63e55c586aa7a531f770bea9a3ed28834c Mon Sep 17 00:00:00 2001 From: Lawrence Liu Date: Sat, 8 Nov 2025 19:33:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E8=84=B1=E6=95=8F=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=97=A5=E5=BF=97=E4=B8=AD=E7=9A=84=E6=95=8F=E6=84=9F?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=20(#761)=20##=20=E9=97=AE=E9=A2=98=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=97=A5=E5=BF=97=E5=9C=A8=E6=89=93=E5=8D=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=9B=B4=E6=96=B0=E6=97=B6=E4=BC=9A=E6=9A=B4?= =?UTF-8?q?=E9=9C=B2=E5=AE=8C=E6=95=B4=E7=9A=84=20API=20Key=E3=80=81Secret?= =?UTF-8?q?=20Key=20=E5=92=8C=E7=A7=81=E9=92=A5=E7=AD=89=E6=95=8F=E6=84=9F?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=EF=BC=88Issue=20#758=EF=BC=89=E3=80=82=20##?= =?UTF-8?q?=20=E8=A7=A3=E5=86=B3=E6=96=B9=E6=A1=88=20###=201.=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=84=B1=E6=95=8F=E5=B7=A5=E5=85=B7=E5=BA=93=20(api/u?= =?UTF-8?q?tils.go)=20-=20`MaskSensitiveString()`:=20=E8=84=B1=E6=95=8F?= =?UTF-8?q?=E6=95=8F=E6=84=9F=E5=AD=97=E7=AC=A6=E4=B8=B2=EF=BC=88=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=89=8D4=E4=BD=8D=E5=92=8C=E5=90=8E4=E4=BD=8D?= =?UTF-8?q?=EF=BC=8C=E4=B8=AD=E9=97=B4=E7=94=A8****=E6=9B=BF=E4=BB=A3?= =?UTF-8?q?=EF=BC=89=20-=20`SanitizeModelConfigForLog()`:=20=E8=84=B1?= =?UTF-8?q?=E6=95=8F=20AI=20=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE=E7=94=A8?= =?UTF-8?q?=E4=BA=8E=E6=97=A5=E5=BF=97=E8=BE=93=E5=87=BA=20-=20`SanitizeEx?= =?UTF-8?q?changeConfigForLog()`:=20=E8=84=B1=E6=95=8F=E4=BA=A4=E6=98=93?= =?UTF-8?q?=E6=89=80=E9=85=8D=E7=BD=AE=E7=94=A8=E4=BA=8E=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=BE=93=E5=87=BA=20-=20`MaskEmail()`:=20=E8=84=B1=E6=95=8F?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E5=9C=B0=E5=9D=80=20###=202.=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=97=A5=E5=BF=97=E6=89=93=E5=8D=B0=20(api/server.go)?= =?UTF-8?q?=20-=20Line=201106:=20=E8=84=B1=E6=95=8F=20AI=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=85=8D=E7=BD=AE=E6=9B=B4=E6=96=B0=E6=97=A5=E5=BF=97?= =?UTF-8?q?=20-=20Line=201203:=20=E8=84=B1=E6=95=8F=E4=BA=A4=E6=98=93?= =?UTF-8?q?=E6=89=80=E9=85=8D=E7=BD=AE=E6=9B=B4=E6=96=B0=E6=97=A5=E5=BF=97?= =?UTF-8?q?=20###=203.=20=E5=AE=8C=E5=96=84=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20(api/utils=5Ftest.go)=20-=204=E4=B8=AA=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=87=BD=E6=95=B0=EF=BC=8C9=E4=B8=AA=E5=AD=90?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=8C=E5=85=A8=E9=83=A8=E9=80=9A=E8=BF=87?= =?UTF-8?q?=20-=20=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=8E=87:=2091%+=20##=20=E8=84=B1=E6=95=8F?= =?UTF-8?q?=E6=95=88=E6=9E=9C=E7=A4=BA=E4=BE=8B=20**=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=89=8D**:=20```=20=E2=9C=93=20=E4=BA=A4=E6=98=93=E6=89=80?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=B7=B2=E6=9B=B4=E6=96=B0:=20map[binance:{a?= =?UTF-8?q?pi=5Fkey:sk-1234567890abcdef=20secret=5Fkey:binance=5Fsecret=5F?= =?UTF-8?q?1234567890abcdef}]=20```=20**=E4=BF=AE=E5=A4=8D=E5=90=8E**:=20`?= =?UTF-8?q?``=20=E2=9C=93=20=E4=BA=A4=E6=98=93=E6=89=80=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=B7=B2=E6=9B=B4=E6=96=B0:=20map[binance:{api=5Fkey:sk-1****c?= =?UTF-8?q?def=20secret=5Fkey:bina****cdef}]=20```=20##=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=BB=93=E6=9E=9C=20```=20PASS=20ok=20=20=09nofx/api?= =?UTF-8?q?=090.012s=20coverage:=2091.2%=20of=20statements=20in=20utils.go?= =?UTF-8?q?=20```=20##=20=E5=AE=89=E5=85=A8=E5=BD=B1=E5=93=8D=20-=20?= =?UTF-8?q?=E9=98=B2=E6=AD=A2=E6=97=A5=E5=BF=97=E6=B3=84=E9=9C=B2=20API=20?= =?UTF-8?q?Key=E3=80=81Secret=20Key=E3=80=81=E7=A7=81=E9=92=A5=E7=AD=89?= =?UTF-8?q?=E6=95=8F=E6=84=9F=E4=BF=A1=E6=81=AF=20-=20=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E9=9A=90=E7=A7=81=E5=92=8C=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E5=AE=89=E5=85=A8=20-=20=E7=AC=A6=E5=90=88=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=9C=80=E4=BD=B3=E5=AE=9E=E8=B7=B5=20Closes=20#758?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server.go | 4 +- api/utils.go | 97 +++++++++++++++++++++++ api/utils_test.go | 193 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 api/utils.go create mode 100644 api/utils_test.go diff --git a/api/server.go b/api/server.go index 01c2c0ae..e98db44a 100644 --- a/api/server.go +++ b/api/server.go @@ -1077,7 +1077,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) { // 这里不返回错误,因为模型配置已经成功更新到数据库 } - log.Printf("✓ AI模型配置已更新: %+v", req.Models) + log.Printf("✓ AI模型配置已更新: %+v", SanitizeModelConfigForLog(req.Models)) c.JSON(http.StatusOK, gin.H{"message": "模型配置已更新"}) } @@ -1174,7 +1174,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { // 这里不返回错误,因为交易所配置已经成功更新到数据库 } - log.Printf("✓ 交易所配置已更新: %+v", req.Exchanges) + log.Printf("✓ 交易所配置已更新: %+v", SanitizeExchangeConfigForLog(req.Exchanges)) c.JSON(http.StatusOK, gin.H{"message": "交易所配置已更新"}) } diff --git a/api/utils.go b/api/utils.go new file mode 100644 index 00000000..4f871ef0 --- /dev/null +++ b/api/utils.go @@ -0,0 +1,97 @@ +package api + +import "strings" + +// MaskSensitiveString 脱敏敏感字符串,只显示前4位和后4位 +// 用于脱敏 API Key、Secret Key、Private Key 等敏感信息 +func MaskSensitiveString(s string) string { + if s == "" { + return "" + } + length := len(s) + if length <= 8 { + return "****" // 字符串太短,全部隐藏 + } + return s[:4] + "****" + s[length-4:] +} + +// SanitizeModelConfigForLog 脱敏模型配置用于日志输出 +func SanitizeModelConfigForLog(models map[string]struct { + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + CustomAPIURL string `json:"custom_api_url"` + CustomModelName string `json:"custom_model_name"` +}) map[string]interface{} { + safe := make(map[string]interface{}) + for modelID, cfg := range models { + safe[modelID] = map[string]interface{}{ + "enabled": cfg.Enabled, + "api_key": MaskSensitiveString(cfg.APIKey), + "custom_api_url": cfg.CustomAPIURL, + "custom_model_name": cfg.CustomModelName, + } + } + return safe +} + +// SanitizeExchangeConfigForLog 脱敏交易所配置用于日志输出 +func SanitizeExchangeConfigForLog(exchanges map[string]struct { + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + SecretKey string `json:"secret_key"` + Testnet bool `json:"testnet"` + HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` + AsterUser string `json:"aster_user"` + AsterSigner string `json:"aster_signer"` + AsterPrivateKey string `json:"aster_private_key"` +}) map[string]interface{} { + safe := make(map[string]interface{}) + for exchangeID, cfg := range exchanges { + safeExchange := map[string]interface{}{ + "enabled": cfg.Enabled, + "testnet": cfg.Testnet, + } + + // 只在有值时才添加脱敏后的敏感字段 + if cfg.APIKey != "" { + safeExchange["api_key"] = MaskSensitiveString(cfg.APIKey) + } + if cfg.SecretKey != "" { + safeExchange["secret_key"] = MaskSensitiveString(cfg.SecretKey) + } + if cfg.AsterPrivateKey != "" { + safeExchange["aster_private_key"] = MaskSensitiveString(cfg.AsterPrivateKey) + } + + // 非敏感字段直接添加 + if cfg.HyperliquidWalletAddr != "" { + safeExchange["hyperliquid_wallet_addr"] = cfg.HyperliquidWalletAddr + } + if cfg.AsterUser != "" { + safeExchange["aster_user"] = cfg.AsterUser + } + if cfg.AsterSigner != "" { + safeExchange["aster_signer"] = cfg.AsterSigner + } + + safe[exchangeID] = safeExchange + } + return safe +} + +// MaskEmail 脱敏邮箱地址,保留前2位和@后部分 +func MaskEmail(email string) string { + if email == "" { + return "" + } + parts := strings.Split(email, "@") + if len(parts) != 2 { + return "****" // 格式不正确 + } + username := parts[0] + domain := parts[1] + if len(username) <= 2 { + return "**@" + domain + } + return username[:2] + "****@" + domain +} diff --git a/api/utils_test.go b/api/utils_test.go new file mode 100644 index 00000000..fb4976ff --- /dev/null +++ b/api/utils_test.go @@ -0,0 +1,193 @@ +package api + +import ( + "testing" +) + +func TestMaskSensitiveString(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "空字符串", + input: "", + expected: "", + }, + { + name: "短字符串(小于等于8位)", + input: "short", + expected: "****", + }, + { + name: "正常API key", + input: "sk-1234567890abcdefghijklmnopqrstuvwxyz", + expected: "sk-1****wxyz", + }, + { + name: "正常私钥", + input: "0x1234567890abcdef1234567890abcdef12345678", + expected: "0x12****5678", + }, + { + name: "刚好9位", + input: "123456789", + expected: "1234****6789", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MaskSensitiveString(tt.input) + if result != tt.expected { + t.Errorf("MaskSensitiveString(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestSanitizeModelConfigForLog(t *testing.T) { + models := map[string]struct { + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + CustomAPIURL string `json:"custom_api_url"` + CustomModelName string `json:"custom_model_name"` + }{ + "deepseek": { + Enabled: true, + APIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyz", + CustomAPIURL: "https://api.deepseek.com", + CustomModelName: "deepseek-chat", + }, + } + + result := SanitizeModelConfigForLog(models) + + deepseekConfig, ok := result["deepseek"].(map[string]interface{}) + if !ok { + t.Fatal("deepseek config not found or wrong type") + } + + if deepseekConfig["enabled"] != true { + t.Errorf("expected enabled=true, got %v", deepseekConfig["enabled"]) + } + + maskedKey, ok := deepseekConfig["api_key"].(string) + if !ok { + t.Fatal("api_key not found or wrong type") + } + + if maskedKey != "sk-1****wxyz" { + t.Errorf("expected masked api_key='sk-1****wxyz', got %q", maskedKey) + } + + if deepseekConfig["custom_api_url"] != "https://api.deepseek.com" { + t.Errorf("custom_api_url should not be masked") + } +} + +func TestSanitizeExchangeConfigForLog(t *testing.T) { + exchanges := map[string]struct { + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + SecretKey string `json:"secret_key"` + Testnet bool `json:"testnet"` + HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` + AsterUser string `json:"aster_user"` + AsterSigner string `json:"aster_signer"` + AsterPrivateKey string `json:"aster_private_key"` + }{ + "binance": { + Enabled: true, + APIKey: "binance_api_key_1234567890abcdef", + SecretKey: "binance_secret_key_1234567890abcdef", + Testnet: false, + }, + "hyperliquid": { + Enabled: true, + HyperliquidWalletAddr: "0x1234567890abcdef1234567890abcdef12345678", + Testnet: false, + }, + } + + result := SanitizeExchangeConfigForLog(exchanges) + + // 检查币安配置 + binanceConfig, ok := result["binance"].(map[string]interface{}) + if !ok { + t.Fatal("binance config not found or wrong type") + } + + maskedAPIKey, ok := binanceConfig["api_key"].(string) + if !ok { + t.Fatal("binance api_key not found or wrong type") + } + + if maskedAPIKey != "bina****cdef" { + t.Errorf("expected masked api_key='bina****cdef', got %q", maskedAPIKey) + } + + maskedSecretKey, ok := binanceConfig["secret_key"].(string) + if !ok { + t.Fatal("binance secret_key not found or wrong type") + } + + if maskedSecretKey != "bina****cdef" { + t.Errorf("expected masked secret_key='bina****cdef', got %q", maskedSecretKey) + } + + // 检查 Hyperliquid 配置 + hlConfig, ok := result["hyperliquid"].(map[string]interface{}) + if !ok { + t.Fatal("hyperliquid config not found or wrong type") + } + + walletAddr, ok := hlConfig["hyperliquid_wallet_addr"].(string) + if !ok { + t.Fatal("hyperliquid_wallet_addr not found or wrong type") + } + + // 钱包地址不应该被脱敏 + if walletAddr != "0x1234567890abcdef1234567890abcdef12345678" { + t.Errorf("wallet address should not be masked, got %q", walletAddr) + } +} + +func TestMaskEmail(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "空邮箱", + input: "", + expected: "", + }, + { + name: "格式错误", + input: "notanemail", + expected: "****", + }, + { + name: "正常邮箱", + input: "user@example.com", + expected: "us****@example.com", + }, + { + name: "短用户名", + input: "a@example.com", + expected: "**@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MaskEmail(tt.input) + if result != tt.expected { + t.Errorf("MaskEmail(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +}