From 943fda0a9c55802bd9a4c263372e7de093c30934 Mon Sep 17 00:00:00 2001 From: rinfx Date: Mon, 4 Aug 2025 20:47:18 +0800 Subject: [PATCH] AI security streaming (#2696) --- .../extensions/ai-security-guard/README.md | 1 + .../extensions/ai-security-guard/README_EN.md | 3 + .../extensions/ai-security-guard/go.mod | 3 +- .../extensions/ai-security-guard/go.sum | 9 +- .../extensions/ai-security-guard/main.go | 155 ++++++++++++++++-- .../wasm-go/extensions/ai-statistics/go.mod | 3 +- .../wasm-go/extensions/ai-statistics/go.sum | 7 +- 7 files changed, 157 insertions(+), 24 deletions(-) diff --git a/plugins/wasm-go/extensions/ai-security-guard/README.md b/plugins/wasm-go/extensions/ai-security-guard/README.md index a005299da..86abec29e 100644 --- a/plugins/wasm-go/extensions/ai-security-guard/README.md +++ b/plugins/wasm-go/extensions/ai-security-guard/README.md @@ -32,6 +32,7 @@ description: 阿里云内容安全检测 | `protocol` | string | optional | openai | 协议格式,非openai协议填`original` | | `riskLevelBar` | string | optional | high | 拦截风险等级,取值为 max, high, medium, low | | `timeout` | int | optional | 2000 | 调用内容安全服务时的超时时间 | +| `bufferLimit` | int | optional | 1000 | 调用内容安全服务时每段文本的长度限制 | 补充说明一下 `denyMessage`,对非法请求的处理逻辑为: - 如果配置了 `denyMessage`,返回内容为 `denyMessage` 配置内容,格式为openai格式的流式/非流式响应 diff --git a/plugins/wasm-go/extensions/ai-security-guard/README_EN.md b/plugins/wasm-go/extensions/ai-security-guard/README_EN.md index 0367686af..1dd96fa81 100644 --- a/plugins/wasm-go/extensions/ai-security-guard/README_EN.md +++ b/plugins/wasm-go/extensions/ai-security-guard/README_EN.md @@ -31,6 +31,9 @@ Plugin Priority: `300` | `denyCode` | int | optional | 200 | Response status code when the specified content is illegal | | `denyMessage` | string | optional | Drainage/non-streaming response in openai format, the answer content is the suggested answer from Alibaba Cloud content security | Response content when the specified content is illegal | | `protocol` | string | optional | openai | protocol format, `openai` or `original` | +| `riskLevelBar` | string | optional | high | risk level threshold, `max`, `high`, `medium` or `low` | +| `timeout` | int | optional | 2000 | timeout for lvwang service | +| `bufferLimit` | int | optional | 1000 | Limit the length of each text when calling the lvwang service | ## Examples of configuration diff --git a/plugins/wasm-go/extensions/ai-security-guard/go.mod b/plugins/wasm-go/extensions/ai-security-guard/go.mod index 772b03c13..c7cdb909b 100644 --- a/plugins/wasm-go/extensions/ai-security-guard/go.mod +++ b/plugins/wasm-go/extensions/ai-security-guard/go.mod @@ -6,7 +6,7 @@ toolchain go1.24.4 require ( github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250611100342-5654e89a7a80 - github.com/higress-group/wasm-go v1.0.1 + github.com/higress-group/wasm-go v1.0.2-0.20250729071413-2478fd585950 github.com/tidwall/gjson v1.18.0 ) @@ -15,4 +15,5 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/resp v0.1.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect ) diff --git a/plugins/wasm-go/extensions/ai-security-guard/go.sum b/plugins/wasm-go/extensions/ai-security-guard/go.sum index 5276acd2f..ddfabe60e 100644 --- a/plugins/wasm-go/extensions/ai-security-guard/go.sum +++ b/plugins/wasm-go/extensions/ai-security-guard/go.sum @@ -4,14 +4,13 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250611100342-5654e89a7a80 h1:xqmtTZI0JQ2O+Lg9/CE6c+Tw9KD6FnvWw8EpLVuuvfg= github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250611100342-5654e89a7a80/go.mod h1:tRI2LfMudSkKHhyv1uex3BWzcice2s/l8Ah8axporfA= -github.com/higress-group/wasm-go v1.0.0 h1:4Ik5n3FsJ5+r13KLQl2ky+8NuAE8dfWQwoKxXYD2KAw= -github.com/higress-group/wasm-go v1.0.0/go.mod h1:ODBV27sjmhIW8Cqv3R74EUcTnbdkE69bmXBQFuRkY1M= -github.com/higress-group/wasm-go v1.0.1 h1:T1m++qTEANp8+jwE0sxltwtaTKmrHCkLOp1m9N+YeqY= -github.com/higress-group/wasm-go v1.0.1/go.mod h1:9k7L730huS/q4V5iH9WLDgf5ZUHEtfhM/uXcegKDG/M= +github.com/higress-group/wasm-go v1.0.2-0.20250729071413-2478fd585950 h1:X4a+wzGEuLkCcAX2XiDf/vcVOIdZWxtEo0YkT+F/mcM= +github.com/higress-group/wasm-go v1.0.2-0.20250729071413-2478fd585950/go.mod h1:9k7L730huS/q4V5iH9WLDgf5ZUHEtfhM/uXcegKDG/M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -21,5 +20,7 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE= github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/wasm-go/extensions/ai-security-guard/main.go b/plugins/wasm-go/extensions/ai-security-guard/main.go index 8c82d9801..7fc91be43 100644 --- a/plugins/wasm-go/extensions/ai-security-guard/main.go +++ b/plugins/wasm-go/extensions/ai-security-guard/main.go @@ -29,11 +29,12 @@ func main() {} func init() { wrapper.SetCtx( "ai-security-guard", - wrapper.ParseConfigBy(parseConfig), - wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), - wrapper.ProcessRequestBodyBy(onHttpRequestBody), - wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders), - wrapper.ProcessResponseBodyBy(onHttpResponseBody), + wrapper.ParseConfig(parseConfig), + wrapper.ProcessRequestHeaders(onHttpRequestHeaders), + wrapper.ProcessRequestBody(onHttpRequestBody), + wrapper.ProcessResponseHeaders(onHttpResponseHeaders), + wrapper.ProcessStreamingResponseBody(onHttpStreamingResponseBody), + wrapper.ProcessResponseBody(onHttpResponseBody), ) } @@ -105,6 +106,7 @@ type AISecurityConfig struct { protocolOriginal bool riskLevelBar string timeout uint32 + bufferLimit int metrics map[string]proxywasm.MetricCounter } @@ -175,7 +177,7 @@ func generateHexID(length int) (string, error) { return hex.EncodeToString(bytes), nil } -func parseConfig(json gjson.Result, config *AISecurityConfig, log log.Log) error { +func parseConfig(json gjson.Result, config *AISecurityConfig) error { serviceName := json.Get("serviceName").String() servicePort := json.Get("servicePort").Int() serviceHost := json.Get("serviceHost").String() @@ -235,6 +237,11 @@ func parseConfig(json gjson.Result, config *AISecurityConfig, log log.Log) error } else { config.timeout = DefaultTimeout } + if obj := json.Get("bufferLimit"); obj.Exists() { + config.bufferLimit = int(obj.Int()) + } else { + config.bufferLimit = 1000 + } config.client = wrapper.NewClusterClient(wrapper.FQDNCluster{ FQDN: serviceName, Port: servicePort, @@ -253,7 +260,7 @@ func generateRandomID() string { return "chatcmpl-" + string(b) } -func onHttpRequestHeaders(ctx wrapper.HttpContext, config AISecurityConfig, log log.Log) types.Action { +func onHttpRequestHeaders(ctx wrapper.HttpContext, config AISecurityConfig) types.Action { ctx.DisableReroute() if !config.checkRequest { log.Debugf("request checking is disabled") @@ -262,7 +269,7 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config AISecurityConfig, log return types.ActionContinue } -func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body []byte, log log.Log) types.Action { +func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body []byte) types.Action { log.Debugf("checking request body...") startTime := time.Now().UnixMilli() content := gjson.GetBytes(body, config.requestContentJsonPath).String() @@ -305,7 +312,7 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body [] } else if response.Data.Advice != nil && response.Data.Advice[0].Answer != "" { denyMessage = response.Data.Advice[0].Answer } - marshalledDenyMessage := marshalStr(denyMessage, log) + marshalledDenyMessage := marshalStr(denyMessage) if config.protocolOriginal { proxywasm.SendHttpResponse(uint32(config.denyCode), [][2]string{{"content-type", "application/json"}}, []byte(marshalledDenyMessage), -1) } else if gjson.GetBytes(body, "stream").Bool() { @@ -350,7 +357,7 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body [] "AccessKeyId": config.ak, "Timestamp": timestamp, "Service": config.requestCheckService, - "ServiceParameters": fmt.Sprintf(`{"sessionId": "%s","content": "%s"}`, sessionID, marshalStr(contentPiece, log)), + "ServiceParameters": fmt.Sprintf(`{"sessionId": "%s","content": "%s"}`, sessionID, marshalStr(contentPiece)), } if config.token != "" { params["SecurityToken"] = config.token @@ -371,7 +378,7 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body [] return types.ActionPause } -func onHttpResponseHeaders(ctx wrapper.HttpContext, config AISecurityConfig, log log.Log) types.Action { +func onHttpResponseHeaders(ctx wrapper.HttpContext, config AISecurityConfig) types.Action { if !config.checkResponse { log.Debugf("response checking is disabled") ctx.DontReadResponseBody() @@ -383,10 +390,126 @@ func onHttpResponseHeaders(ctx wrapper.HttpContext, config AISecurityConfig, log ctx.DontReadResponseBody() return types.ActionContinue } - return types.HeaderStopIteration + contentType, _ := proxywasm.GetHttpResponseHeader("content-type") + ctx.SetContext("end_of_stream_received", false) + ctx.SetContext("during_call", false) + ctx.SetContext("risk_detected", false) + sessionID, _ := generateHexID(20) + ctx.SetContext("sessionID", sessionID) + if strings.Contains(contentType, "text/event-stream") { + ctx.NeedPauseStreamingResponse() + return types.ActionContinue + } else { + ctx.BufferResponseBody() + return types.HeaderStopIteration + } } -func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body []byte, log log.Log) types.Action { +func onHttpStreamingResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, data []byte, endOfStream bool) []byte { + var bufferQueue [][]byte + var singleCall func() + callback := func(statusCode int, responseHeaders http.Header, responseBody []byte) { + log.Info(string(responseBody)) + if statusCode != 200 || gjson.GetBytes(responseBody, "Code").Int() != 200 { + if ctx.GetContext("end_of_stream_received").(bool) { + proxywasm.ResumeHttpResponse() + } + ctx.SetContext("during_call", false) + return + } + var response Response + err := json.Unmarshal(responseBody, &response) + if err != nil { + log.Error("failed to unmarshal aliyun content security response at response phase") + if ctx.GetContext("end_of_stream_received").(bool) { + proxywasm.ResumeHttpResponse() + } + ctx.SetContext("during_call", false) + return + } + if riskLevelToInt(response.Data.RiskLevel) >= riskLevelToInt(config.riskLevelBar) { + denyMessage := DefaultDenyMessage + if response.Data.Advice != nil && response.Data.Advice[0].Answer != "" { + denyMessage = "\n" + response.Data.Advice[0].Answer + } else if config.denyMessage != "" { + denyMessage = config.denyMessage + } + marshalledDenyMessage := marshalStr(denyMessage) + randomID := generateRandomID() + jsonData := []byte(fmt.Sprintf(OpenAIStreamResponseFormat, randomID, marshalledDenyMessage, randomID)) + proxywasm.InjectEncodedDataToFilterChain(jsonData, true) + return + } + endStream := ctx.GetContext("end_of_stream_received").(bool) && ctx.BufferQueueSize() == 0 + proxywasm.InjectEncodedDataToFilterChain(bytes.Join(bufferQueue, []byte("")), endStream) + bufferQueue = [][]byte{} + if !endStream { + ctx.SetContext("during_call", false) + singleCall() + } + } + singleCall = func() { + if ctx.GetContext("during_call").(bool) { + return + } + if ctx.BufferQueueSize() >= config.bufferLimit || ctx.GetContext("end_of_stream_received").(bool) { + ctx.SetContext("during_call", true) + var buffer string + for ctx.BufferQueueSize() > 0 { + front := ctx.PopBuffer() + bufferQueue = append(bufferQueue, front) + msg := gjson.GetBytes(front, config.responseStreamContentJsonPath).String() + buffer += msg + if len([]rune(buffer)) >= config.bufferLimit { + break + } + } + timestamp := time.Now().UTC().Format("2006-01-02T15:04:05Z") + randomID, _ := generateHexID(16) + log.Debugf("current content piece: %s", buffer) + params := map[string]string{ + "Format": "JSON", + "Version": "2022-03-02", + "SignatureMethod": "Hmac-SHA1", + "SignatureNonce": randomID, + "SignatureVersion": "1.0", + "Action": "TextModerationPlus", + "AccessKeyId": config.ak, + "Timestamp": timestamp, + "Service": config.responseCheckService, + "ServiceParameters": fmt.Sprintf(`{"sessionId": "%s","content": "%s"}`, ctx.GetContext("sessionID").(string), marshalStr(buffer)), + } + if config.token != "" { + params["SecurityToken"] = config.token + } + signature := getSign(params, config.sk+"&") + reqParams := url.Values{} + for k, v := range params { + reqParams.Add(k, v) + } + reqParams.Add("Signature", signature) + err := config.client.Post(fmt.Sprintf("/?%s", reqParams.Encode()), [][2]string{{"User-Agent", AliyunUserAgent}}, nil, callback, config.timeout) + if err != nil { + log.Errorf("failed call the safe check service: %v", err) + if ctx.GetContext("end_of_stream_received").(bool) { + proxywasm.ResumeHttpResponse() + } + } + } + } + if !ctx.GetContext("risk_detected").(bool) { + ctx.PushBuffer(data) + ctx.SetContext("end_of_stream_received", endOfStream) + if !ctx.GetContext("during_call").(bool) { + singleCall() + } + } else if endOfStream { + proxywasm.ResumeHttpResponse() + } + return []byte{} +} + +func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body []byte) types.Action { log.Debugf("checking response body...") startTime := time.Now().UnixMilli() contentType, _ := proxywasm.GetHttpResponseHeader("content-type") @@ -436,7 +559,7 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [ } else if response.Data.Advice != nil && response.Data.Advice[0].Answer != "" { denyMessage = response.Data.Advice[0].Answer } - marshalledDenyMessage := marshalStr(denyMessage, log) + marshalledDenyMessage := marshalStr(denyMessage) if config.protocolOriginal { proxywasm.SendHttpResponse(uint32(config.denyCode), [][2]string{{"content-type", "application/json"}}, []byte(marshalledDenyMessage), -1) } else if isStreamingResponse { @@ -480,7 +603,7 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [ "AccessKeyId": config.ak, "Timestamp": timestamp, "Service": config.responseCheckService, - "ServiceParameters": fmt.Sprintf(`{"sessionId": "%s","content": "%s"}`, sessionID, marshalStr(contentPiece, log)), + "ServiceParameters": fmt.Sprintf(`{"sessionId": "%s","content": "%s"}`, sessionID, marshalStr(contentPiece)), } if config.token != "" { params["SecurityToken"] = config.token @@ -511,7 +634,7 @@ func extractMessageFromStreamingBody(data []byte, jsonPath string) string { return strings.Join(strChunks, "") } -func marshalStr(raw string, log log.Log) string { +func marshalStr(raw string) string { helper := map[string]string{ "placeholder": raw, } diff --git a/plugins/wasm-go/extensions/ai-statistics/go.mod b/plugins/wasm-go/extensions/ai-statistics/go.mod index 9a18c19c1..86442cfd2 100644 --- a/plugins/wasm-go/extensions/ai-statistics/go.mod +++ b/plugins/wasm-go/extensions/ai-statistics/go.mod @@ -6,7 +6,7 @@ toolchain go1.24.4 require ( github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250611100342-5654e89a7a80 - github.com/higress-group/wasm-go v1.0.1 + github.com/higress-group/wasm-go v1.0.2-0.20250729071413-2478fd585950 github.com/tidwall/gjson v1.18.0 ) @@ -15,4 +15,5 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/resp v0.1.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect ) diff --git a/plugins/wasm-go/extensions/ai-statistics/go.sum b/plugins/wasm-go/extensions/ai-statistics/go.sum index 567122bfd..ddfabe60e 100644 --- a/plugins/wasm-go/extensions/ai-statistics/go.sum +++ b/plugins/wasm-go/extensions/ai-statistics/go.sum @@ -4,12 +4,13 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250611100342-5654e89a7a80 h1:xqmtTZI0JQ2O+Lg9/CE6c+Tw9KD6FnvWw8EpLVuuvfg= github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250611100342-5654e89a7a80/go.mod h1:tRI2LfMudSkKHhyv1uex3BWzcice2s/l8Ah8axporfA= -github.com/higress-group/wasm-go v1.0.1 h1:T1m++qTEANp8+jwE0sxltwtaTKmrHCkLOp1m9N+YeqY= -github.com/higress-group/wasm-go v1.0.1/go.mod h1:9k7L730huS/q4V5iH9WLDgf5ZUHEtfhM/uXcegKDG/M= +github.com/higress-group/wasm-go v1.0.2-0.20250729071413-2478fd585950 h1:X4a+wzGEuLkCcAX2XiDf/vcVOIdZWxtEo0YkT+F/mcM= +github.com/higress-group/wasm-go v1.0.2-0.20250729071413-2478fd585950/go.mod h1:9k7L730huS/q4V5iH9WLDgf5ZUHEtfhM/uXcegKDG/M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -19,5 +20,7 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE= github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=