feat(ai-security-guard): replace denyMessage with structured DenyResponseBody (#3642)

Co-authored-by: rinfx <yucheng.lxr@alibaba-inc.com>
This commit is contained in:
JianweiWang
2026-04-01 19:38:01 +08:00
committed by GitHub
parent 89587c1c9b
commit 1c9e981bf2
10 changed files with 820 additions and 83 deletions

View File

@@ -156,6 +156,90 @@ var mcpConfig = func() json.RawMessage {
return data
}()
// 测试配置MCP配置启用请求检查
var mcpRequestConfig = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"serviceName": "security-service",
"servicePort": 8080,
"serviceHost": "security.example.com",
"accessKey": "test-ak",
"secretKey": "test-sk",
"checkRequest": true,
"checkResponse": false,
"action": "MultiModalGuard",
"apiType": "mcp",
"requestContentJsonPath": "params.arguments",
"contentModerationLevelBar": "high",
"promptAttackLevelBar": "high",
"sensitiveDataLevelBar": "S3",
"timeout": 2000,
})
return data
}()
// 测试配置MultiModalGuard 文本生成
var multiModalGuardTextConfig = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"serviceName": "security-service",
"servicePort": 8080,
"serviceHost": "security.example.com",
"accessKey": "test-ak",
"secretKey": "test-sk",
"checkRequest": true,
"checkResponse": true,
"action": "MultiModalGuard",
"apiType": "text_generation",
"contentModerationLevelBar": "high",
"promptAttackLevelBar": "high",
"sensitiveDataLevelBar": "S3",
"timeout": 2000,
"bufferLimit": 1000,
})
return data
}()
// 测试配置MultiModalGuard OpenAI 图像生成
var multiModalGuardImageOpenAIConfig = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"serviceName": "security-service",
"servicePort": 8080,
"serviceHost": "security.example.com",
"accessKey": "test-ak",
"secretKey": "test-sk",
"checkRequest": true,
"checkResponse": true,
"action": "MultiModalGuard",
"apiType": "image_generation",
"providerType": "openai",
"contentModerationLevelBar": "high",
"promptAttackLevelBar": "high",
"sensitiveDataLevelBar": "S3",
"timeout": 2000,
})
return data
}()
// 测试配置MultiModalGuard Qwen 图像生成
var multiModalGuardImageQwenConfig = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"serviceName": "security-service",
"servicePort": 8080,
"serviceHost": "security.example.com",
"accessKey": "test-ak",
"secretKey": "test-sk",
"checkRequest": true,
"checkResponse": true,
"action": "MultiModalGuard",
"apiType": "image_generation",
"providerType": "qwen",
"contentModerationLevelBar": "high",
"promptAttackLevelBar": "high",
"sensitiveDataLevelBar": "S3",
"timeout": 2000,
})
return data
}()
func TestParseConfig(t *testing.T) {
test.RunGoTest(t, func(t *testing.T) {
// 测试基础配置解析
@@ -330,6 +414,51 @@ func TestOnHttpRequestBody(t *testing.T) {
// 空内容应该直接通过
require.Equal(t, types.ActionContinue, action)
})
// TextModerationPlus默认 action含 agent/OpenAI 形态)请求拦截应返回 choices[0].message.content 内的 blockedDetails JSON
t.Run("text moderation plus request deny returns blockedDetails in openai completion shape", func(t *testing.T) {
host, status := test.NewTestHost(basicConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
})
body := `{"messages": [{"role": "user", "content": "trigger deny"}]}`
require.Equal(t, types.ActionPause, host.CallOnHttpRequestBody([]byte(body)))
securityResponse := `{"Code": 200, "Message": "Success", "RequestId": "req-tmp-deny", "Data": {"RiskLevel": "high"}}`
host.CallOnHttpCall([][2]string{
{":status", "200"},
{"content-type", "application/json"},
}, []byte(securityResponse))
local := host.GetLocalResponse()
require.NotNil(t, local, "expected SendHttpResponse for request deny")
require.Contains(t, string(local.Data), "blockedDetails")
require.Contains(t, string(local.Data), "req-tmp-deny")
type openAIChatCompletion struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
var outer openAIChatCompletion
require.NoError(t, json.Unmarshal(local.Data, &outer))
require.Len(t, outer.Choices, 1)
var deny cfg.DenyResponseBody
require.NoError(t, json.Unmarshal([]byte(outer.Choices[0].Message.Content), &deny))
require.Equal(t, "req-tmp-deny", deny.RequestId)
require.Equal(t, 200, deny.GuardCode)
require.NotEmpty(t, deny.BlockedDetails)
require.Equal(t, cfg.ContentModerationType, deny.BlockedDetails[0].Type)
})
})
}
@@ -649,3 +778,444 @@ func TestUtilityFunctions(t *testing.T) {
require.Len(t, id, 38) // "chatcmpl-" + 29 random chars
})
}
func TestMultiModalGuardTextGenerationDeny(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
// MultiModalGuard text_generation request deny → exercises multi_modal_guard/text/openai.go BuildDenyResponseBody path
t.Run("multi modal guard text request deny returns blockedDetails", func(t *testing.T) {
host, status := test.NewTestHost(multiModalGuardTextConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
})
body := `{"messages": [{"role": "user", "content": "trigger deny"}]}`
require.Equal(t, types.ActionPause, host.CallOnHttpRequestBody([]byte(body)))
securityResponse := `{"Code": 200, "Message": "Success", "RequestId": "req-mmg-text-deny", "Data": {"RiskLevel": "high"}}`
host.CallOnHttpCall([][2]string{
{":status", "200"},
{"content-type", "application/json"},
}, []byte(securityResponse))
local := host.GetLocalResponse()
require.NotNil(t, local, "expected SendHttpResponse for request deny")
require.Contains(t, string(local.Data), "blockedDetails")
require.Contains(t, string(local.Data), "req-mmg-text-deny")
})
// MultiModalGuard text_generation response deny → exercises common/text/openai.go HandleTextGenerationResponseBody BuildDenyResponseBody path
t.Run("multi modal guard text response deny returns blockedDetails", func(t *testing.T) {
host, status := test.NewTestHost(multiModalGuardTextConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
})
host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"content-type", "application/json"},
})
body := `{"choices": [{"message": {"role": "assistant", "content": "bad response content"}}]}`
action := host.CallOnHttpResponseBody([]byte(body))
require.Equal(t, types.ActionPause, action)
securityResponse := `{"Code": 200, "Message": "Success", "RequestId": "req-mmg-resp-deny", "Data": {"RiskLevel": "high"}}`
host.CallOnHttpCall([][2]string{
{":status", "200"},
{"content-type", "application/json"},
}, []byte(securityResponse))
local := host.GetLocalResponse()
require.NotNil(t, local, "expected SendHttpResponse for response deny")
require.Contains(t, string(local.Data), "blockedDetails")
require.Contains(t, string(local.Data), "req-mmg-resp-deny")
})
// MultiModalGuard text_generation request pass
t.Run("multi modal guard text request pass", func(t *testing.T) {
host, status := test.NewTestHost(multiModalGuardTextConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
})
body := `{"messages": [{"role": "user", "content": "Hello"}]}`
require.Equal(t, types.ActionPause, host.CallOnHttpRequestBody([]byte(body)))
securityResponse := `{"Code": 200, "Message": "Success", "RequestId": "req-mmg-pass", "Data": {"RiskLevel": "low"}}`
host.CallOnHttpCall([][2]string{
{":status", "200"},
{"content-type", "application/json"},
}, []byte(securityResponse))
action := host.GetHttpStreamAction()
require.Equal(t, types.ActionContinue, action)
host.CompleteHttp()
})
})
}
func TestMultiModalGuardImageGenerationDeny(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
// OpenAI image generation request deny → exercises multi_modal_guard/image/openai.go BuildDenyResponseBody path
t.Run("openai image request deny returns blockedDetails", func(t *testing.T) {
host, status := test.NewTestHost(multiModalGuardImageOpenAIConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/images/generations"},
{":method", "POST"},
})
body := `{"prompt": "generate bad image"}`
require.Equal(t, types.ActionPause, host.CallOnHttpRequestBody([]byte(body)))
securityResponse := `{"Code": 200, "Message": "Success", "RequestId": "req-img-openai-deny", "Data": {"RiskLevel": "high"}}`
host.CallOnHttpCall([][2]string{
{":status", "200"},
{"content-type", "application/json"},
}, []byte(securityResponse))
local := host.GetLocalResponse()
require.NotNil(t, local, "expected SendHttpResponse for OpenAI image request deny")
require.Contains(t, string(local.Data), "blockedDetails")
require.Contains(t, string(local.Data), "req-img-openai-deny")
})
// OpenAI image generation request pass
t.Run("openai image request pass", func(t *testing.T) {
host, status := test.NewTestHost(multiModalGuardImageOpenAIConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/images/generations"},
{":method", "POST"},
})
body := `{"prompt": "a cute cat"}`
require.Equal(t, types.ActionPause, host.CallOnHttpRequestBody([]byte(body)))
securityResponse := `{"Code": 200, "Message": "Success", "RequestId": "req-img-pass", "Data": {"RiskLevel": "low"}}`
host.CallOnHttpCall([][2]string{
{":status", "200"},
{"content-type", "application/json"},
}, []byte(securityResponse))
action := host.GetHttpStreamAction()
require.Equal(t, types.ActionContinue, action)
host.CompleteHttp()
})
// Qwen image generation request deny → exercises multi_modal_guard/image/qwen.go BuildDenyResponseBody path
t.Run("qwen image request deny returns blockedDetails", func(t *testing.T) {
host, status := test.NewTestHost(multiModalGuardImageQwenConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/images/generations"},
{":method", "POST"},
})
body := `{"input": {"prompt": "generate bad image"}}`
require.Equal(t, types.ActionPause, host.CallOnHttpRequestBody([]byte(body)))
securityResponse := `{"Code": 200, "Message": "Success", "RequestId": "req-img-qwen-deny", "Data": {"RiskLevel": "high"}}`
host.CallOnHttpCall([][2]string{
{":status", "200"},
{"content-type", "application/json"},
}, []byte(securityResponse))
local := host.GetLocalResponse()
require.NotNil(t, local, "expected SendHttpResponse for Qwen image request deny")
require.Contains(t, string(local.Data), "blockedDetails")
require.Contains(t, string(local.Data), "req-img-qwen-deny")
})
// Qwen image generation request pass
t.Run("qwen image request pass", func(t *testing.T) {
host, status := test.NewTestHost(multiModalGuardImageQwenConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/images/generations"},
{":method", "POST"},
})
body := `{"input": {"prompt": "a cute cat"}}`
require.Equal(t, types.ActionPause, host.CallOnHttpRequestBody([]byte(body)))
securityResponse := `{"Code": 200, "Message": "Success", "RequestId": "req-qwen-pass", "Data": {"RiskLevel": "low"}}`
host.CallOnHttpCall([][2]string{
{":status", "200"},
{"content-type", "application/json"},
}, []byte(securityResponse))
action := host.GetHttpStreamAction()
require.Equal(t, types.ActionContinue, action)
host.CompleteHttp()
})
})
}
func TestMCPRequestDeny(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
// MCP request deny → exercises multi_modal_guard/mcp/mcp.go HandleMcpRequestBody BuildDenyResponseBody path
t.Run("mcp request deny returns blockedDetails", func(t *testing.T) {
host, status := test.NewTestHost(mcpRequestConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/mcp/call"},
{":method", "POST"},
})
body := `{"method": "tools/call", "params": {"arguments": "bad request content"}}`
require.Equal(t, types.ActionPause, host.CallOnHttpRequestBody([]byte(body)))
securityResponse := `{"Code": 200, "Message": "Success", "RequestId": "req-mcp-deny", "Data": {"RiskLevel": "high"}}`
host.CallOnHttpCall([][2]string{
{":status", "200"},
{"content-type", "application/json"},
}, []byte(securityResponse))
local := host.GetLocalResponse()
require.NotNil(t, local, "expected SendHttpResponse for MCP request deny")
require.Contains(t, string(local.Data), "blockedDetails")
require.Contains(t, string(local.Data), "req-mcp-deny")
})
// MCP request pass
t.Run("mcp request pass", func(t *testing.T) {
host, status := test.NewTestHost(mcpRequestConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/mcp/call"},
{":method", "POST"},
})
body := `{"method": "tools/call", "params": {"arguments": "safe content"}}`
require.Equal(t, types.ActionPause, host.CallOnHttpRequestBody([]byte(body)))
securityResponse := `{"Code": 200, "Message": "Success", "RequestId": "req-mcp-pass", "Data": {"RiskLevel": "low"}}`
host.CallOnHttpCall([][2]string{
{":status", "200"},
{"content-type", "application/json"},
}, []byte(securityResponse))
action := host.GetHttpStreamAction()
require.Equal(t, types.ActionContinue, action)
host.CompleteHttp()
})
// MCP request skip non-tool-call method
t.Run("mcp request skip non-tool-call", func(t *testing.T) {
host, status := test.NewTestHost(mcpRequestConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/mcp/call"},
{":method", "POST"},
})
body := `{"method": "resources/list", "params": {}}`
action := host.CallOnHttpRequestBody([]byte(body))
require.Equal(t, types.ActionContinue, action)
})
})
}
func TestTextModerationPlusResponseDeny(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
// TextModerationPlus response deny → exercises text_moderation_plus/text (via common/text) BuildDenyResponseBody response path
t.Run("text moderation plus response deny returns blockedDetails", func(t *testing.T) {
host, status := test.NewTestHost(basicConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
})
host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"content-type", "application/json"},
})
body := `{"choices": [{"message": {"role": "assistant", "content": "bad response"}}]}`
action := host.CallOnHttpResponseBody([]byte(body))
require.Equal(t, types.ActionPause, action)
securityResponse := `{"Code": 200, "Message": "Success", "RequestId": "req-tmp-resp-deny", "Data": {"RiskLevel": "high"}}`
host.CallOnHttpCall([][2]string{
{":status", "200"},
{"content-type", "application/json"},
}, []byte(securityResponse))
local := host.GetLocalResponse()
require.NotNil(t, local, "expected SendHttpResponse for response deny")
require.Contains(t, string(local.Data), "blockedDetails")
require.Contains(t, string(local.Data), "req-tmp-resp-deny")
// Verify OpenAI completion shape wrapper
type openAIChatCompletion struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
var outer openAIChatCompletion
require.NoError(t, json.Unmarshal(local.Data, &outer))
require.Len(t, outer.Choices, 1)
var deny cfg.DenyResponseBody
require.NoError(t, json.Unmarshal([]byte(outer.Choices[0].Message.Content), &deny))
require.Equal(t, "req-tmp-resp-deny", deny.RequestId)
require.Equal(t, 200, deny.GuardCode)
require.NotEmpty(t, deny.BlockedDetails)
})
})
}
func TestBuildDenyResponseBody(t *testing.T) {
makeConfig := func(contentBar, promptBar string) cfg.AISecurityConfig {
return cfg.AISecurityConfig{
ContentModerationLevelBar: contentBar,
PromptAttackLevelBar: promptBar,
SensitiveDataLevelBar: "S4",
MaliciousUrlLevelBar: "max",
ModelHallucinationLevelBar: "max",
Action: cfg.MultiModalGuard,
}
}
t.Run("guardCode equals response.Code", func(t *testing.T) {
resp := cfg.Response{
Code: 200,
RequestId: "req-123",
Data: cfg.Data{},
}
body, err := cfg.BuildDenyResponseBody(resp, makeConfig("high", "high"), "")
require.NoError(t, err)
var result cfg.DenyResponseBody
require.NoError(t, json.Unmarshal(body, &result))
require.Equal(t, 200, result.GuardCode)
require.Equal(t, "req-123", result.RequestId)
})
t.Run("blockedDetails from Data.Detail", func(t *testing.T) {
resp := cfg.Response{
Code: 200,
RequestId: "req-456",
Data: cfg.Data{
Detail: []cfg.Detail{
{Type: cfg.ContentModerationType, Level: "high", Suggestion: "block"},
{Type: cfg.PromptAttackType, Level: "low", Suggestion: "block"},
},
},
}
config := makeConfig("high", "high")
body, err := cfg.BuildDenyResponseBody(resp, config, "")
require.NoError(t, err)
var result cfg.DenyResponseBody
require.NoError(t, json.Unmarshal(body, &result))
// only the contentModeration entry meets the "high" bar; promptAttack at "low" does not
require.Len(t, result.BlockedDetails, 1)
require.Equal(t, cfg.ContentModerationType, result.BlockedDetails[0].Type)
require.Equal(t, "high", result.BlockedDetails[0].Level)
})
t.Run("blockedDetails fallback from RiskLevel when Detail is empty", func(t *testing.T) {
resp := cfg.Response{
Code: 200,
RequestId: "req-789",
Data: cfg.Data{
RiskLevel: "high",
// Detail deliberately empty
},
}
config := makeConfig("high", "high")
body, err := cfg.BuildDenyResponseBody(resp, config, "")
require.NoError(t, err)
var result cfg.DenyResponseBody
require.NoError(t, json.Unmarshal(body, &result))
require.NotEmpty(t, result.BlockedDetails, "expected fallback detail from RiskLevel")
require.Equal(t, cfg.ContentModerationType, result.BlockedDetails[0].Type)
require.Equal(t, "high", result.BlockedDetails[0].Level)
require.Equal(t, "block", result.BlockedDetails[0].Suggestion)
})
t.Run("blockedDetails fallback from AttackLevel when Detail is empty", func(t *testing.T) {
resp := cfg.Response{
Code: 200,
RequestId: "req-abc",
Data: cfg.Data{
AttackLevel: "high",
// Detail deliberately empty
},
}
config := makeConfig("high", "high")
body, err := cfg.BuildDenyResponseBody(resp, config, "")
require.NoError(t, err)
var result cfg.DenyResponseBody
require.NoError(t, json.Unmarshal(body, &result))
require.NotEmpty(t, result.BlockedDetails, "expected fallback detail from AttackLevel")
require.Equal(t, cfg.PromptAttackType, result.BlockedDetails[0].Type)
require.Equal(t, "high", result.BlockedDetails[0].Level)
require.Equal(t, "block", result.BlockedDetails[0].Suggestion)
})
t.Run("blockedDetails empty when risk levels below threshold", func(t *testing.T) {
resp := cfg.Response{
Code: 200,
RequestId: "req-def",
Data: cfg.Data{
RiskLevel: "low",
AttackLevel: "low",
},
}
// threshold is "high", so "low" must not produce fallback entries
config := makeConfig("high", "high")
body, err := cfg.BuildDenyResponseBody(resp, config, "")
require.NoError(t, err)
var result cfg.DenyResponseBody
require.NoError(t, json.Unmarshal(body, &result))
require.Empty(t, result.BlockedDetails)
})
}