mirror of
https://github.com/alibaba/higress.git
synced 2026-05-21 11:17:28 +08:00
feat(ai-security-guard): enhance risk action resolution and support sensitive data masking (#3690)
Co-authored-by: rinfx <yucheng.lxr@alibaba-inc.com>
This commit is contained in:
@@ -4,11 +4,14 @@ import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
mrand "math/rand"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/higress-group/wasm-go/pkg/wrapper"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
func GenerateHexID(length int) (string, error) {
|
||||
@@ -41,3 +44,109 @@ func ExtractMessageFromStreamingBody(data []byte, jsonPath string) string {
|
||||
func GetConsumer(ctx wrapper.HttpContext) string {
|
||||
return ctx.GetStringContext("consumer", "")
|
||||
}
|
||||
|
||||
func ReplaceJsonFieldContent(body []byte, jsonPath string, newContent string) ([]byte, error) {
|
||||
return sjson.SetBytes(body, resolveJsonPath(body, jsonPath), newContent)
|
||||
}
|
||||
|
||||
// ReplaceJsonFieldTextContent replaces text content at jsonPath, handling both
|
||||
// string and array (multimodal) content formats. When the field is an array
|
||||
// (e.g. OpenAI multimodal content with text + image_url items), only the text
|
||||
// items are updated while image_url and other items are preserved.
|
||||
func ReplaceJsonFieldTextContent(body []byte, jsonPath string, newContent string) ([]byte, error) {
|
||||
resolved := resolveJsonPath(body, jsonPath)
|
||||
fieldValue := gjson.GetBytes(body, resolved)
|
||||
if !fieldValue.IsArray() {
|
||||
// Simple string content — replace directly
|
||||
return sjson.SetBytes(body, resolved, newContent)
|
||||
}
|
||||
// Array content (multimodal): replace text items, preserve others
|
||||
result := body
|
||||
var err error
|
||||
remaining := newContent
|
||||
items := fieldValue.Array()
|
||||
// Collect original text lengths for proportional splitting
|
||||
type textEntry struct {
|
||||
index int
|
||||
text string
|
||||
}
|
||||
var textEntries []textEntry
|
||||
totalTextLen := 0
|
||||
for i, item := range items {
|
||||
if item.Get("type").String() == "text" {
|
||||
t := item.Get("text").String()
|
||||
textEntries = append(textEntries, textEntry{index: i, text: t})
|
||||
totalTextLen += utf8.RuneCountInString(t)
|
||||
}
|
||||
}
|
||||
if len(textEntries) == 0 {
|
||||
// No text items found, nothing to replace
|
||||
return body, nil
|
||||
}
|
||||
// If there's only one text item, put all desensitized content there
|
||||
if len(textEntries) == 1 {
|
||||
itemPath := fmt.Sprintf("%s.%d.text", resolved, textEntries[0].index)
|
||||
return sjson.SetBytes(result, itemPath, newContent)
|
||||
}
|
||||
// Multiple text items: split desensitized content proportionally by original lengths
|
||||
for j, entry := range textEntries {
|
||||
var replacement string
|
||||
if j == len(textEntries)-1 {
|
||||
// Last text item gets all remaining content
|
||||
replacement = remaining
|
||||
} else {
|
||||
// Proportional split based on original text length (rune-aware)
|
||||
var proportion int
|
||||
if totalTextLen == 0 {
|
||||
// All original text items are empty; roughly even with remainder on later segments
|
||||
proportion = utf8.RuneCountInString(newContent) / len(textEntries)
|
||||
} else {
|
||||
proportion = utf8.RuneCountInString(entry.text) * utf8.RuneCountInString(newContent) / totalTextLen
|
||||
}
|
||||
runeCount := utf8.RuneCountInString(remaining)
|
||||
if proportion > runeCount {
|
||||
proportion = runeCount
|
||||
}
|
||||
// Convert rune count to byte offset to split at character boundary
|
||||
byteOffset := 0
|
||||
for i := 0; i < proportion; i++ {
|
||||
_, size := utf8.DecodeRuneInString(remaining[byteOffset:])
|
||||
byteOffset += size
|
||||
}
|
||||
replacement = remaining[:byteOffset]
|
||||
remaining = remaining[byteOffset:]
|
||||
}
|
||||
itemPath := fmt.Sprintf("%s.%d.text", resolved, entry.index)
|
||||
result, err = sjson.SetBytes(result, itemPath, replacement)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveJsonPath converts gjson modifier paths (e.g. "messages.@reverse.0.content")
|
||||
// into concrete index paths (e.g. "messages.2.content") that sjson can handle.
|
||||
func resolveJsonPath(body []byte, jsonPath string) string {
|
||||
parts := strings.Split(jsonPath, ".")
|
||||
var resolved []string
|
||||
for i := 0; i < len(parts); i++ {
|
||||
if strings.HasPrefix(parts[i], "@reverse") && i+1 < len(parts) {
|
||||
// Get the array at the path resolved so far
|
||||
arrayPath := strings.Join(resolved, ".")
|
||||
arrayLen := int(gjson.GetBytes(body, arrayPath+".#").Int())
|
||||
// Next part should be the reversed index
|
||||
i++
|
||||
reversedIdx := 0
|
||||
fmt.Sscanf(parts[i], "%d", &reversedIdx)
|
||||
actualIdx := arrayLen - 1 - reversedIdx
|
||||
if actualIdx < 0 {
|
||||
actualIdx = 0
|
||||
}
|
||||
resolved = append(resolved, fmt.Sprintf("%d", actualIdx))
|
||||
} else {
|
||||
resolved = append(resolved, parts[i])
|
||||
}
|
||||
}
|
||||
return strings.Join(resolved, ".")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user