mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-12 23:36:55 +08:00
fix(security): 脱敏后台日志中的敏感信息 (#761)
## 问题
后台日志在打印配置更新时会暴露完整的 API Key、Secret Key 和私钥等敏感信息(Issue #758)。
## 解决方案
### 1. 新增脱敏工具库 (api/utils.go)
- `MaskSensitiveString()`: 脱敏敏感字符串(保留前4位和后4位,中间用****替代)
- `SanitizeModelConfigForLog()`: 脱敏 AI 模型配置用于日志输出
- `SanitizeExchangeConfigForLog()`: 脱敏交易所配置用于日志输出
- `MaskEmail()`: 脱敏邮箱地址
### 2. 修复日志打印 (api/server.go)
- Line 1106: 脱敏 AI 模型配置更新日志
- Line 1203: 脱敏交易所配置更新日志
### 3. 完善单元测试 (api/utils_test.go)
- 4个测试函数,9个子测试,全部通过
- 工具函数测试覆盖率: 91%+
## 脱敏效果示例
**修复前**:
```
✓ 交易所配置已更新: map[binance:{api_key:sk-1234567890abcdef secret_key:binance_secret_1234567890abcdef}]
```
**修复后**:
```
✓ 交易所配置已更新: map[binance:{api_key:sk-1****cdef secret_key:bina****cdef}]
```
## 测试结果
```
PASS
ok nofx/api 0.012s
coverage: 91.2% of statements in utils.go
```
## 安全影响
- 防止日志泄露 API Key、Secret Key、私钥等敏感信息
- 保护用户隐私和账户安全
- 符合安全最佳实践
Closes #758
This commit is contained in:
@@ -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": "模型配置已更新"})
|
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": "交易所配置已更新"})
|
c.JSON(http.StatusOK, gin.H{"message": "交易所配置已更新"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
97
api/utils.go
Normal file
97
api/utils.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
193
api/utils_test.go
Normal file
193
api/utils_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user