fix(P2): 修复4个P2轻微问题
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中处理,跳过
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lijiaoqiao/gateway/internal/adapter"
|
"lijiaoqiao/gateway/internal/adapter"
|
||||||
@@ -40,6 +41,7 @@ type Router struct {
|
|||||||
health map[string]*ProviderHealth
|
health map[string]*ProviderHealth
|
||||||
strategy LoadBalancerStrategy
|
strategy LoadBalancerStrategy
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
roundRobinCounter uint64 // RoundRobin策略的原子计数器
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter 创建路由器
|
// NewRouter 创建路由器
|
||||||
@@ -87,6 +89,8 @@ func (r *Router) SelectProvider(ctx context.Context, model string) (adapter.Prov
|
|||||||
switch r.strategy {
|
switch r.strategy {
|
||||||
case StrategyLatency:
|
case StrategyLatency:
|
||||||
return r.selectByLatency(candidates)
|
return r.selectByLatency(candidates)
|
||||||
|
case StrategyRoundRobin:
|
||||||
|
return r.selectByRoundRobin(candidates)
|
||||||
case StrategyWeighted:
|
case StrategyWeighted:
|
||||||
return r.selectByWeight(candidates)
|
return r.selectByWeight(candidates)
|
||||||
case StrategyAvailability:
|
case StrategyAvailability:
|
||||||
@@ -121,6 +125,16 @@ func (r *Router) isProviderAvailable(name, model string) bool {
|
|||||||
return false
|
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) {
|
func (r *Router) selectByLatency(candidates []string) (adapter.ProviderAdapter, error) {
|
||||||
var bestProvider adapter.ProviderAdapter
|
var bestProvider adapter.ProviderAdapter
|
||||||
var minLatency int64 = math.MaxInt64
|
var minLatency int64 = math.MaxInt64
|
||||||
|
|||||||
51
gateway/internal/router/router_roundrobin_test.go
Normal file
51
gateway/internal/router/router_roundrobin_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,55 +51,66 @@ type CredentialScanner struct {
|
|||||||
rules []ScanRule
|
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 创建凭证扫描器
|
// NewCredentialScanner 创建凭证扫描器
|
||||||
func NewCredentialScanner() *CredentialScanner {
|
func NewCredentialScanner() *CredentialScanner {
|
||||||
scanner := &CredentialScanner{
|
scanner := &CredentialScanner{
|
||||||
rules: []ScanRule{
|
rules: []ScanRule{
|
||||||
{
|
{
|
||||||
ID: "openai_key",
|
ID: "openai_key",
|
||||||
Pattern: regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`),
|
Pattern: compileRegex(`sk-[a-zA-Z0-9]{20,}`),
|
||||||
Description: "OpenAI API Key",
|
Description: "OpenAI API Key",
|
||||||
Severity: "HIGH",
|
Severity: "HIGH",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "api_key",
|
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",
|
Description: "Generic API Key",
|
||||||
Severity: "MEDIUM",
|
Severity: "MEDIUM",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "aws_access_key",
|
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",
|
Description: "AWS Access Key ID",
|
||||||
Severity: "HIGH",
|
Severity: "HIGH",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "aws_secret_key",
|
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",
|
Description: "AWS Secret Access Key",
|
||||||
Severity: "HIGH",
|
Severity: "HIGH",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "password",
|
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",
|
Description: "Password",
|
||||||
Severity: "HIGH",
|
Severity: "HIGH",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "bearer_token",
|
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",
|
Description: "Bearer Token",
|
||||||
Severity: "MEDIUM",
|
Severity: "MEDIUM",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "private_key",
|
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",
|
Description: "Private Key",
|
||||||
Severity: "CRITICAL",
|
Severity: "CRITICAL",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "secret",
|
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",
|
Description: "Secret",
|
||||||
Severity: "HIGH",
|
Severity: "HIGH",
|
||||||
},
|
},
|
||||||
@@ -151,13 +162,13 @@ func NewSanitizer() *Sanitizer {
|
|||||||
return &Sanitizer{
|
return &Sanitizer{
|
||||||
patterns: []*regexp.Regexp{
|
patterns: []*regexp.Regexp{
|
||||||
// OpenAI API Key
|
// 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
|
// 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
|
// 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
|
// 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字符
|
// 替换为格式:前4字符 + **** + 后4字符
|
||||||
result = pattern.ReplaceAllStringFunc(result, func(match string) string {
|
result = pattern.ReplaceAllStringFunc(result, func(match string) string {
|
||||||
// 尝试分组替换
|
// 尝试分组替换
|
||||||
re := regexp.MustCompile(`^(.{4}).+(.{4})$`)
|
re := compileRegex(`^(.{4}).+(.{4})$`)
|
||||||
submatch := re.FindStringSubmatch(match)
|
submatch := re.FindStringSubmatch(match)
|
||||||
if len(submatch) == 3 {
|
if len(submatch) == 3 {
|
||||||
return submatch[1] + "****" + submatch[2]
|
return submatch[1] + "****" + submatch[2]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sanitizer
|
package sanitizer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -288,3 +289,43 @@ func TestSanitizer_MultipleViolations(t *testing.T) {
|
|||||||
assert.True(t, result.HasViolation())
|
assert.True(t, result.HasViolation())
|
||||||
assert.GreaterOrEqual(t, len(result.Violations), 3)
|
assert.GreaterOrEqual(t, len(result.Violations), 3)
|
||||||
}
|
}
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -315,6 +315,9 @@ func isSamePayload(a, b *model.AuditEvent) bool {
|
|||||||
if a.Action != b.Action {
|
if a.Action != b.Action {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if a.ActionDetail != b.ActionDetail {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if a.CredentialType != b.CredentialType {
|
if a.CredentialType != b.CredentialType {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -330,5 +333,30 @@ func isSamePayload(a, b *model.AuditEvent) bool {
|
|||||||
if a.ResultCode != b.ResultCode {
|
if a.ResultCode != b.ResultCode {
|
||||||
return false
|
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
|
return true
|
||||||
}
|
}
|
||||||
@@ -551,3 +551,62 @@ func TestAuditService_IdempotencyRaceCondition(t *testing.T) {
|
|||||||
assert.Equal(t, concurrentCount-1, duplicateCount, "Should have concurrentCount-1 duplicates")
|
assert.Equal(t, concurrentCount-1, duplicateCount, "Should have concurrentCount-1 duplicates")
|
||||||
assert.Equal(t, 0, conflictCount, "Should have no conflicts for same payload")
|
assert.Equal(t, 0, conflictCount, "Should have no conflicts for same payload")
|
||||||
}
|
}
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"lijiaoqiao/supply-api/internal/middleware"
|
"lijiaoqiao/supply-api/internal/middleware"
|
||||||
@@ -174,6 +175,31 @@ func hasScope(scopes []string, target string) bool {
|
|||||||
return false
|
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的中间件
|
// RequireScope 返回一个要求特定Scope的中间件
|
||||||
func (m *ScopeAuthMiddleware) RequireScope(requiredScope string) func(http.Handler) http.Handler {
|
func (m *ScopeAuthMiddleware) RequireScope(requiredScope string) func(http.Handler) http.Handler {
|
||||||
return func(next 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P2-01: 记录通配符scope访问的审计日志
|
||||||
|
if hasWildcardScope(claims.Scope) {
|
||||||
|
logWildcardScopeAccess(r.Context(), claims, requiredScope)
|
||||||
|
}
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
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)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -242,6 +278,11 @@ func (m *ScopeAuthMiddleware) RequireAnyScope(requiredScopes []string) func(http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P2-01: 记录通配符scope访问的审计日志
|
||||||
|
if hasWildcardScope(claims.Scope) {
|
||||||
|
logWildcardScopeAccess(r.Context(), claims, "")
|
||||||
|
}
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -569,3 +569,28 @@ func TestMED01_RequireAnyScope_EmptyScopesShouldDenyAccess(t *testing.T) {
|
|||||||
// assert - 空scope列表应该拒绝访问(安全修复)
|
// assert - 空scope列表应该拒绝访问(安全修复)
|
||||||
assert.Equal(t, http.StatusForbidden, rec.Code, "empty required scopes should DENY access (security fix)")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user