From b2d32be14f8d4a383b2fe4803c7cbcc30dec6374 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 3 Apr 2026 09:39:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(P2):=20=E4=BF=AE=E5=A4=8D4=E4=B8=AAP2?= =?UTF-8?q?=E8=BD=BB=E5=BE=AE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-01: 通配符scope安全风险 (scope_auth.go) - 添加hasWildcardScope()函数检测通配符scope - 添加logWildcardScopeAccess()函数记录审计日志 - 在RequireScope/RequireAllScopes/RequireAnyScope中间件中调用审计日志 P2-02: isSamePayload比较字段不完整 (audit_service.go) - 添加ActionDetail字段比较 - 添加ResultMessage字段比较 - 添加Extensions字段比较 - 添加compareExtensions()辅助函数 P2-03: regexp.MustCompile可能panic (sanitizer.go) - 添加compileRegex()安全编译函数替代MustCompile - 处理编译错误,避免panic P2-04: StrategyRoundRobin未实现 (router.go) - 添加selectByRoundRobin()方法 - 添加roundRobinCounter原子计数器 - 使用atomic.AddUint64实现线程安全的轮询 P2-05: 错误信息泄露内部细节 - 已在MED-09中处理,跳过 --- gateway/internal/router/router.go | 22 +++++-- .../internal/router/router_roundrobin_test.go | 51 ++++++++++++++++ .../internal/audit/sanitizer/sanitizer.go | 37 +++++++---- .../audit/sanitizer/sanitizer_test.go | 43 ++++++++++++- .../internal/audit/service/audit_service.go | 28 +++++++++ .../audit/service/audit_service_test.go | 61 ++++++++++++++++++- .../internal/iam/middleware/scope_auth.go | 41 +++++++++++++ .../iam/middleware/scope_auth_test.go | 25 ++++++++ 8 files changed, 289 insertions(+), 19 deletions(-) create mode 100644 gateway/internal/router/router_roundrobin_test.go diff --git a/gateway/internal/router/router.go b/gateway/internal/router/router.go index 9a47433..0aa0546 100644 --- a/gateway/internal/router/router.go +++ b/gateway/internal/router/router.go @@ -5,6 +5,7 @@ import ( "math" "math/rand" "sync" + "sync/atomic" "time" "lijiaoqiao/gateway/internal/adapter" @@ -36,10 +37,11 @@ type ProviderHealth struct { // Router 路由器 type Router struct { - providers map[string]adapter.ProviderAdapter - health map[string]*ProviderHealth - strategy LoadBalancerStrategy - mu sync.RWMutex + providers map[string]adapter.ProviderAdapter + health map[string]*ProviderHealth + strategy LoadBalancerStrategy + mu sync.RWMutex + roundRobinCounter uint64 // RoundRobin策略的原子计数器 } // NewRouter 创建路由器 @@ -87,6 +89,8 @@ func (r *Router) SelectProvider(ctx context.Context, model string) (adapter.Prov switch r.strategy { case StrategyLatency: return r.selectByLatency(candidates) + case StrategyRoundRobin: + return r.selectByRoundRobin(candidates) case StrategyWeighted: return r.selectByWeight(candidates) case StrategyAvailability: @@ -121,6 +125,16 @@ func (r *Router) isProviderAvailable(name, model string) bool { return false } +func (r *Router) selectByRoundRobin(candidates []string) (adapter.ProviderAdapter, error) { + if len(candidates) == 0 { + return nil, gwerror.NewGatewayError(gwerror.ROUTER_NO_PROVIDER_AVAILABLE, "no available provider") + } + + // 使用原子操作进行轮询选择 + index := atomic.AddUint64(&r.roundRobinCounter, 1) - 1 + return r.providers[candidates[index%uint64(len(candidates))]], nil +} + func (r *Router) selectByLatency(candidates []string) (adapter.ProviderAdapter, error) { var bestProvider adapter.ProviderAdapter var minLatency int64 = math.MaxInt64 diff --git a/gateway/internal/router/router_roundrobin_test.go b/gateway/internal/router/router_roundrobin_test.go new file mode 100644 index 0000000..67270b5 --- /dev/null +++ b/gateway/internal/router/router_roundrobin_test.go @@ -0,0 +1,51 @@ +package router + +import ( + "context" + "testing" +) + +// TestP2_04_StrategyRoundRobin_NotImplemented 验证RoundRobin策略是否真正实现 +// P2-04: StrategyRoundRobin定义了但走default分支 +func TestP2_04_StrategyRoundRobin_NotImplemented(t *testing.T) { + // 创建3个provider,都设置不同的延迟 + // 如果走latency策略,延迟最低的会被持续选中 + // 如果走RoundRobin策略,应该轮询选择 + r := NewRouter(StrategyRoundRobin) + + prov1 := &mockProvider{name: "p1", models: []string{"gpt-4"}, healthy: true} + prov2 := &mockProvider{name: "p2", models: []string{"gpt-4"}, healthy: true} + prov3 := &mockProvider{name: "p3", models: []string{"gpt-4"}, healthy: true} + + r.RegisterProvider("p1", prov1) + r.RegisterProvider("p2", prov2) + r.RegisterProvider("p3", prov3) + + // 设置不同的延迟 - p1延迟最低 + r.health["p1"].LatencyMs = 10 + r.health["p2"].LatencyMs = 20 + r.health["p3"].LatencyMs = 30 + + // 选择100次,统计每个provider被选中的次数 + counts := map[string]int{"p1": 0, "p2": 0, "p3": 0} + const iterations = 99 // 99能被3整除 + + for i := 0; i < iterations; i++ { + selected, err := r.SelectProvider(context.Background(), "gpt-4") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + counts[selected.ProviderName()]++ + } + + t.Logf("Selection counts with different latencies: p1=%d, p2=%d, p3=%d", counts["p1"], counts["p2"], counts["p3"]) + + // 如果走latency策略,p1应该几乎100%被选中 + // 如果走RoundRobin,应该约33% each + + // 严格检查:如果p1被选中了超过50次,说明走的是latency策略而不是round_robin + if counts["p1"] > iterations/2 { + t.Errorf("RoundRobin strategy appears to NOT be implemented. p1 was selected %d/%d times (%.1f%%), which indicates latency-based selection is being used instead.", + counts["p1"], iterations, float64(counts["p1"])*100/float64(iterations)) + } +} diff --git a/supply-api/internal/audit/sanitizer/sanitizer.go b/supply-api/internal/audit/sanitizer/sanitizer.go index 17204ef..93cc6ce 100644 --- a/supply-api/internal/audit/sanitizer/sanitizer.go +++ b/supply-api/internal/audit/sanitizer/sanitizer.go @@ -51,55 +51,66 @@ type CredentialScanner struct { rules []ScanRule } +// compileRegex 安全编译正则表达式,避免panic +func compileRegex(pattern string) *regexp.Regexp { + re, err := regexp.Compile(pattern) + if err != nil { + // 如果编译失败,使用一个永远不会匹配的pattern + // 这样可以避免panic,同时让扫描器继续工作 + return regexp.MustCompile("(?!)") + } + return re +} + // NewCredentialScanner 创建凭证扫描器 func NewCredentialScanner() *CredentialScanner { scanner := &CredentialScanner{ rules: []ScanRule{ { ID: "openai_key", - Pattern: regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`), + Pattern: compileRegex(`sk-[a-zA-Z0-9]{20,}`), Description: "OpenAI API Key", Severity: "HIGH", }, { ID: "api_key", - Pattern: regexp.MustCompile(`(?i)(api[_-]?key|apikey)["\s:=]+['"]?([a-zA-Z0-9_\-]{16,})['"]?`), + Pattern: compileRegex(`(?i)(api[_-]?key|apikey)["\s:=]+['"]?([a-zA-Z0-9_\-]{16,})['"]?`), Description: "Generic API Key", Severity: "MEDIUM", }, { ID: "aws_access_key", - Pattern: regexp.MustCompile(`(?i)(access[_-]?key[_-]?id|aws[_-]?access[_-]?key)["\s:=]+['"]?(AKIA[0-9A-Z]{16})['"]?`), + Pattern: compileRegex(`(?i)(access[_-]?key[_-]?id|aws[_-]?access[_-]?key)["\s:=]+['"]?(AKIA[0-9A-Z]{16})['"]?`), Description: "AWS Access Key ID", Severity: "HIGH", }, { ID: "aws_secret_key", - Pattern: regexp.MustCompile(`(?i)(secret[_-]?key|aws[_-]?.*secret[_-]?key)["\s:=]+['"]?([a-zA-Z0-9/+=]{40})['"]?`), + Pattern: compileRegex(`(?i)(secret[_-]?key|aws[_-]?.*secret[_-]?key)["\s:=]+['"]?([a-zA-Z0-9/+=]{40})['"]?`), Description: "AWS Secret Access Key", Severity: "HIGH", }, { ID: "password", - Pattern: regexp.MustCompile(`(?i)(password|passwd|pwd)["\s:=]+['"]?([a-zA-Z0-9@#$%^&*!]{8,})['"]?`), + Pattern: compileRegex(`(?i)(password|passwd|pwd)["\s:=]+['"]?([a-zA-Z0-9@#$%^&*!]{8,})['"]?`), Description: "Password", Severity: "HIGH", }, { ID: "bearer_token", - Pattern: regexp.MustCompile(`(?i)(token|bearer|authorization)["\s:=]+['"]?([Bb]earer\s+)?([a-zA-Z0-9_\-\.]+)['"]?`), + Pattern: compileRegex(`(?i)(token|bearer|authorization)["\s:=]+['"]?([Bb]earer\s+)?([a-zA-Z0-9_\-\.]+)['"]?`), Description: "Bearer Token", Severity: "MEDIUM", }, { ID: "private_key", - Pattern: regexp.MustCompile(`-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----`), + Pattern: compileRegex(`-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----`), Description: "Private Key", Severity: "CRITICAL", }, { ID: "secret", - Pattern: regexp.MustCompile(`(?i)(secret|client[_-]?secret)["\s:=]+['"]?([a-zA-Z0-9_\-]{16,})['"]?`), + Pattern: compileRegex(`(?i)(secret|client[_-]?secret)["\s:=]+['"]?([a-zA-Z0-9_\-]{16,})['"]?`), Description: "Secret", Severity: "HIGH", }, @@ -151,13 +162,13 @@ func NewSanitizer() *Sanitizer { return &Sanitizer{ patterns: []*regexp.Regexp{ // OpenAI API Key - regexp.MustCompile(`(sk-[a-zA-Z0-9]{4})[a-zA-Z0-9]+([a-zA-Z0-9]{4})`), + compileRegex(`(sk-[a-zA-Z0-9]{4})[a-zA-Z0-9]+([a-zA-Z0-9]{4})`), // AWS Access Key - regexp.MustCompile(`(AKIA[0-9A-Z]{4})[0-9A-Z]+([0-9A-Z]{4})`), + compileRegex(`(AKIA[0-9A-Z]{4})[0-9A-Z]+([0-9A-Z]{4})`), // Generic API Key - regexp.MustCompile(`([a-zA-Z0-9_\-]{4})[a-zA-Z0-9_\-]{8,}([a-zA-Z0-9_\-]{4})`), + compileRegex(`([a-zA-Z0-9_\-]{4})[a-zA-Z0-9_\-]{8,}([a-zA-Z0-9_\-]{4})`), // Password - regexp.MustCompile(`([a-zA-Z0-9@#$%^&*!]{4})[a-zA-Z0-9@#$%^&*!]+([a-zA-Z0-9@#$%^&*!]{4})`), + compileRegex(`([a-zA-Z0-9@#$%^&*!]{4})[a-zA-Z0-9@#$%^&*!]+([a-zA-Z0-9@#$%^&*!]{4})`), }, } } @@ -170,7 +181,7 @@ func (s *Sanitizer) Mask(content string) string { // 替换为格式:前4字符 + **** + 后4字符 result = pattern.ReplaceAllStringFunc(result, func(match string) string { // 尝试分组替换 - re := regexp.MustCompile(`^(.{4}).+(.{4})$`) + re := compileRegex(`^(.{4}).+(.{4})$`) submatch := re.FindStringSubmatch(match) if len(submatch) == 3 { return submatch[1] + "****" + submatch[2] diff --git a/supply-api/internal/audit/sanitizer/sanitizer_test.go b/supply-api/internal/audit/sanitizer/sanitizer_test.go index dfffe61..8301b11 100644 --- a/supply-api/internal/audit/sanitizer/sanitizer_test.go +++ b/supply-api/internal/audit/sanitizer/sanitizer_test.go @@ -1,6 +1,7 @@ package sanitizer import ( + "regexp" "testing" "github.com/stretchr/testify/assert" @@ -287,4 +288,44 @@ func TestSanitizer_MultipleViolations(t *testing.T) { assert.True(t, result.HasViolation()) assert.GreaterOrEqual(t, len(result.Violations), 3) -} \ No newline at end of file +} +// P2-03: regexp.MustCompile可能panic,应该使用regexp.Compile并处理错误 +func TestP2_03_NewCredentialScanner_InvalidRegex(t *testing.T) { + // 测试一个无效的正则表达式 + // 由于NewCredentialScanner内部使用MustCompile,这里我们测试在初始化时是否会panic + + // 创建一个会panic的场景:无效正则应该被Compile检测而不是MustCompile + // 通过检查NewCredentialScanner是否能正常创建(不panic)来验证 + + defer func() { + if r := recover(); r != nil { + t.Errorf("P2-03 BUG: NewCredentialScanner panicked with invalid regex: %v", r) + } + }() + + // 这里如果正则都是有效的,应该不会panic + scanner := NewCredentialScanner() + if scanner == nil { + t.Error("scanner should not be nil") + } + + // 但我们无法在测试中模拟无效正则,因为MustCompile在编译时就panic了 + // 所以这个测试更多是文档性质的 + t.Logf("P2-03: NewCredentialScanner uses MustCompile which panics on invalid regex - should use Compile with error handling") +} + +// P2-03: 验证MustCompile在无效正则时会panic +// 这个测试演示了问题:使用无效正则会导致panic +func TestP2_03_MustCompile_PanicsOnInvalidRegex(t *testing.T) { + invalidRegex := "[invalid" // 无效的正则,缺少结束括号 + + defer func() { + if r := recover(); r != nil { + t.Logf("P2-03 CONFIRMED: MustCompile panics on invalid regex: %v", r) + } + }() + + // 这行会panic + _ = regexp.MustCompile(invalidRegex) + t.Error("Should have panicked") +} diff --git a/supply-api/internal/audit/service/audit_service.go b/supply-api/internal/audit/service/audit_service.go index 1280747..7d1a640 100644 --- a/supply-api/internal/audit/service/audit_service.go +++ b/supply-api/internal/audit/service/audit_service.go @@ -315,6 +315,9 @@ func isSamePayload(a, b *model.AuditEvent) bool { if a.Action != b.Action { return false } + if a.ActionDetail != b.ActionDetail { + return false + } if a.CredentialType != b.CredentialType { return false } @@ -330,5 +333,30 @@ func isSamePayload(a, b *model.AuditEvent) bool { if a.ResultCode != b.ResultCode { return false } + if a.ResultMessage != b.ResultMessage { + return false + } + // 比较Extensions + if !compareExtensions(a.Extensions, b.Extensions) { + return false + } + return true +} + +// compareExtensions 比较两个map是否相等 +func compareExtensions(a, b map[string]any) bool { + if len(a) != len(b) { + return false + } + for k, v1 := range a { + v2, ok := b[k] + if !ok { + return false + } + // 简单的值比较,不处理嵌套map的情况 + if v1 != v2 { + return false + } + } return true } \ No newline at end of file diff --git a/supply-api/internal/audit/service/audit_service_test.go b/supply-api/internal/audit/service/audit_service_test.go index d40156e..0e92efe 100644 --- a/supply-api/internal/audit/service/audit_service_test.go +++ b/supply-api/internal/audit/service/audit_service_test.go @@ -550,4 +550,63 @@ func TestAuditService_IdempotencyRaceCondition(t *testing.T) { assert.Equal(t, 1, createdCount, "Should have exactly one created event") assert.Equal(t, concurrentCount-1, duplicateCount, "Should have concurrentCount-1 duplicates") assert.Equal(t, 0, conflictCount, "Should have no conflicts for same payload") -} \ No newline at end of file +} +// P2-02: isSamePayload比较字段不完整,缺少ActionDetail/ResultMessage/Extensions等字段 +func TestP2_02_IsSamePayload_MissingFields(t *testing.T) { + ctx := context.Background() + svc := NewAuditService(NewInMemoryAuditStore()) + + // 第一次事件 - 完整的payload + event1 := &model.AuditEvent{ + EventName: "CRED-EXPOSE-RESPONSE", + EventCategory: "CRED", + OperatorID: 1001, + TenantID: 2001, + ObjectType: "account", + ObjectID: 12345, + Action: "query", + CredentialType: "platform_token", + SourceType: "api", + SourceIP: "192.168.1.1", + Success: true, + ResultCode: "SEC_CRED_EXPOSED", + ActionDetail: "detailed action info", // 缺失字段 + ResultMessage: "operation completed", // 缺失字段 + IdempotencyKey: "p2-02-test-key", + } + + // 第二次重放 - ActionDetail和ResultMessage不同,但isSamePayload应该能检测出来 + event2 := &model.AuditEvent{ + EventName: "CRED-EXPOSE-RESPONSE", + EventCategory: "CRED", + OperatorID: 1001, + TenantID: 2001, + ObjectType: "account", + ObjectID: 12345, + Action: "query", + CredentialType: "platform_token", + SourceType: "api", + SourceIP: "192.168.1.1", + Success: true, + ResultCode: "SEC_CRED_EXPOSED", + ActionDetail: "different action info", // 与event1不同 + ResultMessage: "different message", // 与event1不同 + IdempotencyKey: "p2-02-test-key", + } + + // 首次创建 + result1, err1 := svc.CreateEvent(ctx, event1) + assert.NoError(t, err1) + assert.Equal(t, 201, result1.StatusCode) + + // 重放异参 - 应该返回409 + result2, err2 := svc.CreateEvent(ctx, event2) + assert.NoError(t, err2) + + // 如果isSamePayload没有比较ActionDetail和ResultMessage,这里会错误地返回200而不是409 + if result2.StatusCode == 200 { + t.Errorf("P2-02 BUG: isSamePayload does NOT compare ActionDetail/ResultMessage fields. Got 200 (duplicate) but should be 409 (conflict)") + } else if result2.StatusCode == 409 { + t.Logf("P2-02 FIXED: isSamePayload correctly detects payload mismatch") + } +} diff --git a/supply-api/internal/iam/middleware/scope_auth.go b/supply-api/internal/iam/middleware/scope_auth.go index 52a8fc3..283d94a 100644 --- a/supply-api/internal/iam/middleware/scope_auth.go +++ b/supply-api/internal/iam/middleware/scope_auth.go @@ -3,6 +3,7 @@ package middleware import ( "context" "encoding/json" + "log" "net/http" "lijiaoqiao/supply-api/internal/middleware" @@ -174,6 +175,31 @@ func hasScope(scopes []string, target string) bool { return false } +// hasWildcardScope 检查scope列表是否包含通配符scope +func hasWildcardScope(scopes []string) bool { + for _, scope := range scopes { + if scope == "*" { + return true + } + } + return false +} + +// logWildcardScopeAccess 记录通配符scope访问的审计日志 +// P2-01: 通配符scope是安全风险,应记录审计日志 +func logWildcardScopeAccess(ctx context.Context, claims *IAMTokenClaims, requiredScope string) { + if claims == nil { + return + } + + // 检查是否使用了通配符scope + if hasWildcardScope(claims.Scope) { + // 记录审计日志 + log.Printf("[AUDIT] P2-01 WILDCARD_SCOPE_ACCESS: subject_id=%s, role=%s, required_scope=%s, tenant_id=%d, user_type=%s", + claims.SubjectID, claims.Role, requiredScope, claims.TenantID, claims.UserType) + } +} + // RequireScope 返回一个要求特定Scope的中间件 func (m *ScopeAuthMiddleware) RequireScope(requiredScope string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { @@ -193,6 +219,11 @@ func (m *ScopeAuthMiddleware) RequireScope(requiredScope string) func(http.Handl return } + // P2-01: 记录通配符scope访问的审计日志 + if hasWildcardScope(claims.Scope) { + logWildcardScopeAccess(r.Context(), claims, requiredScope) + } + next.ServeHTTP(w, r) }) } @@ -218,6 +249,11 @@ func (m *ScopeAuthMiddleware) RequireAllScopes(requiredScopes []string) func(htt } } + // P2-01: 记录通配符scope访问的审计日志 + if hasWildcardScope(claims.Scope) { + logWildcardScopeAccess(r.Context(), claims, "") + } + next.ServeHTTP(w, r) }) } @@ -242,6 +278,11 @@ func (m *ScopeAuthMiddleware) RequireAnyScope(requiredScopes []string) func(http return } + // P2-01: 记录通配符scope访问的审计日志 + if hasWildcardScope(claims.Scope) { + logWildcardScopeAccess(r.Context(), claims, "") + } + next.ServeHTTP(w, r) }) } diff --git a/supply-api/internal/iam/middleware/scope_auth_test.go b/supply-api/internal/iam/middleware/scope_auth_test.go index a97a33a..d0e0b96 100644 --- a/supply-api/internal/iam/middleware/scope_auth_test.go +++ b/supply-api/internal/iam/middleware/scope_auth_test.go @@ -569,3 +569,28 @@ func TestMED01_RequireAnyScope_EmptyScopesShouldDenyAccess(t *testing.T) { // assert - 空scope列表应该拒绝访问(安全修复) assert.Equal(t, http.StatusForbidden, rec.Code, "empty required scopes should DENY access (security fix)") } + +// P2-01: scope=="*"时直接返回true,应记录审计日志 +// 由于hasScope是内部函数,我们通过中间件来验证通配符scope的行为 +func TestP2_01_WildcardScope_SecurityRisk(t *testing.T) { + // 创建一个带通配符scope的claims + claims := &IAMTokenClaims{ + SubjectID: "user:p2-01", + Role: "super_admin", + Scope: []string{"*"}, // 通配符scope代表所有权限 + TenantID: 1, + } + + ctx := WithIAMClaims(context.Background(), claims) + + // 通配符scope应该能通过任何scope检查 + assert.True(t, CheckScope(ctx, "platform:read"), "wildcard scope should have platform:read") + assert.True(t, CheckScope(ctx, "platform:write"), "wildcard scope should have platform:write") + assert.True(t, CheckScope(ctx, "any:custom:scope"), "wildcard scope should have any:custom:scope") + + // 问题:通配符scope被使用时没有记录审计日志 + // 修复建议:在hasScope返回true时,如果scope是"*",应该记录审计日志 + // 这是一个安全风险,因为无法追踪何时使用了超级权限 + + t.Logf("P2-01: Wildcard scope usage should be audited for security compliance") +}